@myzonerocks/pact 0.1.3 → 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.
Files changed (67) hide show
  1. package/dist/src/adapter.d.ts +1 -1
  2. package/dist/src/adapters/erc20.d.ts +13 -3
  3. package/dist/src/adapters/erc20.js +93 -49
  4. package/dist/src/adapters/http.d.ts +2 -0
  5. package/dist/src/adapters/http.js +12 -0
  6. package/dist/src/adapters/mpesa.d.ts +9 -2
  7. package/dist/src/adapters/mpesa.js +82 -12
  8. package/dist/src/adapters/paypal.d.ts +5 -1
  9. package/dist/src/adapters/paypal.js +76 -25
  10. package/dist/src/adapters/stripe.d.ts +3 -2
  11. package/dist/src/adapters/stripe.js +14 -11
  12. package/dist/src/bridge.js +12 -5
  13. package/dist/src/canonical.js +5 -0
  14. package/dist/src/client.d.ts +8 -2
  15. package/dist/src/client.js +181 -20
  16. package/dist/src/compliance.d.ts +4 -0
  17. package/dist/src/compliance.js +10 -3
  18. package/dist/src/crypto.js +4 -1
  19. package/dist/src/index.d.ts +0 -1
  20. package/dist/src/index.js +0 -1
  21. package/dist/src/ledger.d.ts +6 -2
  22. package/dist/src/ledger.js +2 -2
  23. package/dist/src/leg.d.ts +1 -1
  24. package/dist/src/message.d.ts +1 -1
  25. package/dist/src/message.js +8 -5
  26. package/dist/src/money.d.ts +1 -0
  27. package/dist/src/money.js +18 -3
  28. package/dist/src/protocol.d.ts +1 -0
  29. package/dist/src/protocol.js +8 -0
  30. package/dist/src/router.d.ts +2 -0
  31. package/dist/src/router.js +45 -7
  32. package/dist/src/wire.js +19 -2
  33. package/dist/test/erc20.test.js +95 -31
  34. package/dist/test/fake.d.ts +29 -0
  35. package/dist/test/fake.js +79 -0
  36. package/dist/test/lifecycle.test.js +31 -3
  37. package/dist/test/money.test.d.ts +1 -0
  38. package/dist/test/money.test.js +27 -0
  39. package/dist/test/mpesa.test.js +41 -8
  40. package/dist/test/paypal.test.js +33 -8
  41. package/dist/test/policy.test.js +6 -2
  42. package/dist/test/router.test.d.ts +1 -0
  43. package/dist/test/router.test.js +52 -0
  44. package/dist/test/stripe.test.js +5 -4
  45. package/dist/test/vectors.test.js +48 -2
  46. package/dist/test/wire.test.js +15 -0
  47. package/package.json +1 -1
  48. package/src/adapter.ts +7 -2
  49. package/src/adapters/erc20.ts +118 -51
  50. package/src/adapters/http.ts +14 -0
  51. package/src/adapters/mpesa.ts +102 -13
  52. package/src/adapters/paypal.ts +102 -24
  53. package/src/adapters/stripe.ts +16 -13
  54. package/src/bridge.ts +12 -5
  55. package/src/canonical.ts +5 -0
  56. package/src/client.ts +194 -22
  57. package/src/compliance.ts +20 -3
  58. package/src/crypto.ts +4 -1
  59. package/src/index.ts +0 -1
  60. package/src/ledger.ts +12 -4
  61. package/src/leg.ts +4 -1
  62. package/src/message.ts +8 -5
  63. package/src/money.ts +19 -3
  64. package/src/protocol.ts +9 -0
  65. package/src/router.ts +44 -4
  66. package/src/wire.ts +20 -3
  67. package/src/fake.ts +0 -96
@@ -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(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
38
- reverseOut(intentId: string, reason: string): Promise<Settlement>;
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
- return { rails: [this.rail], currencies: [this.currency], refunds: RefundKind.CounterTransfer };
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
- const txHash = await this.transfer(recipientRef, quote.dstAmount.value());
52
- this.sent.set(intentId, txHash);
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 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);
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
- async reverseOut(intentId, reason) {
64
- return this.terminal(intentId, reason);
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 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.
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 txHash = this.sent.get(intentId);
71
- if (!txHash) {
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
- 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
- };
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
- 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
- }
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,2 @@
1
+ export declare const httpTimeoutMs = 30000;
2
+ export declare function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response>;
@@ -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: rec.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, 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) {
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
- if (cb.ResultCode !== 0) {
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: cb.ResultDesc ?? "",
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 fetch(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
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
- return out.access_token ?? "";
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 fetch(this.baseURL + path, {
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,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 { PayOutLeg, InteractivePayInLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult, PayInPreparation } from "../leg.js";
@@ -32,6 +33,8 @@ export interface Payout {
32
33
  export interface RefundParams {
33
34
  captureId: string;
34
35
  reason: string;
36
+ value?: string;
37
+ currencyCode?: string;
35
38
  }
36
39
  export interface Refund {
37
40
  id: string;
@@ -56,13 +59,14 @@ export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
56
59
  private readonly api;
57
60
  private readonly ids;
58
61
  private readonly captures;
62
+ private readonly expected;
59
63
  constructor(cfg: PaypalConfig);
60
64
  payInCapabilities(): PayInCapabilities;
61
65
  payOutCapabilities(): PayOutCapabilities;
62
66
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
63
67
  prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
64
68
  disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
65
- refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
69
+ refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
66
70
  reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
67
71
  parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): Promise<AdapterEvent[]>;
68
72
  }