@myzonerocks/pact 0.1.0
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 +18 -0
- package/dist/src/adapter.js +11 -0
- package/dist/src/adapters/erc20.d.ts +43 -0
- package/dist/src/adapters/erc20.js +126 -0
- package/dist/src/adapters/mpesa.d.ts +61 -0
- package/dist/src/adapters/mpesa.js +272 -0
- package/dist/src/adapters/paypal.d.ts +68 -0
- package/dist/src/adapters/paypal.js +279 -0
- package/dist/src/adapters/stripe.d.ts +61 -0
- package/dist/src/adapters/stripe.js +336 -0
- package/dist/src/bridge.d.ts +40 -0
- package/dist/src/bridge.js +99 -0
- package/dist/src/canonical.d.ts +14 -0
- package/dist/src/canonical.js +82 -0
- package/dist/src/client.d.ts +84 -0
- package/dist/src/client.js +510 -0
- package/dist/src/compliance.d.ts +23 -0
- package/dist/src/compliance.js +26 -0
- package/dist/src/crypto.d.ts +12 -0
- package/dist/src/crypto.js +50 -0
- package/dist/src/fake.d.ts +29 -0
- package/dist/src/fake.js +79 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +16 -0
- package/dist/src/ledger.d.ts +47 -0
- package/dist/src/ledger.js +84 -0
- package/dist/src/leg.d.ts +34 -0
- package/dist/src/leg.js +4 -0
- package/dist/src/message.d.ts +55 -0
- package/dist/src/message.js +88 -0
- package/dist/src/money.d.ts +16 -0
- package/dist/src/money.js +81 -0
- package/dist/src/payload.d.ts +11 -0
- package/dist/src/payload.js +52 -0
- package/dist/src/policy.d.ts +26 -0
- package/dist/src/policy.js +83 -0
- package/dist/src/protocol.d.ts +4 -0
- package/dist/src/protocol.js +15 -0
- package/dist/src/router.d.ts +16 -0
- package/dist/src/router.js +87 -0
- package/dist/src/signing.d.ts +27 -0
- package/dist/src/signing.js +72 -0
- package/dist/src/state.d.ts +23 -0
- package/dist/src/state.js +76 -0
- package/dist/src/wire.d.ts +30 -0
- package/dist/src/wire.js +221 -0
- package/dist/test/erc20.test.d.ts +1 -0
- package/dist/test/erc20.test.js +131 -0
- package/dist/test/lifecycle.test.d.ts +1 -0
- package/dist/test/lifecycle.test.js +212 -0
- package/dist/test/mpesa.test.d.ts +1 -0
- package/dist/test/mpesa.test.js +180 -0
- package/dist/test/payload.test.d.ts +1 -0
- package/dist/test/payload.test.js +32 -0
- package/dist/test/paypal.test.d.ts +1 -0
- package/dist/test/paypal.test.js +140 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +131 -0
- package/dist/test/stripe.test.d.ts +1 -0
- package/dist/test/stripe.test.js +176 -0
- package/dist/test/vectors.test.d.ts +1 -0
- package/dist/test/vectors.test.js +91 -0
- package/dist/test/wire.test.d.ts +1 -0
- package/dist/test/wire.test.js +104 -0
- package/package.json +50 -0
- package/src/adapter.ts +32 -0
- package/src/adapters/erc20.ts +181 -0
- package/src/adapters/mpesa.ts +408 -0
- package/src/adapters/paypal.ts +409 -0
- package/src/adapters/stripe.ts +478 -0
- package/src/bridge.ts +148 -0
- package/src/canonical.ts +94 -0
- package/src/client.ts +605 -0
- package/src/compliance.ts +65 -0
- package/src/crypto.ts +68 -0
- package/src/fake.ts +96 -0
- package/src/index.ts +106 -0
- package/src/ledger.ts +145 -0
- package/src/leg.ts +65 -0
- package/src/message.ts +178 -0
- package/src/money.ts +87 -0
- package/src/payload.ts +58 -0
- package/src/policy.ts +110 -0
- package/src/protocol.ts +19 -0
- package/src/router.ts +97 -0
- package/src/signing.ts +92 -0
- package/src/state.ts +76 -0
- package/src/wire.ts +248 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@myzonerocks/pact",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reference TypeScript SDK for the PACT payment abstraction protocol",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/src/index.js",
|
|
11
|
+
"types": "./dist/src/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/src/index.d.ts",
|
|
15
|
+
"import": "./dist/src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./adapters/erc20": {
|
|
18
|
+
"types": "./dist/src/adapters/erc20.d.ts",
|
|
19
|
+
"import": "./dist/src/adapters/erc20.js"
|
|
20
|
+
},
|
|
21
|
+
"./adapters/stripe": {
|
|
22
|
+
"types": "./dist/src/adapters/stripe.d.ts",
|
|
23
|
+
"import": "./dist/src/adapters/stripe.js"
|
|
24
|
+
},
|
|
25
|
+
"./adapters/mpesa": {
|
|
26
|
+
"types": "./dist/src/adapters/mpesa.d.ts",
|
|
27
|
+
"import": "./dist/src/adapters/mpesa.js"
|
|
28
|
+
},
|
|
29
|
+
"./adapters/paypal": {
|
|
30
|
+
"types": "./dist/src/adapters/paypal.d.ts",
|
|
31
|
+
"import": "./dist/src/adapters/paypal.js"
|
|
32
|
+
},
|
|
33
|
+
"./policy": {
|
|
34
|
+
"types": "./dist/src/policy.d.ts",
|
|
35
|
+
"import": "./dist/src/policy.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": ["dist", "src"],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
+
"prepublishOnly": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.10.0",
|
|
47
|
+
"typescript": "^5.7.0",
|
|
48
|
+
"vitest": "^2.1.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { State } from "./state.js";
|
|
2
|
+
|
|
3
|
+
// RefundKind is the shape of refund a rail can perform. A card network can
|
|
4
|
+
// reverse part of a capture; an irreversible rail can only answer with a
|
|
5
|
+
// counter-transfer. A leg declares the truth in its capabilities and the kernel
|
|
6
|
+
// holds it to it.
|
|
7
|
+
export enum RefundKind {
|
|
8
|
+
None = 0,
|
|
9
|
+
Full,
|
|
10
|
+
Partial,
|
|
11
|
+
CounterTransfer,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// AdapterEvent is a provider webhook normalized into protocol terms. Both legs
|
|
15
|
+
// use it: a pay-in webhook reports collection, a pay-out webhook reports
|
|
16
|
+
// delivery.
|
|
17
|
+
export interface AdapterEvent {
|
|
18
|
+
intentId: string;
|
|
19
|
+
state: State;
|
|
20
|
+
providerTxRef: string;
|
|
21
|
+
onchainTxHash: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
settledAt: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// WebhookParser is implemented by legs that receive provider callbacks. It is
|
|
27
|
+
// server-side only and turns a raw payload plus its request headers into
|
|
28
|
+
// normalized events. The headers carry the provider's signature, so the parser
|
|
29
|
+
// can reject a payload it cannot authenticate.
|
|
30
|
+
export interface WebhookParser {
|
|
31
|
+
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
|
|
32
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// ERC-20 leg for the PACT protocol. It is architecture-neutral: signing and
|
|
2
|
+
// broadcast go through an injected chain client, so where the private key lives —
|
|
3
|
+
// on a device, in a server HSM, behind a smart-account validator — is the
|
|
4
|
+
// adopter's decision, not this package's. The leg never holds key material and
|
|
5
|
+
// never prompts for one. It serves both sides of a corridor: as a pay-in leg it
|
|
6
|
+
// moves the payer's tokens to a destination, as a pay-out leg it delivers tokens
|
|
7
|
+
// to the recipient.
|
|
8
|
+
import { State } from "../state.js";
|
|
9
|
+
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
10
|
+
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
11
|
+
import type {
|
|
12
|
+
PayInLeg,
|
|
13
|
+
PayOutLeg,
|
|
14
|
+
PayInCapabilities,
|
|
15
|
+
PayOutCapabilities,
|
|
16
|
+
CollectResult,
|
|
17
|
+
DisburseResult,
|
|
18
|
+
} from "../leg.js";
|
|
19
|
+
|
|
20
|
+
// The 4-byte selector for the ERC-20 transfer(address,uint256) call. It is a
|
|
21
|
+
// fixed constant of the standard, so building the calldata needs no hashing.
|
|
22
|
+
const transferSelector = "a9059cbb";
|
|
23
|
+
|
|
24
|
+
// Call is a contract invocation the chain client signs and broadcasts.
|
|
25
|
+
export interface Call {
|
|
26
|
+
to: string;
|
|
27
|
+
data: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ChainReceipt is the outcome of a broadcast transaction once mined.
|
|
31
|
+
export interface ChainReceipt {
|
|
32
|
+
status: "success" | "reverted" | "pending";
|
|
33
|
+
blockTimestampMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ChainClient is the on-chain surface the leg depends on. On a real client it
|
|
37
|
+
// wraps a smart-account signer that signs silently with the account's ECDSA key
|
|
38
|
+
// and never prompts for a passkey; the private key stays on the device. Depending
|
|
39
|
+
// on an interface keeps the leg testable without a chain or a key.
|
|
40
|
+
export interface ChainClient {
|
|
41
|
+
// send signs and broadcasts a call to a contract, returning the transaction
|
|
42
|
+
// hash. Signing happens inside the client, never in the leg.
|
|
43
|
+
send(call: Call): Promise<string>;
|
|
44
|
+
receipt(txHash: string): Promise<ChainReceipt>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Erc20Config {
|
|
48
|
+
id?: string;
|
|
49
|
+
token: string; // the ERC-20 contract address
|
|
50
|
+
currency: string; // the token's symbol, e.g. "USDC"
|
|
51
|
+
rail?: string;
|
|
52
|
+
chain: ChainClient;
|
|
53
|
+
ids: () => string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
|
|
57
|
+
// counter-transfer only, because a token transfer is irreversible.
|
|
58
|
+
export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
private readonly token: string;
|
|
61
|
+
private readonly currency: string;
|
|
62
|
+
private readonly rail: string;
|
|
63
|
+
private readonly chain: ChainClient;
|
|
64
|
+
private readonly ids: () => string;
|
|
65
|
+
private readonly sent = new Map<string, string>();
|
|
66
|
+
|
|
67
|
+
constructor(cfg: Erc20Config) {
|
|
68
|
+
if (!cfg.token || !cfg.currency) {
|
|
69
|
+
throw new Error("erc20: config requires a token address and currency");
|
|
70
|
+
}
|
|
71
|
+
this.id = cfg.id ?? "erc20";
|
|
72
|
+
this.token = cfg.token;
|
|
73
|
+
this.currency = cfg.currency;
|
|
74
|
+
this.rail = cfg.rail ?? "erc20";
|
|
75
|
+
this.chain = cfg.chain;
|
|
76
|
+
this.ids = cfg.ids;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
payInCapabilities(): PayInCapabilities {
|
|
80
|
+
return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.CounterTransfer };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
payOutCapabilities(): PayOutCapabilities {
|
|
84
|
+
return { rails: [this.rail], currencies: [this.currency], reversible: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// collect moves the payer's tokens to the destination — the recipient for a
|
|
88
|
+
// direct corridor, the escrow for a bridged one. received is the net a bridge
|
|
89
|
+
// would convert: the source the payer paid less the corridor fees.
|
|
90
|
+
async collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult> {
|
|
91
|
+
const net = quote.srcAmount.sub(quote.fees);
|
|
92
|
+
const txHash = await this.transfer(deliverTo, net.value());
|
|
93
|
+
this.sent.set(intentId, txHash);
|
|
94
|
+
return { providerRef: txHash, received: net };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// disburse delivers the recipient's tokens.
|
|
98
|
+
async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
|
|
99
|
+
const txHash = await this.transfer(recipientRef, quote.dstAmount.value());
|
|
100
|
+
this.sent.set(intentId, txHash);
|
|
101
|
+
return { providerRef: txHash };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// refundIn and reverseOut both answer with a counter-transfer, the only refund
|
|
105
|
+
// an irreversible token movement supports.
|
|
106
|
+
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
107
|
+
if (kind !== RefundKind.CounterTransfer) {
|
|
108
|
+
throw new Error("erc20: a token transfer can only be refunded by counter-transfer");
|
|
109
|
+
}
|
|
110
|
+
return this.terminal(intentId, reason);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async reverseOut(intentId: string, reason: string): Promise<Settlement> {
|
|
114
|
+
return this.terminal(intentId, reason);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// settlementEvent reads the receipt for an intent's transaction and produces the
|
|
118
|
+
// event a host feeds back once the transfer confirms. Crypto has no webhook, so
|
|
119
|
+
// the host polls this instead.
|
|
120
|
+
async settlementEvent(intentId: string): Promise<AdapterEvent> {
|
|
121
|
+
const txHash = this.sent.get(intentId);
|
|
122
|
+
if (!txHash) {
|
|
123
|
+
throw new Error("erc20: no broadcast transaction for intent");
|
|
124
|
+
}
|
|
125
|
+
const receipt = await this.chain.receipt(txHash);
|
|
126
|
+
return {
|
|
127
|
+
intentId,
|
|
128
|
+
state: receiptState(receipt),
|
|
129
|
+
providerTxRef: txHash,
|
|
130
|
+
onchainTxHash: txHash,
|
|
131
|
+
reason: receipt.status === "reverted" ? "transaction reverted" : "",
|
|
132
|
+
settledAt: receipt.blockTimestampMs,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async transfer(to: string, amount: bigint): Promise<string> {
|
|
137
|
+
return this.chain.send({ to: this.token, data: transferCalldata(to, amount) });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private terminal(intentId: string, reason: string): Settlement {
|
|
141
|
+
const txHash = this.sent.get(intentId) ?? "";
|
|
142
|
+
return {
|
|
143
|
+
intentId,
|
|
144
|
+
state: State.Refunded,
|
|
145
|
+
adapterId: this.id,
|
|
146
|
+
providerTxRef: txHash,
|
|
147
|
+
onchainTxHash: txHash,
|
|
148
|
+
receiptHash: new Uint8Array(0),
|
|
149
|
+
reason,
|
|
150
|
+
settledAt: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// transferCalldata builds the ERC-20 transfer calldata: the selector, the
|
|
156
|
+
// recipient address left-padded to 32 bytes, and the amount as a 32-byte word.
|
|
157
|
+
export function transferCalldata(recipient: string, amount: bigint): string {
|
|
158
|
+
const address = recipient.toLowerCase().replace(/^0x/, "");
|
|
159
|
+
if (!/^[0-9a-f]{40}$/.test(address)) {
|
|
160
|
+
throw new Error("erc20: recipient is not a 20-byte address");
|
|
161
|
+
}
|
|
162
|
+
if (amount < 0n) {
|
|
163
|
+
throw new Error("erc20: amount must be non-negative");
|
|
164
|
+
}
|
|
165
|
+
const amountHex = amount.toString(16);
|
|
166
|
+
if (amountHex.length > 64) {
|
|
167
|
+
throw new Error("erc20: amount exceeds a 256-bit word");
|
|
168
|
+
}
|
|
169
|
+
return "0x" + transferSelector + address.padStart(64, "0") + amountHex.padStart(64, "0");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function receiptState(receipt: ChainReceipt): State {
|
|
173
|
+
switch (receipt.status) {
|
|
174
|
+
case "success":
|
|
175
|
+
return State.Settled;
|
|
176
|
+
case "reverted":
|
|
177
|
+
return State.Failed;
|
|
178
|
+
default:
|
|
179
|
+
return State.Submitted;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// M-Pesa leg for the PACT protocol, over Safaricom's Daraja API. It runs
|
|
2
|
+
// server-side: it holds the Daraja credentials, triggers an STK push so the payer
|
|
3
|
+
// approves on their handset, and settles from the asynchronous callback. M-Pesa
|
|
4
|
+
// moves whole shillings, so amounts use a zero-exponent KES. On the pay-in side
|
|
5
|
+
// it triggers the STK push and settles from the callback; on the pay-out side it
|
|
6
|
+
// delivers by a business-to-customer transfer. A collection is not reversible in
|
|
7
|
+
// place, so a refund is answered by a counter-transfer.
|
|
8
|
+
import { Money } from "../money.js";
|
|
9
|
+
import { State } from "../state.js";
|
|
10
|
+
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
11
|
+
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
12
|
+
import type {
|
|
13
|
+
PayInLeg,
|
|
14
|
+
PayOutLeg,
|
|
15
|
+
PayInCapabilities,
|
|
16
|
+
PayOutCapabilities,
|
|
17
|
+
CollectResult,
|
|
18
|
+
DisburseResult,
|
|
19
|
+
} from "../leg.js";
|
|
20
|
+
|
|
21
|
+
// The public Daraja host. It is the same for every integration and holds no
|
|
22
|
+
// secret; tests point the leg at a local server instead.
|
|
23
|
+
const defaultBaseURL = "https://api.safaricom.co.ke";
|
|
24
|
+
|
|
25
|
+
// ErrUnknownCheckout reports a callback whose CheckoutRequestID does not match a
|
|
26
|
+
// push this leg initiated. Daraja does not sign callbacks, so matching the
|
|
27
|
+
// checkout id to one we started is the authentication: an unrecognized id is
|
|
28
|
+
// rejected rather than acted on.
|
|
29
|
+
export const ErrUnknownCheckout = "mpesa: callback for an unknown checkout request";
|
|
30
|
+
|
|
31
|
+
// StkPushParams describes a customer-initiated push payment.
|
|
32
|
+
export interface StkPushParams {
|
|
33
|
+
amount: number;
|
|
34
|
+
payerPhone: string; // MSISDN in 2547XXXXXXXX form
|
|
35
|
+
accountReference: string; // the pact intent id travels here
|
|
36
|
+
description: string;
|
|
37
|
+
callbackURL: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// StkPushResult is what Daraja returns when a push is accepted for delivery.
|
|
41
|
+
export interface StkPushResult {
|
|
42
|
+
merchantRequestId: string;
|
|
43
|
+
checkoutRequestId: string;
|
|
44
|
+
responseCode: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// B2CParams describes a business-to-customer payout, used to deliver a payout and
|
|
48
|
+
// to answer a collected payment with a counter-transfer when a refund is
|
|
49
|
+
// requested.
|
|
50
|
+
export interface B2CParams {
|
|
51
|
+
amount: number;
|
|
52
|
+
phone: string;
|
|
53
|
+
reference: string;
|
|
54
|
+
remarks: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// B2CResult is what Daraja returns when a payout is accepted for delivery.
|
|
58
|
+
export interface B2CResult {
|
|
59
|
+
conversationId: string;
|
|
60
|
+
responseCode: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// DarajaApi is the surface of Daraja this leg depends on. Depending on an
|
|
64
|
+
// interface keeps the leg unit-testable without a network and without
|
|
65
|
+
// credentials.
|
|
66
|
+
export interface DarajaApi {
|
|
67
|
+
stkPush(params: StkPushParams): Promise<StkPushResult>;
|
|
68
|
+
b2cPayment(params: B2CParams): Promise<B2CResult>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// push remembers what settling an STK collection needs between initiating it and
|
|
72
|
+
// the unsigned callback that reports its outcome: the intent, the payer to
|
|
73
|
+
// refund, the amount, and the last state the callback recorded.
|
|
74
|
+
interface PushRecord {
|
|
75
|
+
intentId: string;
|
|
76
|
+
payerPhone: string;
|
|
77
|
+
amount: number;
|
|
78
|
+
state: State;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface MpesaConfig {
|
|
82
|
+
id?: string;
|
|
83
|
+
api: DarajaApi;
|
|
84
|
+
callbackURL: string;
|
|
85
|
+
ids: () => string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MpesaLeg moves mobile money over M-Pesa.
|
|
89
|
+
export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
90
|
+
readonly id: string;
|
|
91
|
+
private readonly api: DarajaApi;
|
|
92
|
+
private readonly callbackURL: string;
|
|
93
|
+
private readonly ids: () => string;
|
|
94
|
+
|
|
95
|
+
private readonly byIntent = new Map<string, PushRecord>();
|
|
96
|
+
private readonly byCheckout = new Map<string, PushRecord>();
|
|
97
|
+
|
|
98
|
+
constructor(cfg: MpesaConfig) {
|
|
99
|
+
if (!cfg.callbackURL) {
|
|
100
|
+
throw new Error("mpesa: config requires a callback URL");
|
|
101
|
+
}
|
|
102
|
+
this.id = cfg.id ?? "mpesa";
|
|
103
|
+
this.api = cfg.api;
|
|
104
|
+
this.callbackURL = cfg.callbackURL;
|
|
105
|
+
this.ids = cfg.ids;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
payInCapabilities(): PayInCapabilities {
|
|
109
|
+
return { rails: ["mpesa"], currencies: ["KES"], refunds: RefundKind.CounterTransfer };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
payOutCapabilities(): PayOutCapabilities {
|
|
113
|
+
return { rails: ["mpesa"], currencies: ["KES"], reversible: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// collect triggers the STK push that prompts the payer to approve on their
|
|
117
|
+
// handset. deliverTo is the payer's phone the prompt is sent to; the pact intent
|
|
118
|
+
// id rides along as the account reference, and the returned checkout id is mapped
|
|
119
|
+
// back to the intent so the unsigned callback can be tied to it. The collection
|
|
120
|
+
// resolves out of band through the callback; collect returns the checkout id as
|
|
121
|
+
// its provider reference. received is the net a bridge would convert.
|
|
122
|
+
async collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult> {
|
|
123
|
+
const amount = wholeShillings(quote.srcAmount);
|
|
124
|
+
const net = quote.srcAmount.sub(quote.fees);
|
|
125
|
+
const phone = normalizePhone(deliverTo);
|
|
126
|
+
if (!phone) {
|
|
127
|
+
throw new Error("mpesa: collect requires the payer's phone");
|
|
128
|
+
}
|
|
129
|
+
const rec: PushRecord = { intentId, payerPhone: phone, amount, state: State.Unspecified };
|
|
130
|
+
const result = await this.api.stkPush({
|
|
131
|
+
amount,
|
|
132
|
+
payerPhone: phone,
|
|
133
|
+
accountReference: intentId,
|
|
134
|
+
description: "payment",
|
|
135
|
+
callbackURL: this.callbackURL,
|
|
136
|
+
});
|
|
137
|
+
this.byIntent.set(intentId, rec);
|
|
138
|
+
this.byCheckout.set(result.checkoutRequestId, rec);
|
|
139
|
+
return { providerRef: result.checkoutRequestId, received: net };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// disburse delivers the recipient's shillings by a business-to-customer payout
|
|
143
|
+
// and returns the Daraja conversation id.
|
|
144
|
+
async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
|
|
145
|
+
const amount = wholeShillings(quote.dstAmount);
|
|
146
|
+
const phone = normalizePhone(recipientRef);
|
|
147
|
+
if (!phone) {
|
|
148
|
+
throw new Error("mpesa: disburse requires the recipient's phone");
|
|
149
|
+
}
|
|
150
|
+
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
|
|
151
|
+
return { providerRef: result.conversationId };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// refundIn answers a collected payment with a business-to-customer payout back
|
|
155
|
+
// to the payer. An STK collection cannot be reversed in place, so a
|
|
156
|
+
// counter-transfer is the only refund this rail supports.
|
|
157
|
+
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
158
|
+
if (kind !== RefundKind.CounterTransfer) {
|
|
159
|
+
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
160
|
+
}
|
|
161
|
+
const rec = this.byIntent.get(intentId);
|
|
162
|
+
if (!rec) {
|
|
163
|
+
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
164
|
+
}
|
|
165
|
+
const result = await this.api.b2cPayment({
|
|
166
|
+
amount: rec.amount,
|
|
167
|
+
phone: rec.payerPhone,
|
|
168
|
+
reference: intentId,
|
|
169
|
+
remarks: reason,
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
intentId,
|
|
173
|
+
state: State.Refunded,
|
|
174
|
+
adapterId: this.id,
|
|
175
|
+
providerTxRef: result.conversationId,
|
|
176
|
+
onchainTxHash: "",
|
|
177
|
+
receiptHash: new Uint8Array(0),
|
|
178
|
+
reason,
|
|
179
|
+
settledAt: 0,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// reverseOut reports the truth of the rail: a delivered payout cannot be pulled
|
|
184
|
+
// back, so a pay-out reversal is refused rather than faked.
|
|
185
|
+
async reverseOut(_intentId: string, _reason: string): Promise<Settlement> {
|
|
186
|
+
throw new Error("mpesa: a delivered payout cannot be reversed");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// parseWebhook reads an STK callback and normalizes it into a protocol event.
|
|
190
|
+
// The callback is unsigned, so it is authenticated by matching its checkout id
|
|
191
|
+
// to a push this leg started; an unrecognized id is refused. The headers are
|
|
192
|
+
// accepted for interface symmetry and for a host that adds its own IP or
|
|
193
|
+
// shared-secret gate on top.
|
|
194
|
+
parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): AdapterEvent[] {
|
|
195
|
+
const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
|
|
196
|
+
const cb = envelope.Body?.stkCallback;
|
|
197
|
+
const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
|
|
198
|
+
if (!cb || !rec) {
|
|
199
|
+
throw new Error(ErrUnknownCheckout);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (cb.ResultCode !== 0) {
|
|
203
|
+
rec.state = State.Failed;
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
intentId: rec.intentId,
|
|
207
|
+
state: State.Failed,
|
|
208
|
+
providerTxRef: cb.CheckoutRequestID,
|
|
209
|
+
onchainTxHash: "",
|
|
210
|
+
reason: cb.ResultDesc ?? "",
|
|
211
|
+
settledAt: 0,
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
217
|
+
rec.state = State.Settled;
|
|
218
|
+
return [
|
|
219
|
+
{
|
|
220
|
+
intentId: rec.intentId,
|
|
221
|
+
state: State.Settled,
|
|
222
|
+
providerTxRef: receipt,
|
|
223
|
+
onchainTxHash: "",
|
|
224
|
+
reason: "",
|
|
225
|
+
settledAt: 0,
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface StkCallbackItem {
|
|
232
|
+
Name: string;
|
|
233
|
+
Value: unknown;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface StkCallbackEnvelope {
|
|
237
|
+
Body?: {
|
|
238
|
+
stkCallback?: {
|
|
239
|
+
CheckoutRequestID: string;
|
|
240
|
+
ResultCode: number;
|
|
241
|
+
ResultDesc?: string;
|
|
242
|
+
CallbackMetadata?: { Item?: StkCallbackItem[] };
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// wholeShillings narrows a KES amount to the integer M-Pesa moves. M-Pesa
|
|
248
|
+
// transacts whole shillings, so a fractional exponent is refused rather than
|
|
249
|
+
// silently truncated.
|
|
250
|
+
function wholeShillings(m: Money): number {
|
|
251
|
+
if (m.exponent !== 0) {
|
|
252
|
+
throw new Error(`mpesa: KES amounts must use exponent 0 (whole shillings), got ${m.exponent}`);
|
|
253
|
+
}
|
|
254
|
+
const amount = m.value();
|
|
255
|
+
if (amount > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
256
|
+
throw new Error("mpesa: amount exceeds range");
|
|
257
|
+
}
|
|
258
|
+
return Number(amount);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// metadataString pulls a named string value out of the callback metadata items.
|
|
262
|
+
function metadataString(items: StkCallbackItem[], name: string): string {
|
|
263
|
+
for (const item of items) {
|
|
264
|
+
if (item.Name !== name) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (typeof item.Value === "string") {
|
|
268
|
+
return item.Value;
|
|
269
|
+
}
|
|
270
|
+
return String(item.Value);
|
|
271
|
+
}
|
|
272
|
+
return "";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// normalizePhone trims a Kenyan MSISDN into the 2547XXXXXXXX form Daraja expects,
|
|
276
|
+
// accepting the common 07XX and +2547XX inputs.
|
|
277
|
+
export function normalizePhone(phone: string): string {
|
|
278
|
+
let p = phone.trim();
|
|
279
|
+
if (p.startsWith("+")) {
|
|
280
|
+
p = p.slice(1);
|
|
281
|
+
}
|
|
282
|
+
if (p.startsWith("0")) {
|
|
283
|
+
return "254" + p.slice(1);
|
|
284
|
+
}
|
|
285
|
+
return p;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Credentials are the Daraja secrets a host injects from its own store. This
|
|
289
|
+
// package holds no defaults for any of them.
|
|
290
|
+
export interface Credentials {
|
|
291
|
+
consumerKey: string;
|
|
292
|
+
consumerSecret: string;
|
|
293
|
+
shortCode: string;
|
|
294
|
+
passkey: string;
|
|
295
|
+
baseURL?: string; // optional, defaults to the public Daraja host
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// httpDarajaApi is the live Daraja client. It fetches an OAuth token and signs
|
|
299
|
+
// each STK push with the timestamped password Daraja expects.
|
|
300
|
+
class HttpDarajaApi implements DarajaApi {
|
|
301
|
+
private readonly creds: Credentials;
|
|
302
|
+
private readonly baseURL: string;
|
|
303
|
+
private readonly now: () => Date;
|
|
304
|
+
|
|
305
|
+
constructor(creds: Credentials, now: () => Date) {
|
|
306
|
+
this.creds = creds;
|
|
307
|
+
this.baseURL = creds.baseURL || defaultBaseURL;
|
|
308
|
+
this.now = now;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async token(): Promise<string> {
|
|
312
|
+
const basic = Buffer.from(`${this.creds.consumerKey}:${this.creds.consumerSecret}`).toString("base64");
|
|
313
|
+
const resp = await fetch(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
|
|
314
|
+
method: "GET",
|
|
315
|
+
headers: { Authorization: `Basic ${basic}` },
|
|
316
|
+
});
|
|
317
|
+
if (resp.status >= 300) {
|
|
318
|
+
throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
|
|
319
|
+
}
|
|
320
|
+
const out = (await resp.json()) as { access_token?: string };
|
|
321
|
+
return out.access_token ?? "";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// password is the base64 of shortcode+passkey+timestamp Daraja requires on each
|
|
325
|
+
// STK push.
|
|
326
|
+
private password(timestamp: string): string {
|
|
327
|
+
return Buffer.from(this.creds.shortCode + this.creds.passkey + timestamp).toString("base64");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async stkPush(params: StkPushParams): Promise<StkPushResult> {
|
|
331
|
+
const token = await this.token();
|
|
332
|
+
const timestamp = formatTimestamp(this.now());
|
|
333
|
+
const body = {
|
|
334
|
+
BusinessShortCode: this.creds.shortCode,
|
|
335
|
+
Password: this.password(timestamp),
|
|
336
|
+
Timestamp: timestamp,
|
|
337
|
+
TransactionType: "CustomerPayBillOnline",
|
|
338
|
+
Amount: params.amount,
|
|
339
|
+
PartyA: params.payerPhone,
|
|
340
|
+
PartyB: this.creds.shortCode,
|
|
341
|
+
PhoneNumber: params.payerPhone,
|
|
342
|
+
CallBackURL: params.callbackURL,
|
|
343
|
+
AccountReference: params.accountReference,
|
|
344
|
+
TransactionDesc: params.description,
|
|
345
|
+
};
|
|
346
|
+
const out = await this.postJSON<{
|
|
347
|
+
MerchantRequestID?: string;
|
|
348
|
+
CheckoutRequestID?: string;
|
|
349
|
+
ResponseCode?: string;
|
|
350
|
+
}>(token, "/mpesa/stkpush/v1/processrequest", body);
|
|
351
|
+
return {
|
|
352
|
+
merchantRequestId: out.MerchantRequestID ?? "",
|
|
353
|
+
checkoutRequestId: out.CheckoutRequestID ?? "",
|
|
354
|
+
responseCode: out.ResponseCode ?? "",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async b2cPayment(params: B2CParams): Promise<B2CResult> {
|
|
359
|
+
const token = await this.token();
|
|
360
|
+
const body = {
|
|
361
|
+
InitiatorName: this.creds.shortCode,
|
|
362
|
+
CommandID: "BusinessPayment",
|
|
363
|
+
Amount: params.amount,
|
|
364
|
+
PartyA: this.creds.shortCode,
|
|
365
|
+
PartyB: params.phone,
|
|
366
|
+
Remarks: params.remarks,
|
|
367
|
+
Occasion: params.reference,
|
|
368
|
+
};
|
|
369
|
+
const out = await this.postJSON<{ ConversationID?: string; ResponseCode?: string }>(
|
|
370
|
+
token,
|
|
371
|
+
"/mpesa/b2c/v3/paymentrequest",
|
|
372
|
+
body,
|
|
373
|
+
);
|
|
374
|
+
return { conversationId: out.ConversationID ?? "", responseCode: out.ResponseCode ?? "" };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private async postJSON<T>(token: string, path: string, body: unknown): Promise<T> {
|
|
378
|
+
const resp = await fetch(this.baseURL + path, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
381
|
+
body: JSON.stringify(body),
|
|
382
|
+
});
|
|
383
|
+
if (resp.status >= 300) {
|
|
384
|
+
throw new Error(`mpesa: ${path} returned ${resp.status}`);
|
|
385
|
+
}
|
|
386
|
+
return (await resp.json()) as T;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// formatTimestamp renders a Date as the yyyyMMddHHmmss string Daraja stamps each
|
|
391
|
+
// push with.
|
|
392
|
+
function formatTimestamp(d: Date): string {
|
|
393
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
394
|
+
return (
|
|
395
|
+
String(d.getFullYear()) +
|
|
396
|
+
pad(d.getMonth() + 1) +
|
|
397
|
+
pad(d.getDate()) +
|
|
398
|
+
pad(d.getHours()) +
|
|
399
|
+
pad(d.getMinutes()) +
|
|
400
|
+
pad(d.getSeconds())
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// newHttpDarajaApi builds a live Daraja client. now is injectable so the STK
|
|
405
|
+
// timestamp and password are deterministic in tests.
|
|
406
|
+
export function newHttpDarajaApi(creds: Credentials, now: () => Date = () => new Date()): DarajaApi {
|
|
407
|
+
return new HttpDarajaApi(creds, now);
|
|
408
|
+
}
|