@myzonerocks/pact 0.1.2 → 0.1.4
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/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +13 -3
- package/dist/src/adapters/erc20.js +93 -49
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +9 -2
- package/dist/src/adapters/mpesa.js +82 -12
- package/dist/src/adapters/paypal.d.ts +15 -3
- package/dist/src/adapters/paypal.js +121 -21
- package/dist/src/adapters/stripe.d.ts +6 -2
- package/dist/src/adapters/stripe.js +25 -12
- package/dist/src/bridge.js +12 -5
- package/dist/src/canonical.js +5 -0
- package/dist/src/client.d.ts +8 -2
- package/dist/src/client.js +181 -20
- package/dist/src/compliance.d.ts +4 -0
- package/dist/src/compliance.js +10 -3
- package/dist/src/crypto.js +4 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/ledger.d.ts +6 -2
- package/dist/src/ledger.js +2 -2
- package/dist/src/leg.d.ts +1 -1
- package/dist/src/message.d.ts +1 -1
- package/dist/src/message.js +8 -5
- package/dist/src/money.d.ts +1 -0
- package/dist/src/money.js +18 -3
- package/dist/src/protocol.d.ts +1 -0
- package/dist/src/protocol.js +8 -0
- package/dist/src/router.d.ts +2 -0
- package/dist/src/router.js +45 -7
- package/dist/src/wire.js +19 -2
- package/dist/test/erc20.test.js +95 -31
- package/dist/test/fake.d.ts +29 -0
- package/dist/test/fake.js +79 -0
- package/dist/test/lifecycle.test.js +31 -3
- package/dist/test/money.test.d.ts +1 -0
- package/dist/test/money.test.js +27 -0
- package/dist/test/mpesa.test.js +41 -8
- package/dist/test/paypal.test.js +54 -8
- package/dist/test/policy.test.js +6 -2
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +52 -0
- package/dist/test/stripe.test.js +5 -4
- package/dist/test/vectors.test.js +48 -2
- package/dist/test/wire.test.js +15 -0
- package/package.json +1 -1
- package/src/adapter.ts +7 -2
- package/src/adapters/erc20.ts +118 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +102 -13
- package/src/adapters/paypal.ts +168 -22
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +194 -22
- package/src/compliance.ts +20 -3
- package/src/crypto.ts +4 -1
- package/src/index.ts +0 -1
- package/src/ledger.ts +12 -4
- package/src/leg.ts +4 -1
- package/src/message.ts +8 -5
- package/src/money.ts +19 -3
- package/src/protocol.ts +9 -0
- package/src/router.ts +44 -4
- package/src/wire.ts +20 -3
- package/src/fake.ts +0 -96
package/dist/src/adapter.d.ts
CHANGED
|
@@ -14,5 +14,5 @@ export interface AdapterEvent {
|
|
|
14
14
|
settledAt: number;
|
|
15
15
|
}
|
|
16
16
|
export interface WebhookParser {
|
|
17
|
-
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[]
|
|
17
|
+
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[] | Promise<AdapterEvent[]>;
|
|
18
18
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Money } from "../money.js";
|
|
1
2
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
3
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
4
|
import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
|
|
@@ -7,11 +8,16 @@ export interface Call {
|
|
|
7
8
|
}
|
|
8
9
|
export interface ChainReceipt {
|
|
9
10
|
status: "success" | "reverted" | "pending";
|
|
11
|
+
blockNumber: number;
|
|
10
12
|
blockTimestampMs: number;
|
|
13
|
+
tokenAddress: string;
|
|
14
|
+
to: string;
|
|
15
|
+
amount: bigint;
|
|
11
16
|
}
|
|
12
17
|
export interface ChainClient {
|
|
13
18
|
send(call: Call): Promise<string>;
|
|
14
19
|
receipt(txHash: string): Promise<ChainReceipt>;
|
|
20
|
+
blockNumber(): Promise<number>;
|
|
15
21
|
}
|
|
16
22
|
export interface Erc20Config {
|
|
17
23
|
id?: string;
|
|
@@ -20,6 +26,8 @@ export interface Erc20Config {
|
|
|
20
26
|
rail?: string;
|
|
21
27
|
chain: ChainClient;
|
|
22
28
|
ids: () => string;
|
|
29
|
+
decimals: number;
|
|
30
|
+
minConfirmations?: number;
|
|
23
31
|
}
|
|
24
32
|
export declare class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
25
33
|
readonly id: string;
|
|
@@ -28,16 +36,18 @@ export declare class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
28
36
|
private readonly rail;
|
|
29
37
|
private readonly chain;
|
|
30
38
|
private readonly ids;
|
|
39
|
+
private readonly decimals;
|
|
40
|
+
private readonly minConf;
|
|
31
41
|
private readonly sent;
|
|
32
42
|
constructor(cfg: Erc20Config);
|
|
43
|
+
private checkScale;
|
|
33
44
|
payInCapabilities(): PayInCapabilities;
|
|
34
45
|
payOutCapabilities(): PayOutCapabilities;
|
|
35
46
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
36
47
|
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
37
|
-
refundIn(
|
|
38
|
-
reverseOut(
|
|
48
|
+
refundIn(_intentId: string, _kind: RefundKind, _amount: Money, _reason: string): Promise<Settlement>;
|
|
49
|
+
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
39
50
|
settlementEvent(intentId: string): Promise<AdapterEvent>;
|
|
40
51
|
private transfer;
|
|
41
|
-
private terminal;
|
|
42
52
|
}
|
|
43
53
|
export declare function transferCalldata(recipient: string, amount: bigint): string;
|
|
@@ -10,6 +10,10 @@ import { RefundKind } from "../adapter.js";
|
|
|
10
10
|
// The 4-byte selector for the ERC-20 transfer(address,uint256) call. It is a
|
|
11
11
|
// fixed constant of the standard, so building the calldata needs no hashing.
|
|
12
12
|
const transferSelector = "a9059cbb";
|
|
13
|
+
// The confirmation depth a transfer must reach before it settles. A shallow
|
|
14
|
+
// success can still be reorged out, so a settlement waits for enough blocks on
|
|
15
|
+
// top of it.
|
|
16
|
+
const defaultMinConfirmations = 12;
|
|
13
17
|
// Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
|
|
14
18
|
// counter-transfer only, because a token transfer is irreversible.
|
|
15
19
|
export class Erc20Leg {
|
|
@@ -19,20 +23,42 @@ export class Erc20Leg {
|
|
|
19
23
|
rail;
|
|
20
24
|
chain;
|
|
21
25
|
ids;
|
|
26
|
+
decimals;
|
|
27
|
+
minConf;
|
|
28
|
+
// Each broadcast transfer and what it was meant to move, so a settlement can
|
|
29
|
+
// confirm the mined transaction really matches the payment.
|
|
22
30
|
sent = new Map();
|
|
23
31
|
constructor(cfg) {
|
|
24
32
|
if (!cfg.token || !cfg.currency) {
|
|
25
33
|
throw new Error("erc20: config requires a token address and currency");
|
|
26
34
|
}
|
|
35
|
+
// The token address is fixed for the leg's lifetime, so validate it once here
|
|
36
|
+
// rather than discover a malformed contract address at the first transfer.
|
|
37
|
+
if (!/^[0-9a-f]{40}$/.test(normalizeAddress(cfg.token))) {
|
|
38
|
+
throw new Error(`erc20: token ${JSON.stringify(cfg.token)} is not a 20-byte address`);
|
|
39
|
+
}
|
|
27
40
|
this.id = cfg.id ?? "erc20";
|
|
28
41
|
this.token = cfg.token;
|
|
29
42
|
this.currency = cfg.currency;
|
|
30
43
|
this.rail = cfg.rail ?? "erc20";
|
|
31
44
|
this.chain = cfg.chain;
|
|
32
45
|
this.ids = cfg.ids;
|
|
46
|
+
this.decimals = cfg.decimals;
|
|
47
|
+
this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
|
|
48
|
+
}
|
|
49
|
+
// checkScale refuses an amount whose exponent does not match the token's
|
|
50
|
+
// decimals, so a quote priced at the wrong scale never moves the wrong number
|
|
51
|
+
// of tokens.
|
|
52
|
+
checkScale(m) {
|
|
53
|
+
if (m.exponent !== this.decimals) {
|
|
54
|
+
throw new Error(`erc20: amount exponent ${m.exponent} does not match the token's ${this.decimals} decimals`);
|
|
55
|
+
}
|
|
33
56
|
}
|
|
34
57
|
payInCapabilities() {
|
|
35
|
-
|
|
58
|
+
// A token collection is irreversible and the leg holds no custody to send the
|
|
59
|
+
// payer back, so it advertises no refund rather than a capability it cannot
|
|
60
|
+
// honour. An adopter that wires refund custody supplies a leg that offers one.
|
|
61
|
+
return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.None };
|
|
36
62
|
}
|
|
37
63
|
payOutCapabilities() {
|
|
38
64
|
return { rails: [this.rail], currencies: [this.currency], reversible: false };
|
|
@@ -41,62 +67,82 @@ export class Erc20Leg {
|
|
|
41
67
|
// direct corridor, the escrow for a bridged one. received is the net a bridge
|
|
42
68
|
// would convert: the source the payer paid less the corridor fees.
|
|
43
69
|
async collect(intentId, quote, _auth, deliverTo) {
|
|
70
|
+
this.checkScale(quote.srcAmount);
|
|
44
71
|
const net = quote.srcAmount.sub(quote.fees);
|
|
72
|
+
// A repeat for the same intent returns the transfer already broadcast rather
|
|
73
|
+
// than sending the payer's tokens twice.
|
|
74
|
+
const prev = this.sent.get(intentId);
|
|
75
|
+
if (prev) {
|
|
76
|
+
return { providerRef: prev.txHash, received: net };
|
|
77
|
+
}
|
|
45
78
|
const txHash = await this.transfer(deliverTo, net.value());
|
|
46
|
-
this.sent.set(intentId, txHash);
|
|
79
|
+
this.sent.set(intentId, { txHash, to: normalizeAddress(deliverTo), amount: net.value() });
|
|
47
80
|
return { providerRef: txHash, received: net };
|
|
48
81
|
}
|
|
49
82
|
// disburse delivers the recipient's tokens.
|
|
50
83
|
async disburse(intentId, quote, recipientRef) {
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
this.checkScale(quote.dstAmount);
|
|
85
|
+
// A repeat for the same intent returns the transfer already broadcast rather
|
|
86
|
+
// than delivering the recipient's tokens twice.
|
|
87
|
+
const prev = this.sent.get(intentId);
|
|
88
|
+
if (prev) {
|
|
89
|
+
return { providerRef: prev.txHash };
|
|
90
|
+
}
|
|
91
|
+
const amount = quote.dstAmount.value();
|
|
92
|
+
const txHash = await this.transfer(recipientRef, amount);
|
|
93
|
+
this.sent.set(intentId, { txHash, to: normalizeAddress(recipientRef), amount });
|
|
53
94
|
return { providerRef: txHash };
|
|
54
95
|
}
|
|
55
|
-
// refundIn
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return this.terminal(intentId, reason);
|
|
96
|
+
// refundIn refuses: an ERC-20 collection is irreversible and the leg holds no
|
|
97
|
+
// custody to counter-transfer the payer back, so it will not record a refund
|
|
98
|
+
// that moves no tokens. An adopter that wires refund custody supplies a leg
|
|
99
|
+
// that advertises and honours a refund.
|
|
100
|
+
async refundIn(_intentId, _kind, _amount, _reason) {
|
|
101
|
+
throw new Error("erc20: a token collection cannot be refunded in place; wire refund custody to return the payer");
|
|
62
102
|
}
|
|
63
|
-
|
|
64
|
-
|
|
103
|
+
// reverseOut refuses, reporting the truth of the rail: a delivered token
|
|
104
|
+
// transfer cannot be pulled back.
|
|
105
|
+
async reverseOut(_intentId, _reason) {
|
|
106
|
+
throw new Error("erc20: a delivered token transfer cannot be reversed");
|
|
65
107
|
}
|
|
66
|
-
// settlementEvent reads
|
|
67
|
-
//
|
|
68
|
-
// the
|
|
108
|
+
// settlementEvent reads a broadcast transfer back from chain and reports its
|
|
109
|
+
// outcome. A token transfer has no webhook, so a host polls this. It settles
|
|
110
|
+
// only when the transaction is final: mined successfully, buried under the
|
|
111
|
+
// required confirmations, and carrying a Transfer of the configured token, in
|
|
112
|
+
// the amount that was sent, to the recipient it was sent to. A pending or
|
|
113
|
+
// shallow success stays submitted so the host keeps polling; a revert or a
|
|
114
|
+
// mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
|
|
69
115
|
async settlementEvent(intentId) {
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
116
|
+
const rec = this.sent.get(intentId);
|
|
117
|
+
if (!rec) {
|
|
72
118
|
throw new Error("erc20: no broadcast transaction for intent");
|
|
73
119
|
}
|
|
74
|
-
const receipt = await this.chain.receipt(txHash);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
state:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
120
|
+
const receipt = await this.chain.receipt(rec.txHash);
|
|
121
|
+
const base = { intentId, providerTxRef: rec.txHash, onchainTxHash: rec.txHash, settledAt: 0 };
|
|
122
|
+
if (receipt.status === "reverted") {
|
|
123
|
+
return { ...base, state: State.Failed, reason: "transaction reverted" };
|
|
124
|
+
}
|
|
125
|
+
if (receipt.status !== "success") {
|
|
126
|
+
return { ...base, state: State.Submitted, reason: "" };
|
|
127
|
+
}
|
|
128
|
+
const head = await this.chain.blockNumber();
|
|
129
|
+
if (receipt.blockNumber === 0 || head < receipt.blockNumber || head - receipt.blockNumber + 1 < this.minConf) {
|
|
130
|
+
return { ...base, state: State.Submitted, reason: "" };
|
|
131
|
+
}
|
|
132
|
+
if (!sameAddress(receipt.tokenAddress, this.token) ||
|
|
133
|
+
!sameAddress(receipt.to, rec.to) ||
|
|
134
|
+
receipt.amount !== rec.amount) {
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
state: State.Failed,
|
|
138
|
+
reason: "on-chain transfer does not match the expected token, recipient, or amount",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { ...base, state: State.Settled, reason: "", settledAt: receipt.blockTimestampMs };
|
|
83
142
|
}
|
|
84
143
|
async transfer(to, amount) {
|
|
85
144
|
return this.chain.send({ to: this.token, data: transferCalldata(to, amount) });
|
|
86
145
|
}
|
|
87
|
-
terminal(intentId, reason) {
|
|
88
|
-
const txHash = this.sent.get(intentId) ?? "";
|
|
89
|
-
return {
|
|
90
|
-
intentId,
|
|
91
|
-
state: State.Refunded,
|
|
92
|
-
adapterId: this.id,
|
|
93
|
-
providerTxRef: txHash,
|
|
94
|
-
onchainTxHash: txHash,
|
|
95
|
-
receiptHash: new Uint8Array(0),
|
|
96
|
-
reason,
|
|
97
|
-
settledAt: 0,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
146
|
}
|
|
101
147
|
// transferCalldata builds the ERC-20 transfer calldata: the selector, the
|
|
102
148
|
// recipient address left-padded to 32 bytes, and the amount as a 32-byte word.
|
|
@@ -114,13 +160,11 @@ export function transferCalldata(recipient, amount) {
|
|
|
114
160
|
}
|
|
115
161
|
return "0x" + transferSelector + address.padStart(64, "0") + amountHex.padStart(64, "0");
|
|
116
162
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return State.Submitted;
|
|
125
|
-
}
|
|
163
|
+
// normalizeAddress lowercases an EVM address and drops any 0x prefix, so two
|
|
164
|
+
// spellings of the same address compare equal.
|
|
165
|
+
function normalizeAddress(addr) {
|
|
166
|
+
return addr.toLowerCase().replace(/^0x/, "");
|
|
167
|
+
}
|
|
168
|
+
function sameAddress(a, b) {
|
|
169
|
+
return normalizeAddress(a) === normalizeAddress(b);
|
|
126
170
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Shared HTTP settings for the network adapters. A provider call that never
|
|
2
|
+
// answers must not hang the corridor forever, so every outbound request carries
|
|
3
|
+
// the same deadline. The Go adapters bound their http.Client the same way.
|
|
4
|
+
// httpTimeoutMs bounds how long an outbound provider request may run before it is
|
|
5
|
+
// aborted. It matches the 30-second ceiling the Go adapters use.
|
|
6
|
+
export const httpTimeoutMs = 30_000;
|
|
7
|
+
// fetchWithTimeout issues a fetch that aborts once httpTimeoutMs elapses, so a
|
|
8
|
+
// stalled provider surfaces as an error rather than an unbounded wait. A caller
|
|
9
|
+
// that already supplies a signal is left untouched.
|
|
10
|
+
export function fetchWithTimeout(url, init = {}) {
|
|
11
|
+
return fetch(url, { signal: AbortSignal.timeout(httpTimeoutMs), ...init });
|
|
12
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Money } from "../money.js";
|
|
1
2
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
3
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
4
|
import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
|
|
@@ -24,9 +25,15 @@ export interface B2CResult {
|
|
|
24
25
|
conversationId: string;
|
|
25
26
|
responseCode: string;
|
|
26
27
|
}
|
|
28
|
+
export interface StkQueryResult {
|
|
29
|
+
resultCode: number;
|
|
30
|
+
resultDesc: string;
|
|
31
|
+
pending: boolean;
|
|
32
|
+
}
|
|
27
33
|
export interface DarajaApi {
|
|
28
34
|
stkPush(params: StkPushParams): Promise<StkPushResult>;
|
|
29
35
|
b2cPayment(params: B2CParams): Promise<B2CResult>;
|
|
36
|
+
query(checkoutRequestId: string): Promise<StkQueryResult>;
|
|
30
37
|
}
|
|
31
38
|
export interface MpesaConfig {
|
|
32
39
|
id?: string;
|
|
@@ -46,9 +53,9 @@ export declare class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
46
53
|
payOutCapabilities(): PayOutCapabilities;
|
|
47
54
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
48
55
|
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
49
|
-
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
56
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
50
57
|
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
51
|
-
parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): AdapterEvent[]
|
|
58
|
+
parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]>;
|
|
52
59
|
}
|
|
53
60
|
export declare function normalizePhone(phone: string): string;
|
|
54
61
|
export interface Credentials {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { State } from "../state.js";
|
|
2
2
|
import { RefundKind } from "../adapter.js";
|
|
3
|
+
import { fetchWithTimeout } from "./http.js";
|
|
3
4
|
// The public Daraja host. It is the same for every integration and holds no
|
|
4
5
|
// secret; tests point the leg at a local server instead.
|
|
5
6
|
const defaultBaseURL = "https://api.safaricom.co.ke";
|
|
@@ -70,16 +71,21 @@ export class MpesaLeg {
|
|
|
70
71
|
// refundIn answers a collected payment with a business-to-customer payout back
|
|
71
72
|
// to the payer. An STK collection cannot be reversed in place, so a
|
|
72
73
|
// counter-transfer is the only refund this rail supports.
|
|
73
|
-
async refundIn(intentId, kind, reason) {
|
|
74
|
+
async refundIn(intentId, kind, amount, reason) {
|
|
74
75
|
if (kind !== RefundKind.CounterTransfer) {
|
|
75
76
|
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
76
77
|
}
|
|
78
|
+
const shillings = wholeShillings(amount);
|
|
77
79
|
const rec = this.byIntent.get(intentId);
|
|
78
80
|
if (!rec) {
|
|
79
81
|
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
80
82
|
}
|
|
83
|
+
// A refund cannot return more shillings than the push collected.
|
|
84
|
+
if (shillings > rec.amount) {
|
|
85
|
+
throw new Error(`mpesa: refund of ${shillings} exceeds the ${rec.amount} collected`);
|
|
86
|
+
}
|
|
81
87
|
const result = await this.api.b2cPayment({
|
|
82
|
-
amount:
|
|
88
|
+
amount: shillings,
|
|
83
89
|
phone: rec.payerPhone,
|
|
84
90
|
reference: intentId,
|
|
85
91
|
remarks: reason,
|
|
@@ -101,18 +107,26 @@ export class MpesaLeg {
|
|
|
101
107
|
throw new Error("mpesa: a delivered payout cannot be reversed");
|
|
102
108
|
}
|
|
103
109
|
// parseWebhook reads an STK callback and normalizes it into a protocol event.
|
|
104
|
-
// The callback is unsigned
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
110
|
+
// The callback is unsigned and its CheckoutRequestID is a value we hand back to
|
|
111
|
+
// the caller — not a secret — so the callback body is treated only as a nudge.
|
|
112
|
+
// The real outcome is read back from Daraja with our own credentials, and a
|
|
113
|
+
// settlement is emitted only when that authenticated query confirms success and
|
|
114
|
+
// the amount Daraja paid equals the amount we authorized. The headers are
|
|
115
|
+
// accepted for interface symmetry and for a host that adds its own gate on top.
|
|
116
|
+
async parseWebhook(raw, _headers) {
|
|
109
117
|
const envelope = JSON.parse(new TextDecoder().decode(raw));
|
|
110
118
|
const cb = envelope.Body?.stkCallback;
|
|
111
119
|
const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
|
|
112
120
|
if (!cb || !rec) {
|
|
113
121
|
throw new Error(ErrUnknownCheckout);
|
|
114
122
|
}
|
|
115
|
-
|
|
123
|
+
const confirmed = await this.api.query(cb.CheckoutRequestID);
|
|
124
|
+
// No outcome yet — wait for a later callback rather than settling or failing
|
|
125
|
+
// on an unconfirmed body.
|
|
126
|
+
if (confirmed.pending) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
if (confirmed.resultCode !== 0) {
|
|
116
130
|
rec.state = State.Failed;
|
|
117
131
|
return [
|
|
118
132
|
{
|
|
@@ -120,11 +134,17 @@ export class MpesaLeg {
|
|
|
120
134
|
state: State.Failed,
|
|
121
135
|
providerTxRef: cb.CheckoutRequestID,
|
|
122
136
|
onchainTxHash: "",
|
|
123
|
-
reason:
|
|
137
|
+
reason: confirmed.resultDesc,
|
|
124
138
|
settledAt: 0,
|
|
125
139
|
},
|
|
126
140
|
];
|
|
127
141
|
}
|
|
142
|
+
// The amount Daraja collected must equal the amount we authorized; a partial
|
|
143
|
+
// or tampered collection settles nothing.
|
|
144
|
+
const paid = metadataInt(cb.CallbackMetadata?.Item ?? [], "Amount");
|
|
145
|
+
if (paid !== rec.amount) {
|
|
146
|
+
throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
|
|
147
|
+
}
|
|
128
148
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
129
149
|
rec.state = State.Settled;
|
|
130
150
|
return [
|
|
@@ -152,6 +172,19 @@ function wholeShillings(m) {
|
|
|
152
172
|
}
|
|
153
173
|
return Number(amount);
|
|
154
174
|
}
|
|
175
|
+
// metadataInt pulls a named numeric value out of the callback metadata items,
|
|
176
|
+
// used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
|
|
177
|
+
// as a number with a fractional part, so it is floored to the shilling.
|
|
178
|
+
function metadataInt(items, name) {
|
|
179
|
+
for (const item of items) {
|
|
180
|
+
if (item.Name !== name) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const n = Number(item.Value);
|
|
184
|
+
return Number.isFinite(n) ? Math.floor(n) : 0;
|
|
185
|
+
}
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
155
188
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
156
189
|
function metadataString(items, name) {
|
|
157
190
|
for (const item of items) {
|
|
@@ -183,14 +216,21 @@ class HttpDarajaApi {
|
|
|
183
216
|
creds;
|
|
184
217
|
baseURL;
|
|
185
218
|
now;
|
|
219
|
+
cachedToken = "";
|
|
220
|
+
tokenExpiryMs = 0;
|
|
186
221
|
constructor(creds, now) {
|
|
187
222
|
this.creds = creds;
|
|
188
223
|
this.baseURL = creds.baseURL || defaultBaseURL;
|
|
189
224
|
this.now = now;
|
|
190
225
|
}
|
|
191
226
|
async token() {
|
|
227
|
+
// Reuse the cached token until it is within a minute of expiry, so a burst of
|
|
228
|
+
// pushes does not re-authenticate against Daraja on every call.
|
|
229
|
+
if (this.cachedToken && this.now().getTime() < this.tokenExpiryMs - 60_000) {
|
|
230
|
+
return this.cachedToken;
|
|
231
|
+
}
|
|
192
232
|
const basic = Buffer.from(`${this.creds.consumerKey}:${this.creds.consumerSecret}`).toString("base64");
|
|
193
|
-
const resp = await
|
|
233
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
|
|
194
234
|
method: "GET",
|
|
195
235
|
headers: { Authorization: `Basic ${basic}` },
|
|
196
236
|
});
|
|
@@ -198,7 +238,11 @@ class HttpDarajaApi {
|
|
|
198
238
|
throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
|
|
199
239
|
}
|
|
200
240
|
const out = (await resp.json());
|
|
201
|
-
|
|
241
|
+
// Daraja tokens live an hour; fall back to that if the field is absent.
|
|
242
|
+
const ttlSeconds = Number(out.expires_in) > 0 ? Number(out.expires_in) : 3599;
|
|
243
|
+
this.cachedToken = out.access_token ?? "";
|
|
244
|
+
this.tokenExpiryMs = this.now().getTime() + ttlSeconds * 1000;
|
|
245
|
+
return this.cachedToken;
|
|
202
246
|
}
|
|
203
247
|
// password is the base64 of shortcode+passkey+timestamp Daraja requires on each
|
|
204
248
|
// STK push.
|
|
@@ -228,6 +272,32 @@ class HttpDarajaApi {
|
|
|
228
272
|
responseCode: out.ResponseCode ?? "",
|
|
229
273
|
};
|
|
230
274
|
}
|
|
275
|
+
async query(checkoutRequestId) {
|
|
276
|
+
const token = await this.token();
|
|
277
|
+
const timestamp = formatTimestamp(this.now());
|
|
278
|
+
const body = {
|
|
279
|
+
BusinessShortCode: this.creds.shortCode,
|
|
280
|
+
Password: this.password(timestamp),
|
|
281
|
+
Timestamp: timestamp,
|
|
282
|
+
CheckoutRequestID: checkoutRequestId,
|
|
283
|
+
};
|
|
284
|
+
const resp = await fetchWithTimeout(this.baseURL + "/mpesa/stkpushquery/v1/query", {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
287
|
+
body: JSON.stringify(body),
|
|
288
|
+
});
|
|
289
|
+
const out = (await resp.json());
|
|
290
|
+
// Daraja answers a query for a push it is still processing with an error code
|
|
291
|
+
// rather than a result; treat that as pending so the outcome is confirmed on a
|
|
292
|
+
// later query rather than mistaken for a failure.
|
|
293
|
+
if (out.errorCode === "500.001.1001") {
|
|
294
|
+
return { resultCode: 0, resultDesc: "", pending: true };
|
|
295
|
+
}
|
|
296
|
+
if (resp.status >= 300) {
|
|
297
|
+
throw new Error(`mpesa: stk query returned ${resp.status}`);
|
|
298
|
+
}
|
|
299
|
+
return { resultCode: Number(out.ResultCode ?? -1), resultDesc: out.ResultDesc ?? "", pending: false };
|
|
300
|
+
}
|
|
231
301
|
async b2cPayment(params) {
|
|
232
302
|
const token = await this.token();
|
|
233
303
|
const body = {
|
|
@@ -243,7 +313,7 @@ class HttpDarajaApi {
|
|
|
243
313
|
return { conversationId: out.ConversationID ?? "", responseCode: out.ResponseCode ?? "" };
|
|
244
314
|
}
|
|
245
315
|
async postJSON(token, path, body) {
|
|
246
|
-
const resp = await
|
|
316
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
247
317
|
method: "POST",
|
|
248
318
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
249
319
|
body: JSON.stringify(body),
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
import type { Money } from "../money.js";
|
|
1
2
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
3
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
|
-
import type {
|
|
4
|
+
import type { PayOutLeg, InteractivePayInLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult, PayInPreparation } from "../leg.js";
|
|
4
5
|
export declare const ErrSignatureMismatch = "paypal: webhook signature does not verify";
|
|
5
6
|
export interface CreateOrderParams {
|
|
6
7
|
value: string;
|
|
7
8
|
currencyCode: string;
|
|
8
9
|
payee: string;
|
|
9
10
|
referenceId: string;
|
|
11
|
+
platformFee?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Order {
|
|
14
|
+
id: string;
|
|
15
|
+
status: string;
|
|
16
|
+
approveUrl: string;
|
|
10
17
|
}
|
|
11
18
|
export interface Capture {
|
|
12
19
|
orderId: string;
|
|
@@ -26,6 +33,8 @@ export interface Payout {
|
|
|
26
33
|
export interface RefundParams {
|
|
27
34
|
captureId: string;
|
|
28
35
|
reason: string;
|
|
36
|
+
value?: string;
|
|
37
|
+
currencyCode?: string;
|
|
29
38
|
}
|
|
30
39
|
export interface Refund {
|
|
31
40
|
id: string;
|
|
@@ -33,6 +42,7 @@ export interface Refund {
|
|
|
33
42
|
}
|
|
34
43
|
export interface PaypalApi {
|
|
35
44
|
createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
|
|
45
|
+
createOrder(params: CreateOrderParams): Promise<Order>;
|
|
36
46
|
sendPayout(params: PayoutParams): Promise<Payout>;
|
|
37
47
|
refundCapture(params: RefundParams): Promise<Refund>;
|
|
38
48
|
verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
|
|
@@ -43,18 +53,20 @@ export interface PaypalConfig {
|
|
|
43
53
|
api: PaypalApi;
|
|
44
54
|
ids: () => string;
|
|
45
55
|
}
|
|
46
|
-
export declare class PaypalLeg implements
|
|
56
|
+
export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
47
57
|
readonly id: string;
|
|
48
58
|
private readonly currencies;
|
|
49
59
|
private readonly api;
|
|
50
60
|
private readonly ids;
|
|
51
61
|
private readonly captures;
|
|
62
|
+
private readonly expected;
|
|
52
63
|
constructor(cfg: PaypalConfig);
|
|
53
64
|
payInCapabilities(): PayInCapabilities;
|
|
54
65
|
payOutCapabilities(): PayOutCapabilities;
|
|
55
66
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
67
|
+
prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
|
|
56
68
|
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
57
|
-
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
69
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
58
70
|
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
59
71
|
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): Promise<AdapterEvent[]>;
|
|
60
72
|
}
|