@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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { State } from "./state.js";
|
|
2
|
+
export declare enum RefundKind {
|
|
3
|
+
None = 0,
|
|
4
|
+
Full = 1,
|
|
5
|
+
Partial = 2,
|
|
6
|
+
CounterTransfer = 3
|
|
7
|
+
}
|
|
8
|
+
export interface AdapterEvent {
|
|
9
|
+
intentId: string;
|
|
10
|
+
state: State;
|
|
11
|
+
providerTxRef: string;
|
|
12
|
+
onchainTxHash: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
settledAt: number;
|
|
15
|
+
}
|
|
16
|
+
export interface WebhookParser {
|
|
17
|
+
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// RefundKind is the shape of refund a rail can perform. A card network can
|
|
2
|
+
// reverse part of a capture; an irreversible rail can only answer with a
|
|
3
|
+
// counter-transfer. A leg declares the truth in its capabilities and the kernel
|
|
4
|
+
// holds it to it.
|
|
5
|
+
export var RefundKind;
|
|
6
|
+
(function (RefundKind) {
|
|
7
|
+
RefundKind[RefundKind["None"] = 0] = "None";
|
|
8
|
+
RefundKind[RefundKind["Full"] = 1] = "Full";
|
|
9
|
+
RefundKind[RefundKind["Partial"] = 2] = "Partial";
|
|
10
|
+
RefundKind[RefundKind["CounterTransfer"] = 3] = "CounterTransfer";
|
|
11
|
+
})(RefundKind || (RefundKind = {}));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
|
+
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
|
+
import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
|
|
4
|
+
export interface Call {
|
|
5
|
+
to: string;
|
|
6
|
+
data: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ChainReceipt {
|
|
9
|
+
status: "success" | "reverted" | "pending";
|
|
10
|
+
blockTimestampMs: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ChainClient {
|
|
13
|
+
send(call: Call): Promise<string>;
|
|
14
|
+
receipt(txHash: string): Promise<ChainReceipt>;
|
|
15
|
+
}
|
|
16
|
+
export interface Erc20Config {
|
|
17
|
+
id?: string;
|
|
18
|
+
token: string;
|
|
19
|
+
currency: string;
|
|
20
|
+
rail?: string;
|
|
21
|
+
chain: ChainClient;
|
|
22
|
+
ids: () => string;
|
|
23
|
+
}
|
|
24
|
+
export declare class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
private readonly token;
|
|
27
|
+
private readonly currency;
|
|
28
|
+
private readonly rail;
|
|
29
|
+
private readonly chain;
|
|
30
|
+
private readonly ids;
|
|
31
|
+
private readonly sent;
|
|
32
|
+
constructor(cfg: Erc20Config);
|
|
33
|
+
payInCapabilities(): PayInCapabilities;
|
|
34
|
+
payOutCapabilities(): PayOutCapabilities;
|
|
35
|
+
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
36
|
+
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
37
|
+
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
38
|
+
reverseOut(intentId: string, reason: string): Promise<Settlement>;
|
|
39
|
+
settlementEvent(intentId: string): Promise<AdapterEvent>;
|
|
40
|
+
private transfer;
|
|
41
|
+
private terminal;
|
|
42
|
+
}
|
|
43
|
+
export declare function transferCalldata(recipient: string, amount: bigint): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { RefundKind } from "../adapter.js";
|
|
10
|
+
// The 4-byte selector for the ERC-20 transfer(address,uint256) call. It is a
|
|
11
|
+
// fixed constant of the standard, so building the calldata needs no hashing.
|
|
12
|
+
const transferSelector = "a9059cbb";
|
|
13
|
+
// Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
|
|
14
|
+
// counter-transfer only, because a token transfer is irreversible.
|
|
15
|
+
export class Erc20Leg {
|
|
16
|
+
id;
|
|
17
|
+
token;
|
|
18
|
+
currency;
|
|
19
|
+
rail;
|
|
20
|
+
chain;
|
|
21
|
+
ids;
|
|
22
|
+
sent = new Map();
|
|
23
|
+
constructor(cfg) {
|
|
24
|
+
if (!cfg.token || !cfg.currency) {
|
|
25
|
+
throw new Error("erc20: config requires a token address and currency");
|
|
26
|
+
}
|
|
27
|
+
this.id = cfg.id ?? "erc20";
|
|
28
|
+
this.token = cfg.token;
|
|
29
|
+
this.currency = cfg.currency;
|
|
30
|
+
this.rail = cfg.rail ?? "erc20";
|
|
31
|
+
this.chain = cfg.chain;
|
|
32
|
+
this.ids = cfg.ids;
|
|
33
|
+
}
|
|
34
|
+
payInCapabilities() {
|
|
35
|
+
return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.CounterTransfer };
|
|
36
|
+
}
|
|
37
|
+
payOutCapabilities() {
|
|
38
|
+
return { rails: [this.rail], currencies: [this.currency], reversible: false };
|
|
39
|
+
}
|
|
40
|
+
// collect moves the payer's tokens to the destination — the recipient for a
|
|
41
|
+
// direct corridor, the escrow for a bridged one. received is the net a bridge
|
|
42
|
+
// would convert: the source the payer paid less the corridor fees.
|
|
43
|
+
async collect(intentId, quote, _auth, deliverTo) {
|
|
44
|
+
const net = quote.srcAmount.sub(quote.fees);
|
|
45
|
+
const txHash = await this.transfer(deliverTo, net.value());
|
|
46
|
+
this.sent.set(intentId, txHash);
|
|
47
|
+
return { providerRef: txHash, received: net };
|
|
48
|
+
}
|
|
49
|
+
// disburse delivers the recipient's tokens.
|
|
50
|
+
async disburse(intentId, quote, recipientRef) {
|
|
51
|
+
const txHash = await this.transfer(recipientRef, quote.dstAmount.value());
|
|
52
|
+
this.sent.set(intentId, txHash);
|
|
53
|
+
return { providerRef: txHash };
|
|
54
|
+
}
|
|
55
|
+
// refundIn and reverseOut both answer with a counter-transfer, the only refund
|
|
56
|
+
// an irreversible token movement supports.
|
|
57
|
+
async refundIn(intentId, kind, reason) {
|
|
58
|
+
if (kind !== RefundKind.CounterTransfer) {
|
|
59
|
+
throw new Error("erc20: a token transfer can only be refunded by counter-transfer");
|
|
60
|
+
}
|
|
61
|
+
return this.terminal(intentId, reason);
|
|
62
|
+
}
|
|
63
|
+
async reverseOut(intentId, reason) {
|
|
64
|
+
return this.terminal(intentId, reason);
|
|
65
|
+
}
|
|
66
|
+
// settlementEvent reads the receipt for an intent's transaction and produces the
|
|
67
|
+
// event a host feeds back once the transfer confirms. Crypto has no webhook, so
|
|
68
|
+
// the host polls this instead.
|
|
69
|
+
async settlementEvent(intentId) {
|
|
70
|
+
const txHash = this.sent.get(intentId);
|
|
71
|
+
if (!txHash) {
|
|
72
|
+
throw new Error("erc20: no broadcast transaction for intent");
|
|
73
|
+
}
|
|
74
|
+
const receipt = await this.chain.receipt(txHash);
|
|
75
|
+
return {
|
|
76
|
+
intentId,
|
|
77
|
+
state: receiptState(receipt),
|
|
78
|
+
providerTxRef: txHash,
|
|
79
|
+
onchainTxHash: txHash,
|
|
80
|
+
reason: receipt.status === "reverted" ? "transaction reverted" : "",
|
|
81
|
+
settledAt: receipt.blockTimestampMs,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async transfer(to, amount) {
|
|
85
|
+
return this.chain.send({ to: this.token, data: transferCalldata(to, amount) });
|
|
86
|
+
}
|
|
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
|
+
}
|
|
101
|
+
// transferCalldata builds the ERC-20 transfer calldata: the selector, the
|
|
102
|
+
// recipient address left-padded to 32 bytes, and the amount as a 32-byte word.
|
|
103
|
+
export function transferCalldata(recipient, amount) {
|
|
104
|
+
const address = recipient.toLowerCase().replace(/^0x/, "");
|
|
105
|
+
if (!/^[0-9a-f]{40}$/.test(address)) {
|
|
106
|
+
throw new Error("erc20: recipient is not a 20-byte address");
|
|
107
|
+
}
|
|
108
|
+
if (amount < 0n) {
|
|
109
|
+
throw new Error("erc20: amount must be non-negative");
|
|
110
|
+
}
|
|
111
|
+
const amountHex = amount.toString(16);
|
|
112
|
+
if (amountHex.length > 64) {
|
|
113
|
+
throw new Error("erc20: amount exceeds a 256-bit word");
|
|
114
|
+
}
|
|
115
|
+
return "0x" + transferSelector + address.padStart(64, "0") + amountHex.padStart(64, "0");
|
|
116
|
+
}
|
|
117
|
+
function receiptState(receipt) {
|
|
118
|
+
switch (receipt.status) {
|
|
119
|
+
case "success":
|
|
120
|
+
return State.Settled;
|
|
121
|
+
case "reverted":
|
|
122
|
+
return State.Failed;
|
|
123
|
+
default:
|
|
124
|
+
return State.Submitted;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
|
+
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
|
+
import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
|
|
4
|
+
export declare const ErrUnknownCheckout = "mpesa: callback for an unknown checkout request";
|
|
5
|
+
export interface StkPushParams {
|
|
6
|
+
amount: number;
|
|
7
|
+
payerPhone: string;
|
|
8
|
+
accountReference: string;
|
|
9
|
+
description: string;
|
|
10
|
+
callbackURL: string;
|
|
11
|
+
}
|
|
12
|
+
export interface StkPushResult {
|
|
13
|
+
merchantRequestId: string;
|
|
14
|
+
checkoutRequestId: string;
|
|
15
|
+
responseCode: string;
|
|
16
|
+
}
|
|
17
|
+
export interface B2CParams {
|
|
18
|
+
amount: number;
|
|
19
|
+
phone: string;
|
|
20
|
+
reference: string;
|
|
21
|
+
remarks: string;
|
|
22
|
+
}
|
|
23
|
+
export interface B2CResult {
|
|
24
|
+
conversationId: string;
|
|
25
|
+
responseCode: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DarajaApi {
|
|
28
|
+
stkPush(params: StkPushParams): Promise<StkPushResult>;
|
|
29
|
+
b2cPayment(params: B2CParams): Promise<B2CResult>;
|
|
30
|
+
}
|
|
31
|
+
export interface MpesaConfig {
|
|
32
|
+
id?: string;
|
|
33
|
+
api: DarajaApi;
|
|
34
|
+
callbackURL: string;
|
|
35
|
+
ids: () => string;
|
|
36
|
+
}
|
|
37
|
+
export declare class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
private readonly api;
|
|
40
|
+
private readonly callbackURL;
|
|
41
|
+
private readonly ids;
|
|
42
|
+
private readonly byIntent;
|
|
43
|
+
private readonly byCheckout;
|
|
44
|
+
constructor(cfg: MpesaConfig);
|
|
45
|
+
payInCapabilities(): PayInCapabilities;
|
|
46
|
+
payOutCapabilities(): PayOutCapabilities;
|
|
47
|
+
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
48
|
+
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
49
|
+
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
50
|
+
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
51
|
+
parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): AdapterEvent[];
|
|
52
|
+
}
|
|
53
|
+
export declare function normalizePhone(phone: string): string;
|
|
54
|
+
export interface Credentials {
|
|
55
|
+
consumerKey: string;
|
|
56
|
+
consumerSecret: string;
|
|
57
|
+
shortCode: string;
|
|
58
|
+
passkey: string;
|
|
59
|
+
baseURL?: string;
|
|
60
|
+
}
|
|
61
|
+
export declare function newHttpDarajaApi(creds: Credentials, now?: () => Date): DarajaApi;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { State } from "../state.js";
|
|
2
|
+
import { RefundKind } from "../adapter.js";
|
|
3
|
+
// The public Daraja host. It is the same for every integration and holds no
|
|
4
|
+
// secret; tests point the leg at a local server instead.
|
|
5
|
+
const defaultBaseURL = "https://api.safaricom.co.ke";
|
|
6
|
+
// ErrUnknownCheckout reports a callback whose CheckoutRequestID does not match a
|
|
7
|
+
// push this leg initiated. Daraja does not sign callbacks, so matching the
|
|
8
|
+
// checkout id to one we started is the authentication: an unrecognized id is
|
|
9
|
+
// rejected rather than acted on.
|
|
10
|
+
export const ErrUnknownCheckout = "mpesa: callback for an unknown checkout request";
|
|
11
|
+
// MpesaLeg moves mobile money over M-Pesa.
|
|
12
|
+
export class MpesaLeg {
|
|
13
|
+
id;
|
|
14
|
+
api;
|
|
15
|
+
callbackURL;
|
|
16
|
+
ids;
|
|
17
|
+
byIntent = new Map();
|
|
18
|
+
byCheckout = new Map();
|
|
19
|
+
constructor(cfg) {
|
|
20
|
+
if (!cfg.callbackURL) {
|
|
21
|
+
throw new Error("mpesa: config requires a callback URL");
|
|
22
|
+
}
|
|
23
|
+
this.id = cfg.id ?? "mpesa";
|
|
24
|
+
this.api = cfg.api;
|
|
25
|
+
this.callbackURL = cfg.callbackURL;
|
|
26
|
+
this.ids = cfg.ids;
|
|
27
|
+
}
|
|
28
|
+
payInCapabilities() {
|
|
29
|
+
return { rails: ["mpesa"], currencies: ["KES"], refunds: RefundKind.CounterTransfer };
|
|
30
|
+
}
|
|
31
|
+
payOutCapabilities() {
|
|
32
|
+
return { rails: ["mpesa"], currencies: ["KES"], reversible: false };
|
|
33
|
+
}
|
|
34
|
+
// collect triggers the STK push that prompts the payer to approve on their
|
|
35
|
+
// handset. deliverTo is the payer's phone the prompt is sent to; the pact intent
|
|
36
|
+
// id rides along as the account reference, and the returned checkout id is mapped
|
|
37
|
+
// back to the intent so the unsigned callback can be tied to it. The collection
|
|
38
|
+
// resolves out of band through the callback; collect returns the checkout id as
|
|
39
|
+
// its provider reference. received is the net a bridge would convert.
|
|
40
|
+
async collect(intentId, quote, _auth, deliverTo) {
|
|
41
|
+
const amount = wholeShillings(quote.srcAmount);
|
|
42
|
+
const net = quote.srcAmount.sub(quote.fees);
|
|
43
|
+
const phone = normalizePhone(deliverTo);
|
|
44
|
+
if (!phone) {
|
|
45
|
+
throw new Error("mpesa: collect requires the payer's phone");
|
|
46
|
+
}
|
|
47
|
+
const rec = { intentId, payerPhone: phone, amount, state: State.Unspecified };
|
|
48
|
+
const result = await this.api.stkPush({
|
|
49
|
+
amount,
|
|
50
|
+
payerPhone: phone,
|
|
51
|
+
accountReference: intentId,
|
|
52
|
+
description: "payment",
|
|
53
|
+
callbackURL: this.callbackURL,
|
|
54
|
+
});
|
|
55
|
+
this.byIntent.set(intentId, rec);
|
|
56
|
+
this.byCheckout.set(result.checkoutRequestId, rec);
|
|
57
|
+
return { providerRef: result.checkoutRequestId, received: net };
|
|
58
|
+
}
|
|
59
|
+
// disburse delivers the recipient's shillings by a business-to-customer payout
|
|
60
|
+
// and returns the Daraja conversation id.
|
|
61
|
+
async disburse(intentId, quote, recipientRef) {
|
|
62
|
+
const amount = wholeShillings(quote.dstAmount);
|
|
63
|
+
const phone = normalizePhone(recipientRef);
|
|
64
|
+
if (!phone) {
|
|
65
|
+
throw new Error("mpesa: disburse requires the recipient's phone");
|
|
66
|
+
}
|
|
67
|
+
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
|
|
68
|
+
return { providerRef: result.conversationId };
|
|
69
|
+
}
|
|
70
|
+
// refundIn answers a collected payment with a business-to-customer payout back
|
|
71
|
+
// to the payer. An STK collection cannot be reversed in place, so a
|
|
72
|
+
// counter-transfer is the only refund this rail supports.
|
|
73
|
+
async refundIn(intentId, kind, reason) {
|
|
74
|
+
if (kind !== RefundKind.CounterTransfer) {
|
|
75
|
+
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
76
|
+
}
|
|
77
|
+
const rec = this.byIntent.get(intentId);
|
|
78
|
+
if (!rec) {
|
|
79
|
+
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
80
|
+
}
|
|
81
|
+
const result = await this.api.b2cPayment({
|
|
82
|
+
amount: rec.amount,
|
|
83
|
+
phone: rec.payerPhone,
|
|
84
|
+
reference: intentId,
|
|
85
|
+
remarks: reason,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
intentId,
|
|
89
|
+
state: State.Refunded,
|
|
90
|
+
adapterId: this.id,
|
|
91
|
+
providerTxRef: result.conversationId,
|
|
92
|
+
onchainTxHash: "",
|
|
93
|
+
receiptHash: new Uint8Array(0),
|
|
94
|
+
reason,
|
|
95
|
+
settledAt: 0,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// reverseOut reports the truth of the rail: a delivered payout cannot be pulled
|
|
99
|
+
// back, so a pay-out reversal is refused rather than faked.
|
|
100
|
+
async reverseOut(_intentId, _reason) {
|
|
101
|
+
throw new Error("mpesa: a delivered payout cannot be reversed");
|
|
102
|
+
}
|
|
103
|
+
// parseWebhook reads an STK callback and normalizes it into a protocol event.
|
|
104
|
+
// The callback is unsigned, so it is authenticated by matching its checkout id
|
|
105
|
+
// to a push this leg started; an unrecognized id is refused. The headers are
|
|
106
|
+
// accepted for interface symmetry and for a host that adds its own IP or
|
|
107
|
+
// shared-secret gate on top.
|
|
108
|
+
parseWebhook(raw, _headers) {
|
|
109
|
+
const envelope = JSON.parse(new TextDecoder().decode(raw));
|
|
110
|
+
const cb = envelope.Body?.stkCallback;
|
|
111
|
+
const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
|
|
112
|
+
if (!cb || !rec) {
|
|
113
|
+
throw new Error(ErrUnknownCheckout);
|
|
114
|
+
}
|
|
115
|
+
if (cb.ResultCode !== 0) {
|
|
116
|
+
rec.state = State.Failed;
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
intentId: rec.intentId,
|
|
120
|
+
state: State.Failed,
|
|
121
|
+
providerTxRef: cb.CheckoutRequestID,
|
|
122
|
+
onchainTxHash: "",
|
|
123
|
+
reason: cb.ResultDesc ?? "",
|
|
124
|
+
settledAt: 0,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
129
|
+
rec.state = State.Settled;
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
intentId: rec.intentId,
|
|
133
|
+
state: State.Settled,
|
|
134
|
+
providerTxRef: receipt,
|
|
135
|
+
onchainTxHash: "",
|
|
136
|
+
reason: "",
|
|
137
|
+
settledAt: 0,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// wholeShillings narrows a KES amount to the integer M-Pesa moves. M-Pesa
|
|
143
|
+
// transacts whole shillings, so a fractional exponent is refused rather than
|
|
144
|
+
// silently truncated.
|
|
145
|
+
function wholeShillings(m) {
|
|
146
|
+
if (m.exponent !== 0) {
|
|
147
|
+
throw new Error(`mpesa: KES amounts must use exponent 0 (whole shillings), got ${m.exponent}`);
|
|
148
|
+
}
|
|
149
|
+
const amount = m.value();
|
|
150
|
+
if (amount > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
151
|
+
throw new Error("mpesa: amount exceeds range");
|
|
152
|
+
}
|
|
153
|
+
return Number(amount);
|
|
154
|
+
}
|
|
155
|
+
// metadataString pulls a named string value out of the callback metadata items.
|
|
156
|
+
function metadataString(items, name) {
|
|
157
|
+
for (const item of items) {
|
|
158
|
+
if (item.Name !== name) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (typeof item.Value === "string") {
|
|
162
|
+
return item.Value;
|
|
163
|
+
}
|
|
164
|
+
return String(item.Value);
|
|
165
|
+
}
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
168
|
+
// normalizePhone trims a Kenyan MSISDN into the 2547XXXXXXXX form Daraja expects,
|
|
169
|
+
// accepting the common 07XX and +2547XX inputs.
|
|
170
|
+
export function normalizePhone(phone) {
|
|
171
|
+
let p = phone.trim();
|
|
172
|
+
if (p.startsWith("+")) {
|
|
173
|
+
p = p.slice(1);
|
|
174
|
+
}
|
|
175
|
+
if (p.startsWith("0")) {
|
|
176
|
+
return "254" + p.slice(1);
|
|
177
|
+
}
|
|
178
|
+
return p;
|
|
179
|
+
}
|
|
180
|
+
// httpDarajaApi is the live Daraja client. It fetches an OAuth token and signs
|
|
181
|
+
// each STK push with the timestamped password Daraja expects.
|
|
182
|
+
class HttpDarajaApi {
|
|
183
|
+
creds;
|
|
184
|
+
baseURL;
|
|
185
|
+
now;
|
|
186
|
+
constructor(creds, now) {
|
|
187
|
+
this.creds = creds;
|
|
188
|
+
this.baseURL = creds.baseURL || defaultBaseURL;
|
|
189
|
+
this.now = now;
|
|
190
|
+
}
|
|
191
|
+
async token() {
|
|
192
|
+
const basic = Buffer.from(`${this.creds.consumerKey}:${this.creds.consumerSecret}`).toString("base64");
|
|
193
|
+
const resp = await fetch(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
|
|
194
|
+
method: "GET",
|
|
195
|
+
headers: { Authorization: `Basic ${basic}` },
|
|
196
|
+
});
|
|
197
|
+
if (resp.status >= 300) {
|
|
198
|
+
throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
|
|
199
|
+
}
|
|
200
|
+
const out = (await resp.json());
|
|
201
|
+
return out.access_token ?? "";
|
|
202
|
+
}
|
|
203
|
+
// password is the base64 of shortcode+passkey+timestamp Daraja requires on each
|
|
204
|
+
// STK push.
|
|
205
|
+
password(timestamp) {
|
|
206
|
+
return Buffer.from(this.creds.shortCode + this.creds.passkey + timestamp).toString("base64");
|
|
207
|
+
}
|
|
208
|
+
async stkPush(params) {
|
|
209
|
+
const token = await this.token();
|
|
210
|
+
const timestamp = formatTimestamp(this.now());
|
|
211
|
+
const body = {
|
|
212
|
+
BusinessShortCode: this.creds.shortCode,
|
|
213
|
+
Password: this.password(timestamp),
|
|
214
|
+
Timestamp: timestamp,
|
|
215
|
+
TransactionType: "CustomerPayBillOnline",
|
|
216
|
+
Amount: params.amount,
|
|
217
|
+
PartyA: params.payerPhone,
|
|
218
|
+
PartyB: this.creds.shortCode,
|
|
219
|
+
PhoneNumber: params.payerPhone,
|
|
220
|
+
CallBackURL: params.callbackURL,
|
|
221
|
+
AccountReference: params.accountReference,
|
|
222
|
+
TransactionDesc: params.description,
|
|
223
|
+
};
|
|
224
|
+
const out = await this.postJSON(token, "/mpesa/stkpush/v1/processrequest", body);
|
|
225
|
+
return {
|
|
226
|
+
merchantRequestId: out.MerchantRequestID ?? "",
|
|
227
|
+
checkoutRequestId: out.CheckoutRequestID ?? "",
|
|
228
|
+
responseCode: out.ResponseCode ?? "",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
async b2cPayment(params) {
|
|
232
|
+
const token = await this.token();
|
|
233
|
+
const body = {
|
|
234
|
+
InitiatorName: this.creds.shortCode,
|
|
235
|
+
CommandID: "BusinessPayment",
|
|
236
|
+
Amount: params.amount,
|
|
237
|
+
PartyA: this.creds.shortCode,
|
|
238
|
+
PartyB: params.phone,
|
|
239
|
+
Remarks: params.remarks,
|
|
240
|
+
Occasion: params.reference,
|
|
241
|
+
};
|
|
242
|
+
const out = await this.postJSON(token, "/mpesa/b2c/v3/paymentrequest", body);
|
|
243
|
+
return { conversationId: out.ConversationID ?? "", responseCode: out.ResponseCode ?? "" };
|
|
244
|
+
}
|
|
245
|
+
async postJSON(token, path, body) {
|
|
246
|
+
const resp = await fetch(this.baseURL + path, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
249
|
+
body: JSON.stringify(body),
|
|
250
|
+
});
|
|
251
|
+
if (resp.status >= 300) {
|
|
252
|
+
throw new Error(`mpesa: ${path} returned ${resp.status}`);
|
|
253
|
+
}
|
|
254
|
+
return (await resp.json());
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// formatTimestamp renders a Date as the yyyyMMddHHmmss string Daraja stamps each
|
|
258
|
+
// push with.
|
|
259
|
+
function formatTimestamp(d) {
|
|
260
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
261
|
+
return (String(d.getFullYear()) +
|
|
262
|
+
pad(d.getMonth() + 1) +
|
|
263
|
+
pad(d.getDate()) +
|
|
264
|
+
pad(d.getHours()) +
|
|
265
|
+
pad(d.getMinutes()) +
|
|
266
|
+
pad(d.getSeconds()));
|
|
267
|
+
}
|
|
268
|
+
// newHttpDarajaApi builds a live Daraja client. now is injectable so the STK
|
|
269
|
+
// timestamp and password are deterministic in tests.
|
|
270
|
+
export function newHttpDarajaApi(creds, now = () => new Date()) {
|
|
271
|
+
return new HttpDarajaApi(creds, now);
|
|
272
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
|
+
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
|
+
import type { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
|
|
4
|
+
export declare const ErrSignatureMismatch = "paypal: webhook signature does not verify";
|
|
5
|
+
export interface CreateOrderParams {
|
|
6
|
+
value: string;
|
|
7
|
+
currencyCode: string;
|
|
8
|
+
payee: string;
|
|
9
|
+
referenceId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Capture {
|
|
12
|
+
orderId: string;
|
|
13
|
+
captureId: string;
|
|
14
|
+
status: string;
|
|
15
|
+
}
|
|
16
|
+
export interface PayoutParams {
|
|
17
|
+
value: string;
|
|
18
|
+
currencyCode: string;
|
|
19
|
+
receiver: string;
|
|
20
|
+
referenceId: string;
|
|
21
|
+
}
|
|
22
|
+
export interface Payout {
|
|
23
|
+
batchId: string;
|
|
24
|
+
status: string;
|
|
25
|
+
}
|
|
26
|
+
export interface RefundParams {
|
|
27
|
+
captureId: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
export interface Refund {
|
|
31
|
+
id: string;
|
|
32
|
+
status: string;
|
|
33
|
+
}
|
|
34
|
+
export interface PaypalApi {
|
|
35
|
+
createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
|
|
36
|
+
sendPayout(params: PayoutParams): Promise<Payout>;
|
|
37
|
+
refundCapture(params: RefundParams): Promise<Refund>;
|
|
38
|
+
verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
|
|
39
|
+
}
|
|
40
|
+
export interface PaypalConfig {
|
|
41
|
+
id?: string;
|
|
42
|
+
currencies: string[];
|
|
43
|
+
api: PaypalApi;
|
|
44
|
+
ids: () => string;
|
|
45
|
+
}
|
|
46
|
+
export declare class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
private readonly currencies;
|
|
49
|
+
private readonly api;
|
|
50
|
+
private readonly ids;
|
|
51
|
+
private readonly captures;
|
|
52
|
+
constructor(cfg: PaypalConfig);
|
|
53
|
+
payInCapabilities(): PayInCapabilities;
|
|
54
|
+
payOutCapabilities(): PayOutCapabilities;
|
|
55
|
+
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
56
|
+
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
57
|
+
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
58
|
+
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
59
|
+
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): Promise<AdapterEvent[]>;
|
|
60
|
+
}
|
|
61
|
+
export declare function majorAmount(minor: string, exponent: number): string;
|
|
62
|
+
export interface Credentials {
|
|
63
|
+
clientId: string;
|
|
64
|
+
clientSecret: string;
|
|
65
|
+
webhookId: string;
|
|
66
|
+
baseURL?: string;
|
|
67
|
+
}
|
|
68
|
+
export declare function newHttpPaypalApi(creds: Credentials, now?: () => number): PaypalApi;
|