@herberthtk/yo-payments-api 0.1.0 → 0.3.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/CHANGELOG.md +11 -0
- package/README.md +484 -76
- package/dist/index.cjs +5 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @herberthtk/yo-payments-api
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@herberthtk/yo-payments-api)
|
|
4
|
+
[](https://github.com/herberthk/yo-payments-api/actions)
|
|
5
|
+
[](https://github.com/herberthk/yo-payments-api/blob/main/LICENSE)
|
|
6
|
+
|
|
3
7
|
TypeScript client for the [Yo! Payments API PHP library](https://github.com/YO-Uganda) (`YoAPI.php`) for mobile money, airtime and account operations on the Yo! Payments gateway. Runs on [Bun](https://bun.com) and Node.js 18+ (uses `fetch` + `node:crypto`), including Next.js App Router handlers, Server Actions and Server Components (**server-side only** — never import it into a Client Component).
|
|
4
8
|
|
|
5
9
|
## Install
|
|
@@ -9,7 +13,7 @@ npm install @herberthtk/yo-payments-api
|
|
|
9
13
|
# or: bun add @herberthtk/yo-payments-api
|
|
10
14
|
```
|
|
11
15
|
|
|
12
|
-
##
|
|
16
|
+
## Quick start
|
|
13
17
|
|
|
14
18
|
```ts
|
|
15
19
|
import { YoAPI } from "@herberthtk/yo-payments-api";
|
|
@@ -18,9 +22,13 @@ import { YoAPI } from "@herberthtk/yo-payments-api";
|
|
|
18
22
|
const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD");
|
|
19
23
|
|
|
20
24
|
// Request a mobile money user to deposit funds into your account
|
|
21
|
-
const response = await yoAPI.acDepositFunds(
|
|
25
|
+
const response = await yoAPI.acDepositFunds(
|
|
26
|
+
"256770000000",
|
|
27
|
+
10000,
|
|
28
|
+
"Reason for transfer of funds",
|
|
29
|
+
);
|
|
22
30
|
if (response.Status === "OK") {
|
|
23
|
-
|
|
31
|
+
console.log("Transaction Reference =", response.TransactionReference);
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
// Check the balance of your account
|
|
@@ -30,55 +38,447 @@ console.log(balance.balance); // [{ code: "UGX", balance: "50000" }, ...]
|
|
|
30
38
|
|
|
31
39
|
All network methods are `async` and return typed response objects. Method names use idiomatic camelCase (e.g. `acDepositFunds`, `setExternalReference`, `getTransactionLimitAccountIdentifier`) — the one intentional divergence from the PHP library's `snake_case` names; the XML wire format is unchanged.
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
## Configuration
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- `acAcctBalance()`
|
|
39
|
-
- `acGetMinistatement(startDate?, endDate?, transactionStatus?, currencyCode?, resultSetLimit?, transactionEntryDesignation?, externalReference?)`
|
|
40
|
-
- `acSendAirtimeMobile(msisdn, amount, narrative)`
|
|
41
|
-
- `acSendAirtimeInternal(currencyCode, amount, beneficiaryAccount, beneficiaryEmail, narrative)`
|
|
42
|
-
- `acWithdrawFunds(msisdn, amount, narrative)`
|
|
43
|
-
- `acUserPurchaseAirtimestock(airtimeCurrencyCode, amount)`
|
|
44
|
-
- `acGetMsisdnKycInfo(msisdn)`
|
|
45
|
-
- `generatePublicKeyAuthenticationSignature(msisdn, amount, narrative)`
|
|
43
|
+
```ts
|
|
44
|
+
const yoAPI = new YoAPI(username: string, password: string, mode: "production" | "sandbox" = "production");
|
|
45
|
+
```
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
| Setter | Type | Default | Purpose |
|
|
48
|
+
| ------------------------------------------- | ------------------- | ------------ | ------------------------------------------------------------------------------ |
|
|
49
|
+
| `setExternalReference` | `string \| null` | `null` | Your reference for the payment (e.g. invoice number); sent with most requests |
|
|
50
|
+
| `setInternalReference` | `string \| null` | `null` | Reference to another Yo! Payments system transaction |
|
|
51
|
+
| `setNonblocking` | `"TRUE" \| "FALSE"` | `"FALSE"` | `"TRUE"` returns immediately; poll status or use IPN URLs |
|
|
52
|
+
| `setInstantNotificationUrl` | `string \| null` | `null` | URL POSTed on successful deposit (non-blocking flow) |
|
|
53
|
+
| `setFailureNotificationUrl` | `string \| null` | `null` | URL POSTed on failed deposit (non-blocking flow) |
|
|
54
|
+
| `setProviderReferenceText` | `string \| null` | `null` | Text appended to the subscriber's confirmation SMS |
|
|
55
|
+
| `setAuthenticationSignatureBase64` | `string \| null` | `null` | Required for certain deposit requests (ask Yo! support) |
|
|
56
|
+
| `setDepositTransactionType` | `"PULL" \| "PUSH"` | `"PULL"` | Which deposit flow `acTransactionCheckStatus` follows up on |
|
|
57
|
+
| `setTransactionLimitAccountIdentifier` | `string \| null` | `null` | Ask your account administrator before using |
|
|
58
|
+
| `setPublicKeyAuthenticationNonce` | `string \| null` | `null` | Unique-per-request nonce for public-key-auth payouts |
|
|
59
|
+
| `setPublicKeyAuthenticationSignatureBase64` | `string \| null` | `null` | Usually set via `generatePublicKeyAuthenticationSignature` |
|
|
60
|
+
| `setPrivateKeyFileLocation` | `string \| null` | `null` | Path to the signing private key (PEM file) |
|
|
61
|
+
| `setPrivateKeyContent` | `string \| null` | `null` | Key PEM text; for serverless hosts without key files (wins over file location) |
|
|
62
|
+
| `setPublicKeyFileUrl` | `string` | bundled cert | Certificate used to verify IPN signatures |
|
|
63
|
+
| `setUrl` | `string` | gateway URL | Override the API endpoint (testing/proxies) |
|
|
64
|
+
| `setTimeout` | `number` (ms) | `120000` | Request timeout; `<= 0` disables it |
|
|
65
|
+
| `setTlsVerificationEnabled` | `boolean` | `true` | Only disable for testing against self-signed endpoints |
|
|
66
|
+
| `setMaxResponseBytes` | `number` | `1048576` | Cap on gateway response bodies |
|
|
67
|
+
|
|
68
|
+
Every setter has a matching getter (`getExternalReference()`, `getMode()`, …). One instance holds per-request state, so create a fresh client per request — never share one across concurrent operations.
|
|
69
|
+
|
|
70
|
+
## API reference
|
|
71
|
+
|
|
72
|
+
Conventions used below:
|
|
73
|
+
|
|
74
|
+
- **Success** — `Status: "OK"` (and usually `TransactionStatus: "SUCCEEDED"`); reference fields are present.
|
|
75
|
+
- **Business failure** — returned as a normal object, never thrown: `Status: "FAILED"` with `ErrorMessageCode` / `ErrorMessage` set. Check `Status` (and `TransactionStatus`) before trusting reference fields.
|
|
76
|
+
- **Transport failure** — thrown as `YoAPIError`: connection errors, timeouts, non-2xx HTTP, oversized bodies, malformed XML, missing `<Response>`. See [Error handling](#error-handling).
|
|
77
|
+
|
|
78
|
+
Amounts accept `number | string` — pass a string when exact formatting matters (e.g. `"100.50"`), since numbers use JavaScript float-to-string conversion. Phone numbers use international format without `+` (e.g. `"256770000000"`).
|
|
79
|
+
|
|
80
|
+
### acDepositFunds — request a mobile money deposit (USSD PIN prompt)
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const res: DepositFundsResponse = await yoAPI.acDepositFunds(
|
|
84
|
+
msisdn,
|
|
85
|
+
amount,
|
|
86
|
+
narrative,
|
|
87
|
+
);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| Parameter | Type | Description |
|
|
91
|
+
| ----------- | ------------------ | --------------------------------------- |
|
|
92
|
+
| `msisdn` | `string` | Subscriber phone, e.g. `"256770000000"` |
|
|
93
|
+
| `amount` | `number \| string` | Amount to collect |
|
|
94
|
+
| `narrative` | `string` | Reason shown to the subscriber |
|
|
48
95
|
|
|
49
|
-
|
|
96
|
+
Response (`DepositFundsResponse`): `Status`, `StatusCode`, `StatusMessage`, `TransactionStatus` always present. On success also `TransactionReference` (save this — it identifies the payment everywhere else), `MNOTransactionReferenceId`, `IssuedReceiptNumber`. On business failure, `ErrorMessageCode` / `ErrorMessage` instead. Optional request tweaks: `setNonblocking("TRUE")` + IPN URLs, `setAuthenticationSignatureBase64`.
|
|
97
|
+
|
|
98
|
+
### acTransactionCheckStatus — poll a transaction
|
|
50
99
|
|
|
51
100
|
```ts
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
101
|
+
const res: TransactionCheckStatusResponse = await yoAPI.acTransactionCheckStatus(
|
|
102
|
+
transactionReference: string | null,
|
|
103
|
+
privateTransactionReference: string | null = null,
|
|
104
|
+
);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Pass the gateway `TransactionReference`, or `null` plus the `ExternalReference` you sent (`privateTransactionReference`). `setDepositTransactionType("PUSH")` first when following up a push deposit. Same base fields as deposits, plus (when available): `Amount`, `AmountFormatted`, `CurrencyCode`, `TransactionInitiationDate`, `TransactionCompletionDate`. `TransactionStatus` is one of `SUCCEEDED`, `PENDING`, `FAILED`, `INDETERMINATE` — poll until it leaves `PENDING`.
|
|
108
|
+
|
|
109
|
+
### acInternalTransfer — pay another Yo! Payments account
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const res: DepositFundsResponse = await yoAPI.acInternalTransfer(
|
|
113
|
+
currencyCode: string, // e.g. "UGX-MTNMM", "UGX-MTNAT", "UGX-WTLAT", "UGX-OULAT", "UGX-AIRAT"
|
|
114
|
+
amount: number | string,
|
|
115
|
+
beneficiaryAccount: number | string, // recipient Yo! account number
|
|
116
|
+
beneficiaryEmail: string,
|
|
117
|
+
narrative: string,
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Same response shape as deposits (success/failure fields as above).
|
|
122
|
+
|
|
123
|
+
### acAcctBalance — account balances
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const res: AcctBalanceResponse = await yoAPI.acAcctBalance();
|
|
127
|
+
// res.balance → [{ code: "UGX", balance: "50000" }, { code: "UGX-MTNAT", balance: "1500" }, ...]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`Status` / `StatusCode` always present, `balance` always an array (possibly empty), plus optional `StatusMessage` / error fields.
|
|
131
|
+
|
|
132
|
+
### acGetMinistatement — transaction history
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const res: MinistatementResponse = await yoAPI.acGetMinistatement(
|
|
136
|
+
startDate: string | null = null, // "YYYY-MM-DD HH:MM:SS"
|
|
137
|
+
endDate: string | null = null, // "YYYY-MM-DD HH:MM:SS"
|
|
138
|
+
transactionStatus: string | null = null, // "SUCCEEDED", "FAILED", "PENDING", "INDETERMINATE", or comma-joined
|
|
139
|
+
currencyCode: string | null = null, // e.g. "UGX-MTNMM", "UGX-WARIDMM"
|
|
140
|
+
resultSetLimit: number | null = null, // 0 returns all; gateway default is 15
|
|
141
|
+
transactionEntryDesignation = "ANY", // "TRANSACTION" | "CHARGES" | "ANY"
|
|
142
|
+
externalReference: string | null = null,
|
|
143
|
+
);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`Status`, `StatusCode`, `TotalTransactions`, `ReturnedTransactions` and `Transactions` always present. Each `TransactionDetail` carries `TransactionSystemId`, `TransactionReference`, `TransactionStatus`, `InitiationDate`, `CompletionDate`, `NarrativeBase64`, `Currency`, `Amount`, `Balance`, `GeneralType`, `DetailedType`, `BeneficiaryBase64`, `SenderBase64`, `TransactionEntryDesignation`, plus optional `BeneficiaryMsisdn`, `SenderMsisdn`, `Base64TransactionExternalReference` (present only when the gateway sends them).
|
|
147
|
+
|
|
148
|
+
### acSendAirtimeMobile / acSendAirtimeInternal — send airtime
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// to a phone number
|
|
152
|
+
await yoAPI.acSendAirtimeMobile(msisdn, amount, narrative);
|
|
153
|
+
// to another Yo! account ("UGX-MTNAT" | "UGX-WTLAT" | "UGX-OULAT" | "UGX-AIRAT")
|
|
154
|
+
await yoAPI.acSendAirtimeInternal(
|
|
155
|
+
currencyCode,
|
|
156
|
+
amount,
|
|
157
|
+
beneficiaryAccount,
|
|
158
|
+
beneficiaryEmail,
|
|
159
|
+
narrative,
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Same response shape as deposits.
|
|
164
|
+
|
|
165
|
+
### acWithdrawFunds — pay out to mobile money (handle with care)
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const res: DepositFundsResponse = await yoAPI.acWithdrawFunds(
|
|
169
|
+
msisdn,
|
|
170
|
+
amount,
|
|
171
|
+
narrative,
|
|
172
|
+
);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Same response shape as deposits. Requires an API Access Letter; some payouts additionally require public-key authentication — see below. Optional: `setTransactionLimitAccountIdentifier`, `setPublicKeyAuthenticationNonce` + `setPublicKeyAuthenticationSignatureBase64`.
|
|
176
|
+
|
|
177
|
+
### acUserPurchaseAirtimestock — buy airtime stock with mobile money credit
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const res: PurchaseAirtimeStockResponse = await yoAPI.acUserPurchaseAirtimestock(
|
|
181
|
+
airtimeCurrencyCode: string, // "UGX-MTNAT" | "UGX-AIRAT" | "UGX-OULAT" | "UGX-UTLAT" | "UGX-SMTAT"
|
|
182
|
+
amount: number | string,
|
|
183
|
+
);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`Status` / `StatusCode` always present; on success `TransactionReference`, `TotalCurrencyDebited`, `CommissionAmount`, `StatusMessage`. (Parity note: your external reference is sent inside a `<TransactionReference>` tag, exactly like the PHP library.)
|
|
187
|
+
|
|
188
|
+
### acGetMsisdnKycInfo — name lookup before paying out
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const res: MsisdnKycInfoResponse =
|
|
192
|
+
await yoAPI.acGetMsisdnKycInfo("256770000000");
|
|
193
|
+
// res.FirstName / res.MiddleName / res.Surname when the gateway returns them
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
MTN Uganda and Airtel Uganda only; needs permission from support@yo.co.ug. `Status` / `StatusCode` always present.
|
|
197
|
+
|
|
198
|
+
### receivePaymentNotification / receivePaymentFailureNotification — verify IPNs
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const payment: PaymentNotificationResult = yoAPI.receivePaymentNotification({
|
|
202
|
+
date_time,
|
|
203
|
+
amount,
|
|
204
|
+
narrative,
|
|
205
|
+
network_ref,
|
|
206
|
+
external_ref,
|
|
207
|
+
msisdn,
|
|
208
|
+
signature,
|
|
70
209
|
});
|
|
210
|
+
// payment.is_verified === true → trust payment.msisdn / .amount / .external_ref / ...
|
|
211
|
+
const failure: PaymentFailureNotificationResult =
|
|
212
|
+
yoAPI.receivePaymentFailureNotification({
|
|
213
|
+
failed_transaction_reference,
|
|
214
|
+
transaction_init_date,
|
|
215
|
+
verification,
|
|
216
|
+
});
|
|
71
217
|
```
|
|
72
218
|
|
|
73
|
-
|
|
219
|
+
Pass the parsed POST form body (PHP reads `$_POST`; here you supply it). Verification is RSA-SHA256 against the bundled Yo! certificate and is fail-closed: any problem (bad signature, missing cert) yields `is_verified: false`, never a throw. Always gate crediting on `is_verified` **and** dedupe on `external_ref` — notifications carry no replay protection.
|
|
220
|
+
|
|
221
|
+
### generatePublicKeyAuthenticationSignature — sign a payout
|
|
74
222
|
|
|
75
223
|
```ts
|
|
76
|
-
const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD");
|
|
77
224
|
yoAPI.setExternalReference("INV-123");
|
|
78
|
-
yoAPI.setPublicKeyAuthenticationNonce(crypto.randomUUID());
|
|
79
|
-
yoAPI.
|
|
80
|
-
yoAPI.generatePublicKeyAuthenticationSignature(
|
|
81
|
-
const res = await yoAPI.acWithdrawFunds(
|
|
225
|
+
yoAPI.setPublicKeyAuthenticationNonce(crypto.randomUUID()); // unique per request
|
|
226
|
+
yoAPI.setPrivateKeyContent(process.env.YO_PRIVATE_KEY!.replace(/\\n/g, "\n")); // or setPrivateKeyFileLocation(path)
|
|
227
|
+
yoAPI.generatePublicKeyAuthenticationSignature(msisdn, amount, narrative); // throws on missing/invalid key
|
|
228
|
+
const res = await yoAPI.acWithdrawFunds(msisdn, amount, narrative);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Signs `username + amount + msisdn + narrative + externalReference + nonce` (SHA1+RSA per the gateway protocol) and stores it for the next payout call. Throws `"Public key authentication nonce is not set…"`, `"Private key file location cannot be NULL"`, `"Private key file could not be opened…"`, or `"Private key is invalid"`.
|
|
232
|
+
|
|
233
|
+
### Response examples
|
|
234
|
+
|
|
235
|
+
Concrete objects each call resolves to. Absent optional fields are omitted (never `null`).
|
|
236
|
+
|
|
237
|
+
**Deposits, transfers, airtime, withdrawals** (`DepositFundsResponse` family) — success:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
{
|
|
241
|
+
Status: "OK",
|
|
242
|
+
StatusCode: "200",
|
|
243
|
+
StatusMessage: "OK",
|
|
244
|
+
TransactionStatus: "SUCCEEDED",
|
|
245
|
+
TransactionReference: "TRX-EX-1",
|
|
246
|
+
MNOTransactionReferenceId: "MNO-9",
|
|
247
|
+
IssuedReceiptNumber: "R-77",
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Same calls — business failure (returned, not thrown):
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
{
|
|
255
|
+
Status: "FAILED",
|
|
256
|
+
StatusCode: "500",
|
|
257
|
+
StatusMessage: "Failed",
|
|
258
|
+
TransactionStatus: "FAILED",
|
|
259
|
+
ErrorMessageCode: "INVALID_MSISDN",
|
|
260
|
+
ErrorMessage: "The MSISDN is invalid",
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Transaction status** (`TransactionCheckStatusResponse`) — success carries the money fields:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
{
|
|
268
|
+
Status: "OK",
|
|
269
|
+
StatusCode: "200",
|
|
270
|
+
StatusMessage: "OK",
|
|
271
|
+
TransactionStatus: "SUCCEEDED",
|
|
272
|
+
TransactionReference: "TRX-EX-1",
|
|
273
|
+
Amount: "10000",
|
|
274
|
+
AmountFormatted: "UGX 10,000",
|
|
275
|
+
CurrencyCode: "UGX",
|
|
276
|
+
TransactionInitiationDate: "2026-09-07T10:00:00",
|
|
277
|
+
TransactionCompletionDate: "2026-09-07T10:01:00",
|
|
278
|
+
IssuedReceiptNumber: "R-77",
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Still pending — keep polling:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
{
|
|
286
|
+
Status: "OK",
|
|
287
|
+
StatusCode: "200",
|
|
288
|
+
StatusMessage: "OK",
|
|
289
|
+
TransactionStatus: "PENDING",
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Balance** (`AcctBalanceResponse`):
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
{
|
|
297
|
+
Status: "OK",
|
|
298
|
+
StatusCode: "200",
|
|
299
|
+
balance: [
|
|
300
|
+
{ code: "UGX", balance: "50000" },
|
|
301
|
+
{ code: "UGX-MTNAT", balance: "1500" },
|
|
302
|
+
],
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Ministatement** (`MinistatementResponse`) — `Transactions` is always an array:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
{
|
|
310
|
+
Status: "OK",
|
|
311
|
+
StatusCode: "200",
|
|
312
|
+
TotalTransactions: "2",
|
|
313
|
+
ReturnedTransactions: "2",
|
|
314
|
+
Transactions: [
|
|
315
|
+
{
|
|
316
|
+
TransactionSystemId: "SYS-1",
|
|
317
|
+
TransactionReference: "TRX-EX-1",
|
|
318
|
+
TransactionStatus: "SUCCEEDED",
|
|
319
|
+
InitiationDate: "2026-09-07 10:00:00",
|
|
320
|
+
CompletionDate: "2026-09-07 10:01:00",
|
|
321
|
+
NarrativeBase64: "SGVsbG8=",
|
|
322
|
+
Currency: "UGX",
|
|
323
|
+
Amount: "100",
|
|
324
|
+
Balance: "900",
|
|
325
|
+
GeneralType: "DEPOSIT",
|
|
326
|
+
DetailedType: "MOBILE_MONEY_DEPOSIT",
|
|
327
|
+
BeneficiaryMsisdn: "256770000000",
|
|
328
|
+
BeneficiaryBase64: "QmVuZQ==",
|
|
329
|
+
SenderMsisdn: "256780000000",
|
|
330
|
+
SenderBase64: "U2VuZGVy",
|
|
331
|
+
Base64TransactionExternalReference: "RVhULTE=",
|
|
332
|
+
TransactionEntryDesignation: "TRANSACTION",
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Airtimestock purchase** (`PurchaseAirtimeStockResponse`):
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
{
|
|
342
|
+
Status: "OK",
|
|
343
|
+
StatusCode: "200",
|
|
344
|
+
StatusMessage: "Purchased",
|
|
345
|
+
TransactionReference: "TRX-EX-1",
|
|
346
|
+
TotalCurrencyDebited: "1000",
|
|
347
|
+
CommissionAmount: "50",
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**KYC lookup** (`MsisdnKycInfoResponse`):
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
{
|
|
355
|
+
Status: "OK",
|
|
356
|
+
StatusCode: "200",
|
|
357
|
+
StatusMessage: "Found",
|
|
358
|
+
FirstName: "John",
|
|
359
|
+
MiddleName: "Middle",
|
|
360
|
+
Surname: "Doe",
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**Verified payment notification** (`PaymentNotificationResult`):
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
{
|
|
368
|
+
is_verified: true,
|
|
369
|
+
date_time: "2026-09-07 10:00:00",
|
|
370
|
+
amount: "1000",
|
|
371
|
+
narrative: "Payment",
|
|
372
|
+
network_ref: "NET-1",
|
|
373
|
+
external_ref: "EXT-1",
|
|
374
|
+
msisdn: "256770000000",
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Unverifiable notification (bad signature or cert problem) — credit nothing:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
{
|
|
382
|
+
is_verified: false,
|
|
383
|
+
date_time: "2026-09-07 10:00:00",
|
|
384
|
+
amount: "9999",
|
|
385
|
+
narrative: "Payment",
|
|
386
|
+
network_ref: "NET-1",
|
|
387
|
+
external_ref: "EXT-1",
|
|
388
|
+
msisdn: "256770000000",
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Transport failure** — thrown as `YoAPIError`, e.g. gateway HTTP 502:
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
// caught error instance:
|
|
396
|
+
YoAPIError: Yo! Payments gateway responded with HTTP 502
|
|
397
|
+
// e.status === 502
|
|
398
|
+
// e.body === "<html><body>Bad Gateway</body></html>"
|
|
399
|
+
// e.cause === undefined (set only for connection/timeout errors)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## Usage cases
|
|
403
|
+
|
|
404
|
+
**1. Blocking deposit** — simplest collection flow; the call returns after the subscriber approves:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
const api = new YoAPI(u, p, "sandbox");
|
|
408
|
+
api.setExternalReference(`INV-${Date.now()}`);
|
|
409
|
+
const res = await api.acDepositFunds("256770000000", 10000, "Order payment");
|
|
410
|
+
if (res.Status === "OK" && res.TransactionStatus === "SUCCEEDED") {
|
|
411
|
+
await markPaid(res.TransactionReference!);
|
|
412
|
+
} else {
|
|
413
|
+
console.error(res.ErrorMessageCode, res.ErrorMessage);
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**2. Non-blocking deposit with IPN + polling fallback** — instant response, then confirm:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
api.setNonblocking("TRUE");
|
|
421
|
+
api.setInstantNotificationUrl("https://example.com/api/yo/ipn");
|
|
422
|
+
api.setFailureNotificationUrl("https://example.com/api/yo/failure");
|
|
423
|
+
const res = await api.acDepositFunds("256770000000", 10000, "Order payment");
|
|
424
|
+
// ...meanwhile your IPN endpoint verifies and credits on payment.external_ref...
|
|
425
|
+
// ...and/or poll until settled:
|
|
426
|
+
for (;;) {
|
|
427
|
+
const st = await api.acTransactionCheckStatus(null, externalRef);
|
|
428
|
+
if (st.TransactionStatus !== "PENDING") break;
|
|
429
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
**3. Daily reconciliation from the ministatement:**
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const st = await api.acGetMinistatement(
|
|
437
|
+
"2026-09-10 00:00:00",
|
|
438
|
+
"2026-09-10 23:59:59",
|
|
439
|
+
"SUCCEEDED",
|
|
440
|
+
"UGX-MTNMM",
|
|
441
|
+
0,
|
|
442
|
+
);
|
|
443
|
+
for (const tx of st.Transactions) await reconcile(tx);
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**4. Serverless payout with key material (no key files on Vercel/Lambda):**
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
api.setExternalReference("SAL-SEP-001");
|
|
450
|
+
api.setPublicKeyAuthenticationNonce(crypto.randomUUID());
|
|
451
|
+
api.setPrivateKeyContent(process.env.YO_PRIVATE_KEY!.replace(/\\n/g, "\n"));
|
|
452
|
+
api.generatePublicKeyAuthenticationSignature("256770000000", 5000, "Payout");
|
|
453
|
+
const res = await api.acWithdrawFunds("256770000000", 5000, "Payout");
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Receiving payment notifications (IPN)
|
|
457
|
+
|
|
458
|
+
PHP reads `$_POST` / `php://input` globals, which is impossible in TypeScript, so you pass the parsed form body yourself. Use `setPublicKeyFileUrl` if you need a different certificate (sandbox vs production is picked automatically by the constructor `mode`).
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
// Bun HTTP server example
|
|
462
|
+
Bun.serve({
|
|
463
|
+
port: 3000,
|
|
464
|
+
async fetch(req) {
|
|
465
|
+
const form = await req.formData();
|
|
466
|
+
const body = Object.fromEntries(form.entries()) as any;
|
|
467
|
+
|
|
468
|
+
const yoAPI = new YoAPI("API_USERNAME", "API_PASSWORD", "sandbox");
|
|
469
|
+
const payment = yoAPI.receivePaymentNotification(body);
|
|
470
|
+
if (payment.is_verified) {
|
|
471
|
+
console.log(
|
|
472
|
+
`Payment from ${payment.msisdn} of ${payment.amount} (ref ${payment.external_ref})`,
|
|
473
|
+
);
|
|
474
|
+
// update your transaction status where external_ref = payment.external_ref
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Failure notifications:
|
|
478
|
+
// const failure = yoAPI.receivePaymentFailureNotification(body);
|
|
479
|
+
return new Response("OK");
|
|
480
|
+
},
|
|
481
|
+
});
|
|
82
482
|
```
|
|
83
483
|
|
|
84
484
|
### Usage in Next.js (App Router)
|
|
@@ -95,7 +495,11 @@ import "server-only";
|
|
|
95
495
|
import { YoAPI } from "@herberthtk/yo-payments-api";
|
|
96
496
|
|
|
97
497
|
export function getYoClient() {
|
|
98
|
-
|
|
498
|
+
return new YoAPI(
|
|
499
|
+
process.env.YO_API_USERNAME!,
|
|
500
|
+
process.env.YO_API_PASSWORD!,
|
|
501
|
+
"sandbox",
|
|
502
|
+
);
|
|
99
503
|
}
|
|
100
504
|
```
|
|
101
505
|
|
|
@@ -104,27 +508,18 @@ export function getYoClient() {
|
|
|
104
508
|
import "server-only";
|
|
105
509
|
import { getYoClient } from "@/lib/yo";
|
|
106
510
|
|
|
107
|
-
export const runtime = "nodejs";
|
|
108
511
|
export const dynamic = "force-dynamic";
|
|
512
|
+
export const runtime = "nodejs";
|
|
109
513
|
|
|
110
514
|
export async function POST(req: Request) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
network_ref: body.network_ref ?? "",
|
|
120
|
-
external_ref: body.external_ref ?? "",
|
|
121
|
-
msisdn: body.msisdn ?? "",
|
|
122
|
-
signature: body.signature ?? "",
|
|
123
|
-
});
|
|
124
|
-
if (!payment.is_verified) return new Response("NOT VERIFIED", { status: 400 });
|
|
125
|
-
|
|
126
|
-
// TODO: persist + mark processed idempotently on payment.external_ref
|
|
127
|
-
return new Response("OK");
|
|
515
|
+
const form = await req.formData();
|
|
516
|
+
const body = Object.fromEntries(form.entries()) as any;
|
|
517
|
+
const payment = getYoClient().receivePaymentNotification(body);
|
|
518
|
+
if (!payment.is_verified)
|
|
519
|
+
return new Response("NOT VERIFIED", { status: 400 });
|
|
520
|
+
|
|
521
|
+
// TODO: persist + mark processed idempotently on payment.external_ref
|
|
522
|
+
return new Response("OK");
|
|
128
523
|
}
|
|
129
524
|
```
|
|
130
525
|
|
|
@@ -133,12 +528,17 @@ export async function POST(req: Request) {
|
|
|
133
528
|
"use server";
|
|
134
529
|
import { getYoClient } from "@/lib/yo";
|
|
135
530
|
|
|
136
|
-
export async function requestDeposit(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
531
|
+
export async function requestDeposit(
|
|
532
|
+
msisdn: string,
|
|
533
|
+
amount: number,
|
|
534
|
+
narrative: string,
|
|
535
|
+
) {
|
|
536
|
+
const api = getYoClient();
|
|
537
|
+
api.setExternalReference(`${Date.now()}`);
|
|
538
|
+
const res = await api.acDepositFunds(msisdn, amount, narrative);
|
|
539
|
+
if (res.Status === "OK")
|
|
540
|
+
return { ok: true, reference: res.TransactionReference };
|
|
541
|
+
return { ok: false, message: res.StatusMessage };
|
|
142
542
|
}
|
|
143
543
|
```
|
|
144
544
|
|
|
@@ -147,8 +547,14 @@ export async function requestDeposit(msisdn: string, amount: number, narrative:
|
|
|
147
547
|
import { getYoClient } from "@/lib/yo";
|
|
148
548
|
|
|
149
549
|
export default async function StatementPage() {
|
|
150
|
-
|
|
151
|
-
|
|
550
|
+
const res = await getYoClient().acGetMinistatement(
|
|
551
|
+
null,
|
|
552
|
+
null,
|
|
553
|
+
"SUCCEEDED",
|
|
554
|
+
"UGX-MTNMM",
|
|
555
|
+
0,
|
|
556
|
+
);
|
|
557
|
+
return <pre>{JSON.stringify(res.Transactions, null, 2)}</pre>;
|
|
152
558
|
}
|
|
153
559
|
```
|
|
154
560
|
|
|
@@ -162,24 +568,26 @@ Transport-level and protocol-level failures throw `YoAPIError` (an `Error` subcl
|
|
|
162
568
|
import { YoAPI, YoAPIError } from "@herberthtk/yo-payments-api";
|
|
163
569
|
|
|
164
570
|
try {
|
|
165
|
-
|
|
571
|
+
await yoAPI.acAcctBalance();
|
|
166
572
|
} catch (e) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
573
|
+
if (e instanceof YoAPIError) {
|
|
574
|
+
console.error(e.message, "status:", e.status, "cause:", e.cause);
|
|
575
|
+
}
|
|
170
576
|
}
|
|
171
577
|
```
|
|
172
578
|
|
|
173
|
-
`YoAPIError`
|
|
579
|
+
`YoAPIError` fields: `message` (what failed), `status?: number` (HTTP status when a response was received), `body?: string` (first 500 chars of the response, when any), `cause?: unknown` (the underlying fetch error). Thrown for connection errors, timeouts, non-2xx HTTP statuses, oversized bodies, malformed XML and responses missing the `<Response>` node. Gateway-level business failures (e.g. `Status: "FAILED"`) are still returned as normal response objects, exactly like the PHP library.
|
|
174
580
|
|
|
175
581
|
### Examples
|
|
176
582
|
|
|
177
|
-
The `examples/` directory ports
|
|
583
|
+
The `examples/` directory ports the PHP examples (plus balance and KYC extras); each exports testable functions and is runnable with `bun run`:
|
|
178
584
|
|
|
179
585
|
```bash
|
|
180
586
|
YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/deposit_funds.ts
|
|
181
587
|
YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/deposit_funds_nonblocking.ts
|
|
182
588
|
YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/get_ministatement.ts
|
|
589
|
+
YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/get_account_balance.ts
|
|
590
|
+
YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox bun run examples/get_user_info.ts
|
|
183
591
|
YO_API_USERNAME=... YO_API_PASSWORD=... YO_API_MODE=sandbox \
|
|
184
592
|
YO_PRIVATE_KEY_FILE=/path/to/private-key.pem \
|
|
185
593
|
bun run examples/withdraw_funds_public_key_authentication.ts
|
|
@@ -215,7 +623,7 @@ The suite (`tests/YoAPI.test.ts`, `tests/examples.test.ts`, `tests/keys.test.ts`
|
|
|
215
623
|
|
|
216
624
|
### Releasing (maintainers)
|
|
217
625
|
|
|
218
|
-
Versions follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE:` → major).
|
|
626
|
+
Versions follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE:` → major). Before the first release, configure npm trusted publishing for `@herberthtk/yo-payments-api` in npm package settings: select GitHub Actions, set the repository to `herberthk/yo-payments-api`, and set the workflow filename to `release.yml` (not its full path). See npm's [trusted publishers guide](https://docs.npmjs.com/trusted-publishers/) for the full setup. Then run **Actions → Release → Run workflow** — release-it bumps the version, updates `CHANGELOG.md`, tags, creates the GitHub release and publishes to npm via trusted publishing (no npm token needed).
|
|
219
627
|
|
|
220
628
|
## Project structure
|
|
221
629
|
|
|
@@ -227,7 +635,7 @@ Versions follow [Conventional Commits](https://www.conventionalcommits.org/) (`f
|
|
|
227
635
|
- `src/keys.ts` — cached verification-key loading (file-first, embedded fallback)
|
|
228
636
|
- `src/constants.ts` — gateway URLs, certificate names, defaults
|
|
229
637
|
- `src/embeddedCerts.ts` — auto-generated from `certs/` (`bun run embed-certs`)
|
|
230
|
-
- `examples/` — runnable ports of the
|
|
638
|
+
- `examples/` — runnable ports of the PHP examples (plus balance and KYC extras)
|
|
231
639
|
- `examples/nextjs/` — Next.js App Router handlers, Server Actions and queries
|
|
232
640
|
- `certs/` — Yo! Uganda public certificates for IPN verification
|
|
233
641
|
- `.github/workflows/` — `ci.yml` (test/typecheck/build/pack-check) and `release.yml` (release-it via trusted publishing)
|