pesa-cli 1.0.0__py3-none-any.whl
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.
- pesa_cli/__init__.py +2 -0
- pesa_cli/__main__.py +4 -0
- pesa_cli/cli.py +447 -0
- pesa_cli/main.py +269 -0
- pesa_cli-1.0.0.dist-info/METADATA +184 -0
- pesa_cli-1.0.0.dist-info/RECORD +10 -0
- pesa_cli-1.0.0.dist-info/WHEEL +5 -0
- pesa_cli-1.0.0.dist-info/entry_points.txt +2 -0
- pesa_cli-1.0.0.dist-info/licenses/LICENSE +3 -0
- pesa_cli-1.0.0.dist-info/top_level.txt +1 -0
pesa_cli/__init__.py
ADDED
pesa_cli/__main__.py
ADDED
pesa_cli/cli.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pesa-cli — Command-line tool for M-Pesa Daraja v3.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
pesa auth Test credentials and show token expiry
|
|
6
|
+
pesa stk Initiate STK Push to a phone number
|
|
7
|
+
pesa stk query Poll the status of an STK Push
|
|
8
|
+
pesa b2c Send money to a phone (B2C)
|
|
9
|
+
pesa balance Check account balance
|
|
10
|
+
pesa config View or set credentials
|
|
11
|
+
|
|
12
|
+
Credentials are stored in ~/.pesa/config.json (or PESA_CONFIG env var).
|
|
13
|
+
All sensitive values can also be provided as environment variables:
|
|
14
|
+
DARAJA_CONSUMER_KEY, DARAJA_CONSUMER_SECRET, DARAJA_SHORTCODE,
|
|
15
|
+
DARAJA_PASSKEY, DARAJA_ENV (sandbox | production)
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
pesa config set --key consumer-key --value YOUR_KEY
|
|
19
|
+
pesa auth
|
|
20
|
+
pesa stk 0712345678 500 --ref "Order001" --desc "Payment"
|
|
21
|
+
pesa stk query ws_CO_...
|
|
22
|
+
pesa b2c 0712345678 1000 --remarks "Disbursement"
|
|
23
|
+
pesa balance
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import hashlib
|
|
32
|
+
import base64
|
|
33
|
+
import urllib.request
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.parse
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
import typer
|
|
41
|
+
from rich import print as rprint
|
|
42
|
+
from rich.console import Console
|
|
43
|
+
from rich.table import Table
|
|
44
|
+
from rich.panel import Panel
|
|
45
|
+
from rich.text import Text
|
|
46
|
+
|
|
47
|
+
app = typer.Typer(
|
|
48
|
+
name="pesa",
|
|
49
|
+
help="M-Pesa Daraja v3 command-line tool.",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
rich_markup_mode="rich",
|
|
52
|
+
)
|
|
53
|
+
stk_app = typer.Typer(help="STK Push operations.", no_args_is_help=True)
|
|
54
|
+
app.add_typer(stk_app, name="stk")
|
|
55
|
+
|
|
56
|
+
console = Console()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Config management ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".pesa" / "config.json"
|
|
62
|
+
|
|
63
|
+
CREDENTIAL_KEYS = [
|
|
64
|
+
"consumer_key", "consumer_secret", "shortcode",
|
|
65
|
+
"passkey", "initiator_name", "security_credential",
|
|
66
|
+
"environment", # sandbox | production
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _config_path() -> Path:
|
|
71
|
+
env_path = os.environ.get("PESA_CONFIG")
|
|
72
|
+
return Path(env_path) if env_path else DEFAULT_CONFIG_PATH
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_config() -> dict:
|
|
76
|
+
path = _config_path()
|
|
77
|
+
if path.exists():
|
|
78
|
+
return json.loads(path.read_text())
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def save_config(cfg: dict) -> None:
|
|
83
|
+
path = _config_path()
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
path.write_text(json.dumps(cfg, indent=2))
|
|
86
|
+
path.chmod(0o600) # Owner read/write only — credentials file
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_credential(key: str) -> Optional[str]:
|
|
90
|
+
"""Resolve credential: env var > config file."""
|
|
91
|
+
env_key = f"DARAJA_{key.upper()}"
|
|
92
|
+
val = os.environ.get(env_key)
|
|
93
|
+
if val:
|
|
94
|
+
return val
|
|
95
|
+
cfg = load_config()
|
|
96
|
+
return cfg.get(key)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def require_credential(key: str, label: str) -> str:
|
|
100
|
+
val = get_credential(key)
|
|
101
|
+
if not val:
|
|
102
|
+
rprint(f"[red]✗[/red] Missing {label}. Run [cyan]pesa config set --key {key.replace('_','-')} --value VALUE[/cyan]")
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
return val
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Daraja HTTP layer ──────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def _base_url() -> str:
|
|
110
|
+
env = get_credential("environment") or "sandbox"
|
|
111
|
+
if env == "production":
|
|
112
|
+
return "https://api.safaricom.co.ke"
|
|
113
|
+
return "https://sandbox.safaricom.co.ke"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_token(consumer_key: str, consumer_secret: str) -> str:
|
|
117
|
+
credentials = base64.b64encode(f"{consumer_key}:{consumer_secret}".encode()).decode()
|
|
118
|
+
url = f"{_base_url()}/oauth/v1/generate?grant_type=client_credentials"
|
|
119
|
+
req = urllib.request.Request(url, headers={"Authorization": f"Basic {credentials}"})
|
|
120
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
121
|
+
data = json.loads(r.read())
|
|
122
|
+
token = data.get("access_token")
|
|
123
|
+
if not token:
|
|
124
|
+
raise RuntimeError(f"No access_token in response: {data}")
|
|
125
|
+
return token
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _stk_password(shortcode: str, passkey: str, timestamp: str) -> str:
|
|
129
|
+
return base64.b64encode(f"{shortcode}{passkey}{timestamp}".encode()).decode()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _daraja_post(endpoint: str, payload: dict, token: str) -> dict:
|
|
133
|
+
url = f"{_base_url()}{endpoint}"
|
|
134
|
+
body = json.dumps(payload).encode()
|
|
135
|
+
req = urllib.request.Request(
|
|
136
|
+
url, data=body,
|
|
137
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
141
|
+
return json.loads(r.read())
|
|
142
|
+
except urllib.error.HTTPError as e:
|
|
143
|
+
error_body = e.read().decode()
|
|
144
|
+
try:
|
|
145
|
+
return json.loads(error_body)
|
|
146
|
+
except Exception:
|
|
147
|
+
raise RuntimeError(f"HTTP {e.code}: {error_body[:200]}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalise_phone(raw: str) -> str:
|
|
151
|
+
phone = raw.strip().replace(" ", "").lstrip("+")
|
|
152
|
+
if phone.startswith("07") or phone.startswith("01"):
|
|
153
|
+
phone = "254" + phone[1:]
|
|
154
|
+
elif len(phone) == 9 and phone[0] == "7":
|
|
155
|
+
phone = "254" + phone
|
|
156
|
+
if not (phone.startswith("254") and phone.isdigit() and len(phone) == 12):
|
|
157
|
+
rprint(f"[red]✗[/red] Invalid Kenyan phone number: {raw!r}")
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
return phone
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Commands ───────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
config_app = typer.Typer(help="View and set Daraja credentials.", no_args_is_help=True)
|
|
165
|
+
app.add_typer(config_app, name="config")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@config_app.command("show")
|
|
169
|
+
def config_show():
|
|
170
|
+
"""Show current configuration (secrets masked)."""
|
|
171
|
+
cfg = load_config()
|
|
172
|
+
env_overrides = {k: os.environ.get(f"DARAJA_{k.upper()}") for k in CREDENTIAL_KEYS}
|
|
173
|
+
if not cfg and not any(env_overrides.values()):
|
|
174
|
+
rprint("[yellow]No configuration found.[/yellow] Run [cyan]pesa config set[/cyan] to add credentials.")
|
|
175
|
+
return
|
|
176
|
+
table = Table(title="pesa configuration", show_header=True)
|
|
177
|
+
table.add_column("Key", style="cyan")
|
|
178
|
+
table.add_column("Source")
|
|
179
|
+
table.add_column("Value")
|
|
180
|
+
for key in CREDENTIAL_KEYS:
|
|
181
|
+
env_val = env_overrides.get(key)
|
|
182
|
+
cfg_val = cfg.get(key)
|
|
183
|
+
val = env_val or cfg_val
|
|
184
|
+
source = "env var" if env_val else ("config file" if cfg_val else "—")
|
|
185
|
+
is_secret = key not in ("shortcode", "environment", "initiator_name")
|
|
186
|
+
display = ("*" * 8 + val[-4:] if val and is_secret and len(val) > 4 else val or "—")
|
|
187
|
+
table.add_row(key, source, display)
|
|
188
|
+
console.print(table)
|
|
189
|
+
rprint(f"
|
|
190
|
+
[dim]Config file: {_config_path()}[/dim]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@config_app.command("set")
|
|
194
|
+
def config_set(
|
|
195
|
+
key: str = typer.Option(..., help="Credential key (e.g. consumer-key)"),
|
|
196
|
+
value: str = typer.Option(..., help="Credential value"),
|
|
197
|
+
):
|
|
198
|
+
"""Set a credential in ~/.pesa/config.json."""
|
|
199
|
+
key = key.replace("-", "_")
|
|
200
|
+
if key not in CREDENTIAL_KEYS:
|
|
201
|
+
rprint(f"[red]✗[/red] Unknown key {key!r}. Valid keys: {CREDENTIAL_KEYS}")
|
|
202
|
+
raise typer.Exit(1)
|
|
203
|
+
cfg = load_config()
|
|
204
|
+
cfg[key] = value
|
|
205
|
+
save_config(cfg)
|
|
206
|
+
rprint(f"[green]✓[/green] Set [cyan]{key}[/cyan]")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command()
|
|
210
|
+
def auth():
|
|
211
|
+
"""Test credentials — fetch an OAuth token and show expiry."""
|
|
212
|
+
consumer_key = require_credential("consumer_key", "Consumer Key")
|
|
213
|
+
consumer_secret = require_credential("consumer_secret", "Consumer Secret")
|
|
214
|
+
with console.status("Authenticating..."):
|
|
215
|
+
try:
|
|
216
|
+
token = _get_token(consumer_key, consumer_secret)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
rprint(f"[red]✗ Authentication failed:[/red] {e}")
|
|
219
|
+
raise typer.Exit(1)
|
|
220
|
+
env = get_credential("environment") or "sandbox"
|
|
221
|
+
rprint(Panel(
|
|
222
|
+
f"[green]✓ Authenticated[/green]
|
|
223
|
+
"
|
|
224
|
+
f"Environment: [cyan]{env}[/cyan]
|
|
225
|
+
"
|
|
226
|
+
f"Token: [dim]{token[:12]}...{token[-4:]}[/dim]
|
|
227
|
+
"
|
|
228
|
+
f"Expires in: [cyan]~60 minutes[/cyan]",
|
|
229
|
+
title="pesa auth", border_style="green",
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@stk_app.command("push")
|
|
234
|
+
def stk_push(
|
|
235
|
+
phone: str = typer.Argument(..., help="Kenyan phone number (any format)"),
|
|
236
|
+
amount: int = typer.Argument(..., help="Amount in KES (must be integer)"),
|
|
237
|
+
ref: str = typer.Option("pesa-cli", "--ref", help="AccountReference (max 12 chars)"),
|
|
238
|
+
desc: str = typer.Option("Payment", "--desc", help="TransactionDesc"),
|
|
239
|
+
):
|
|
240
|
+
"""Initiate an STK Push payment request."""
|
|
241
|
+
phone = _normalise_phone(phone)
|
|
242
|
+
consumer_key = require_credential("consumer_key", "Consumer Key")
|
|
243
|
+
consumer_secret = require_credential("consumer_secret", "Consumer Secret")
|
|
244
|
+
shortcode = require_credential("shortcode", "Shortcode")
|
|
245
|
+
passkey = require_credential("passkey", "Passkey")
|
|
246
|
+
callback_url = get_credential("callback_url") or "https://example.com/mpesa/callback"
|
|
247
|
+
ref = ref[:12] # Daraja hard limit
|
|
248
|
+
|
|
249
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
250
|
+
password = _stk_password(shortcode, passkey, timestamp)
|
|
251
|
+
|
|
252
|
+
with console.status(f"Sending STK Push to +{phone}..."):
|
|
253
|
+
try:
|
|
254
|
+
token = _get_token(consumer_key, consumer_secret)
|
|
255
|
+
response = _daraja_post("/mpesa/stkpush/v1/processrequest", {
|
|
256
|
+
"BusinessShortCode": shortcode,
|
|
257
|
+
"Password": password,
|
|
258
|
+
"Timestamp": timestamp,
|
|
259
|
+
"TransactionType": "CustomerPayBillOnline",
|
|
260
|
+
"Amount": amount,
|
|
261
|
+
"PartyA": phone,
|
|
262
|
+
"PartyB": shortcode,
|
|
263
|
+
"PhoneNumber": phone,
|
|
264
|
+
"CallBackURL": callback_url,
|
|
265
|
+
"AccountReference": ref,
|
|
266
|
+
"TransactionDesc": desc,
|
|
267
|
+
}, token)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
rprint(f"[red]✗ STK Push failed:[/red] {e}")
|
|
270
|
+
raise typer.Exit(1)
|
|
271
|
+
|
|
272
|
+
if response.get("ResponseCode") == "0":
|
|
273
|
+
checkout_id = response.get("CheckoutRequestID", "")
|
|
274
|
+
rprint(Panel(
|
|
275
|
+
f"[green]✓ STK Push sent[/green]
|
|
276
|
+
"
|
|
277
|
+
f"Phone: [cyan]+{phone}[/cyan]
|
|
278
|
+
"
|
|
279
|
+
f"Amount: [cyan]KES {amount:,}[/cyan]
|
|
280
|
+
"
|
|
281
|
+
f"Reference: [cyan]{ref}[/cyan]
|
|
282
|
+
"
|
|
283
|
+
f"Checkout ID: [dim]{checkout_id}[/dim]
|
|
284
|
+
|
|
285
|
+
"
|
|
286
|
+
f"[dim]The customer will receive a PIN prompt on their phone.[/dim]
|
|
287
|
+
"
|
|
288
|
+
f"[dim]Run: pesa stk query {checkout_id}[/dim]",
|
|
289
|
+
title="STK Push", border_style="green",
|
|
290
|
+
))
|
|
291
|
+
else:
|
|
292
|
+
rprint(Panel(
|
|
293
|
+
f"[red]✗ Request rejected[/red]
|
|
294
|
+
{json.dumps(response, indent=2)}",
|
|
295
|
+
title="STK Push", border_style="red",
|
|
296
|
+
))
|
|
297
|
+
raise typer.Exit(1)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@stk_app.command("query")
|
|
301
|
+
def stk_query(
|
|
302
|
+
checkout_id: str = typer.Argument(..., help="CheckoutRequestID from stk push"),
|
|
303
|
+
):
|
|
304
|
+
"""Poll the status of an STK Push request."""
|
|
305
|
+
consumer_key = require_credential("consumer_key", "Consumer Key")
|
|
306
|
+
consumer_secret = require_credential("consumer_secret", "Consumer Secret")
|
|
307
|
+
shortcode = require_credential("shortcode", "Shortcode")
|
|
308
|
+
passkey = require_credential("passkey", "Passkey")
|
|
309
|
+
|
|
310
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
311
|
+
password = _stk_password(shortcode, passkey, timestamp)
|
|
312
|
+
|
|
313
|
+
with console.status("Querying status..."):
|
|
314
|
+
try:
|
|
315
|
+
token = _get_token(consumer_key, consumer_secret)
|
|
316
|
+
response = _daraja_post("/mpesa/stkpushquery/v1/query", {
|
|
317
|
+
"BusinessShortCode": shortcode,
|
|
318
|
+
"Password": password,
|
|
319
|
+
"Timestamp": timestamp,
|
|
320
|
+
"CheckoutRequestID": checkout_id,
|
|
321
|
+
}, token)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
rprint(f"[red]✗ Query failed:[/red] {e}")
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
|
|
326
|
+
result_code = response.get("ResultCode", "?")
|
|
327
|
+
result_desc = response.get("ResultDesc", "Unknown")
|
|
328
|
+
STATUS_COLORS = {"0": "green", "1032": "yellow", "1037": "yellow"}
|
|
329
|
+
color = STATUS_COLORS.get(str(result_code), "red")
|
|
330
|
+
symbol = "✓" if str(result_code) == "0" else "✗"
|
|
331
|
+
rprint(Panel(
|
|
332
|
+
f"[{color}]{symbol} {result_desc}[/{color}]
|
|
333
|
+
"
|
|
334
|
+
f"Result Code: [cyan]{result_code}[/cyan]
|
|
335
|
+
"
|
|
336
|
+
f"Checkout ID: [dim]{checkout_id}[/dim]",
|
|
337
|
+
title="STK Query", border_style=color,
|
|
338
|
+
))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command()
|
|
342
|
+
def b2c(
|
|
343
|
+
phone: str = typer.Argument(..., help="Recipient phone number"),
|
|
344
|
+
amount: int = typer.Argument(..., help="Amount in KES"),
|
|
345
|
+
remarks: str = typer.Option("Payment", "--remarks", help="Transaction remarks"),
|
|
346
|
+
occasion: str = typer.Option("", "--occasion", help="Optional occasion"),
|
|
347
|
+
):
|
|
348
|
+
"""Send money from shortcode to a phone number (B2C)."""
|
|
349
|
+
phone = _normalise_phone(phone)
|
|
350
|
+
consumer_key = require_credential("consumer_key", "Consumer Key")
|
|
351
|
+
consumer_secret = require_credential("consumer_secret", "Consumer Secret")
|
|
352
|
+
shortcode = require_credential("shortcode", "Shortcode")
|
|
353
|
+
initiator = require_credential("initiator_name", "Initiator Name")
|
|
354
|
+
security_cred = require_credential("security_credential", "Security Credential")
|
|
355
|
+
result_url = get_credential("result_url") or "https://example.com/b2c/result"
|
|
356
|
+
timeout_url = get_credential("timeout_url") or "https://example.com/b2c/timeout"
|
|
357
|
+
|
|
358
|
+
with console.status(f"Sending KES {amount:,} to +{phone}..."):
|
|
359
|
+
try:
|
|
360
|
+
token = _get_token(consumer_key, consumer_secret)
|
|
361
|
+
response = _daraja_post("/mpesa/b2c/v3/paymentrequest", {
|
|
362
|
+
"InitiatorName": initiator,
|
|
363
|
+
"SecurityCredential": security_cred,
|
|
364
|
+
"CommandID": "BusinessPayment",
|
|
365
|
+
"Amount": amount,
|
|
366
|
+
"PartyA": shortcode,
|
|
367
|
+
"PartyB": f"+{phone}",
|
|
368
|
+
"Remarks": remarks,
|
|
369
|
+
"QueueTimeOutURL": timeout_url,
|
|
370
|
+
"ResultURL": result_url,
|
|
371
|
+
"Occasion": occasion,
|
|
372
|
+
"OriginatorConversationID": f"pesa-cli-{int(time.time())}",
|
|
373
|
+
}, token)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
rprint(f"[red]✗ B2C failed:[/red] {e}")
|
|
376
|
+
raise typer.Exit(1)
|
|
377
|
+
|
|
378
|
+
if response.get("ResponseCode") == "0":
|
|
379
|
+
rprint(Panel(
|
|
380
|
+
f"[green]✓ B2C request accepted[/green]
|
|
381
|
+
"
|
|
382
|
+
f"Phone: [cyan]+{phone}[/cyan]
|
|
383
|
+
"
|
|
384
|
+
f"Amount: [cyan]KES {amount:,}[/cyan]
|
|
385
|
+
"
|
|
386
|
+
f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]
|
|
387
|
+
|
|
388
|
+
"
|
|
389
|
+
f"[dim]Result will be posted to your ResultURL.[/dim]",
|
|
390
|
+
title="B2C", border_style="green",
|
|
391
|
+
))
|
|
392
|
+
else:
|
|
393
|
+
rprint(Panel(
|
|
394
|
+
f"[red]✗ B2C rejected[/red]
|
|
395
|
+
{json.dumps(response, indent=2)}",
|
|
396
|
+
title="B2C", border_style="red",
|
|
397
|
+
))
|
|
398
|
+
raise typer.Exit(1)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.command()
|
|
402
|
+
def balance():
|
|
403
|
+
"""Check account balance for the configured shortcode."""
|
|
404
|
+
consumer_key = require_credential("consumer_key", "Consumer Key")
|
|
405
|
+
consumer_secret = require_credential("consumer_secret", "Consumer Secret")
|
|
406
|
+
shortcode = require_credential("shortcode", "Shortcode")
|
|
407
|
+
initiator = require_credential("initiator_name", "Initiator Name")
|
|
408
|
+
security_cred = require_credential("security_credential", "Security Credential")
|
|
409
|
+
result_url = get_credential("result_url") or "https://example.com/balance/result"
|
|
410
|
+
timeout_url = get_credential("timeout_url") or "https://example.com/balance/timeout"
|
|
411
|
+
|
|
412
|
+
with console.status("Requesting balance..."):
|
|
413
|
+
try:
|
|
414
|
+
token = _get_token(consumer_key, consumer_secret)
|
|
415
|
+
response = _daraja_post("/mpesa/accountbalance/v1/query", {
|
|
416
|
+
"InitiatorName": initiator,
|
|
417
|
+
"SecurityCredential": security_cred,
|
|
418
|
+
"CommandID": "AccountBalance",
|
|
419
|
+
"PartyA": shortcode,
|
|
420
|
+
"IdentifierType": "4",
|
|
421
|
+
"Remarks": "Balance query from pesa-cli",
|
|
422
|
+
"QueueTimeOutURL": timeout_url,
|
|
423
|
+
"ResultURL": result_url,
|
|
424
|
+
}, token)
|
|
425
|
+
except Exception as e:
|
|
426
|
+
rprint(f"[red]✗ Balance query failed:[/red] {e}")
|
|
427
|
+
raise typer.Exit(1)
|
|
428
|
+
|
|
429
|
+
if response.get("ResponseCode") == "0":
|
|
430
|
+
rprint(Panel(
|
|
431
|
+
f"[green]✓ Balance request accepted[/green]
|
|
432
|
+
"
|
|
433
|
+
f"Shortcode: [cyan]{shortcode}[/cyan]
|
|
434
|
+
"
|
|
435
|
+
f"Conversation ID: [dim]{response.get('ConversationID', '')}[/dim]
|
|
436
|
+
|
|
437
|
+
"
|
|
438
|
+
f"[dim]Balance will be posted to your ResultURL asynchronously.[/dim]",
|
|
439
|
+
title="Account Balance", border_style="green",
|
|
440
|
+
))
|
|
441
|
+
else:
|
|
442
|
+
rprint(Panel(
|
|
443
|
+
f"[red]✗ Request rejected[/red]
|
|
444
|
+
{json.dumps(response, indent=2)}",
|
|
445
|
+
title="Account Balance", border_style="red",
|
|
446
|
+
))
|
|
447
|
+
raise typer.Exit(1)
|
pesa_cli/main.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pesa — command-line tool for M-Pesa Daraja v3.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
pesa stk-push — trigger STK Push payment prompt
|
|
6
|
+
pesa stk-query — check STK Push status
|
|
7
|
+
pesa b2c — send money to phone (B2C)
|
|
8
|
+
pesa balance — check shortcode balance
|
|
9
|
+
pesa config — show/set environment configuration
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
export MPESA_CONSUMER_KEY=...
|
|
13
|
+
export MPESA_CONSUMER_SECRET=...
|
|
14
|
+
export MPESA_SHORTCODE=174379
|
|
15
|
+
export MPESA_PASSKEY=...
|
|
16
|
+
export MPESA_CALLBACK_URL=https://example.com/callback
|
|
17
|
+
export MPESA_SANDBOX=true
|
|
18
|
+
|
|
19
|
+
pesa stk-push --phone 0712345678 --amount 100 --ref "Order1"
|
|
20
|
+
pesa stk-query --id ws_CO_...
|
|
21
|
+
pesa balance
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
import requests
|
|
34
|
+
import typer
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
name="pesa",
|
|
38
|
+
help="Command-line tool for M-Pesa Daraja v3.",
|
|
39
|
+
no_args_is_help=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_token_cache: dict = {"token": None, "expires_at": 0.0}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def _base_url() -> str:
|
|
48
|
+
sandbox = os.environ.get("MPESA_SANDBOX", "true").lower() == "true"
|
|
49
|
+
return "https://sandbox.safaricom.co.ke" if sandbox else "https://api.safaricom.co.ke"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_token() -> str:
|
|
53
|
+
if time.time() < _token_cache.get("expires_at", 0) - 30:
|
|
54
|
+
return _token_cache["token"] # type: ignore[return-value]
|
|
55
|
+
key = _require("MPESA_CONSUMER_KEY")
|
|
56
|
+
secret = _require("MPESA_CONSUMER_SECRET")
|
|
57
|
+
creds = base64.b64encode(f"{key}:{secret}".encode()).decode()
|
|
58
|
+
resp = requests.get(
|
|
59
|
+
f"{_base_url()}/oauth/v1/generate?grant_type=client_credentials",
|
|
60
|
+
headers={"Authorization": f"Basic {creds}"},
|
|
61
|
+
timeout=10,
|
|
62
|
+
)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
data = resp.json()
|
|
65
|
+
_token_cache["token"] = data["access_token"]
|
|
66
|
+
_token_cache["expires_at"] = time.time() + int(data["expires_in"])
|
|
67
|
+
return _token_cache["token"] # type: ignore[return-value]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _require(var: str) -> str:
|
|
71
|
+
val = os.environ.get(var)
|
|
72
|
+
if not val:
|
|
73
|
+
typer.echo(f"Error: {var} not set. Run: pesa config", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
return val
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize(phone: str) -> str:
|
|
79
|
+
phone = phone.strip().lstrip("+").replace(" ", "")
|
|
80
|
+
if phone.startswith("0"):
|
|
81
|
+
phone = "254" + phone[1:]
|
|
82
|
+
elif not phone.startswith("254"):
|
|
83
|
+
phone = "254" + phone
|
|
84
|
+
return phone
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _password_ts() -> tuple[str, str]:
|
|
88
|
+
shortcode = _require("MPESA_SHORTCODE")
|
|
89
|
+
passkey = _require("MPESA_PASSKEY")
|
|
90
|
+
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
91
|
+
pw = base64.b64encode(f"{shortcode}{passkey}{ts}".encode()).decode()
|
|
92
|
+
return pw, ts
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _post(path: str, payload: dict) -> dict:
|
|
96
|
+
token = _get_token()
|
|
97
|
+
resp = requests.post(
|
|
98
|
+
f"{_base_url()}{path}",
|
|
99
|
+
json=payload,
|
|
100
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
101
|
+
timeout=15,
|
|
102
|
+
)
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
return resp.json()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _print_result(data: dict, success_key: str = "ResponseCode", success_val: str = "0") -> None:
|
|
108
|
+
is_ok = data.get(success_key) == success_val
|
|
109
|
+
icon = "✓" if is_ok else "✗"
|
|
110
|
+
for k, v in data.items():
|
|
111
|
+
typer.echo(f" {k}: {v}")
|
|
112
|
+
typer.echo(f"\n{icon} {'Success' if is_ok else 'Failed'}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
@app.command("stk-push")
|
|
118
|
+
def stk_push(
|
|
119
|
+
phone: str = typer.Option(..., "--phone", "-p", help="Phone number (07xx, 254xx, or +254xx)"),
|
|
120
|
+
amount: int = typer.Option(..., "--amount", "-a", help="Amount in KES (whole number)"),
|
|
121
|
+
ref: str = typer.Option("Payment", "--ref", "-r", help="Account reference (max 12 chars)"),
|
|
122
|
+
desc: str = typer.Option("Payment", "--desc", "-d", help="Transaction description"),
|
|
123
|
+
):
|
|
124
|
+
"""Trigger an M-Pesa STK Push payment prompt on the customer's phone."""
|
|
125
|
+
shortcode = _require("MPESA_SHORTCODE")
|
|
126
|
+
callback = _require("MPESA_CALLBACK_URL")
|
|
127
|
+
pw, ts = _password_ts()
|
|
128
|
+
phone = _normalize(phone)
|
|
129
|
+
|
|
130
|
+
typer.echo(f"Sending STK Push: KES {amount} → {phone}")
|
|
131
|
+
data = _post("/mpesa/stkpush/v1/processrequest", {
|
|
132
|
+
"BusinessShortCode": shortcode,
|
|
133
|
+
"Password": pw,
|
|
134
|
+
"Timestamp": ts,
|
|
135
|
+
"TransactionType": "CustomerPayBillOnline",
|
|
136
|
+
"Amount": amount,
|
|
137
|
+
"PartyA": phone,
|
|
138
|
+
"PartyB": shortcode,
|
|
139
|
+
"PhoneNumber": phone,
|
|
140
|
+
"CallBackURL": callback,
|
|
141
|
+
"AccountReference": ref[:12],
|
|
142
|
+
"TransactionDesc": desc[:13],
|
|
143
|
+
})
|
|
144
|
+
_print_result(data)
|
|
145
|
+
checkout_id = data.get("CheckoutRequestID")
|
|
146
|
+
if checkout_id:
|
|
147
|
+
typer.echo(f"\nCheckoutRequestID: {checkout_id}")
|
|
148
|
+
typer.echo(f"Run: pesa stk-query --id {checkout_id}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command("stk-query")
|
|
152
|
+
def stk_query(
|
|
153
|
+
checkout_id: str = typer.Option(..., "--id", "-i", help="CheckoutRequestID from stk-push"),
|
|
154
|
+
wait: int = typer.Option(0, "--wait", "-w", help="Seconds to wait before querying (0=immediate)"),
|
|
155
|
+
):
|
|
156
|
+
"""Check the status of an STK Push request."""
|
|
157
|
+
if wait:
|
|
158
|
+
typer.echo(f"Waiting {wait}s...")
|
|
159
|
+
time.sleep(wait)
|
|
160
|
+
|
|
161
|
+
shortcode = _require("MPESA_SHORTCODE")
|
|
162
|
+
pw, ts = _password_ts()
|
|
163
|
+
|
|
164
|
+
data = _post("/mpesa/stkpushquery/v1/query", {
|
|
165
|
+
"BusinessShortCode": shortcode,
|
|
166
|
+
"Password": pw,
|
|
167
|
+
"Timestamp": ts,
|
|
168
|
+
"CheckoutRequestID": checkout_id,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
result_code = data.get("ResultCode", "-1")
|
|
172
|
+
status_map = {
|
|
173
|
+
"0": "✓ SUCCESS",
|
|
174
|
+
"1": "✗ INSUFFICIENT FUNDS",
|
|
175
|
+
"1032": "✗ CANCELLED BY USER",
|
|
176
|
+
"1037": "✗ TIMED OUT",
|
|
177
|
+
"2001": "✗ WRONG PIN",
|
|
178
|
+
}
|
|
179
|
+
typer.echo(f"Status: {status_map.get(result_code, f'Code {result_code}')}")
|
|
180
|
+
typer.echo(f"Description: {data.get('ResultDesc')}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("b2c")
|
|
184
|
+
def b2c(
|
|
185
|
+
phone: str = typer.Option(..., "--phone", "-p", help="Recipient phone number"),
|
|
186
|
+
amount: int = typer.Option(..., "--amount", "-a", help="Amount in KES"),
|
|
187
|
+
reason: str = typer.Option("SalaryPayment", "--reason", "-r",
|
|
188
|
+
help="CommandID: SalaryPayment | BusinessPayment | PromotionPayment"),
|
|
189
|
+
):
|
|
190
|
+
"""Send money from shortcode to a phone number (B2C)."""
|
|
191
|
+
initiator = _require("MPESA_INITIATOR_NAME")
|
|
192
|
+
security = _require("MPESA_SECURITY_CREDENTIAL")
|
|
193
|
+
shortcode = _require("MPESA_SHORTCODE")
|
|
194
|
+
callback = _require("MPESA_CALLBACK_URL")
|
|
195
|
+
phone = _normalize(phone)
|
|
196
|
+
|
|
197
|
+
typer.echo(f"Sending B2C: KES {amount} → {phone}")
|
|
198
|
+
data = _post("/mpesa/b2c/v3/paymentrequest", {
|
|
199
|
+
"InitiatorName": initiator,
|
|
200
|
+
"SecurityCredential": security,
|
|
201
|
+
"CommandID": reason,
|
|
202
|
+
"Amount": amount,
|
|
203
|
+
"PartyA": shortcode,
|
|
204
|
+
"PartyB": phone,
|
|
205
|
+
"Remarks": f"B2C {reason}",
|
|
206
|
+
"QueueTimeOutURL": callback,
|
|
207
|
+
"ResultURL": callback,
|
|
208
|
+
"Occassion": "",
|
|
209
|
+
})
|
|
210
|
+
_print_result(data)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@app.command("balance")
|
|
214
|
+
def balance():
|
|
215
|
+
"""Query the M-Pesa shortcode account balance."""
|
|
216
|
+
initiator = _require("MPESA_INITIATOR_NAME")
|
|
217
|
+
security = _require("MPESA_SECURITY_CREDENTIAL")
|
|
218
|
+
shortcode = _require("MPESA_SHORTCODE")
|
|
219
|
+
callback = _require("MPESA_CALLBACK_URL")
|
|
220
|
+
|
|
221
|
+
typer.echo("Querying balance (result delivered async to callback URL)...")
|
|
222
|
+
data = _post("/mpesa/accountbalance/v1/query", {
|
|
223
|
+
"Initiator": initiator,
|
|
224
|
+
"SecurityCredential": security,
|
|
225
|
+
"CommandID": "AccountBalance",
|
|
226
|
+
"PartyA": shortcode,
|
|
227
|
+
"IdentifierType": "4",
|
|
228
|
+
"Remarks": "Balance query",
|
|
229
|
+
"QueueTimeOutURL": callback,
|
|
230
|
+
"ResultURL": callback,
|
|
231
|
+
})
|
|
232
|
+
_print_result(data)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command("config")
|
|
236
|
+
def config():
|
|
237
|
+
"""Show current M-Pesa configuration (keys masked)."""
|
|
238
|
+
env_vars = [
|
|
239
|
+
"MPESA_CONSUMER_KEY",
|
|
240
|
+
"MPESA_CONSUMER_SECRET",
|
|
241
|
+
"MPESA_SHORTCODE",
|
|
242
|
+
"MPESA_PASSKEY",
|
|
243
|
+
"MPESA_CALLBACK_URL",
|
|
244
|
+
"MPESA_INITIATOR_NAME",
|
|
245
|
+
"MPESA_SANDBOX",
|
|
246
|
+
]
|
|
247
|
+
typer.echo("Current pesa configuration:\n")
|
|
248
|
+
for var in env_vars:
|
|
249
|
+
val = os.environ.get(var)
|
|
250
|
+
if val:
|
|
251
|
+
if "KEY" in var or "SECRET" in var or "CREDENTIAL" in var or "PASSKEY" in var:
|
|
252
|
+
display = val[:4] + "****" + val[-4:] if len(val) > 8 else "****"
|
|
253
|
+
else:
|
|
254
|
+
display = val
|
|
255
|
+
else:
|
|
256
|
+
display = "(not set)"
|
|
257
|
+
typer.echo(f" {var:<32} {display}")
|
|
258
|
+
|
|
259
|
+
sandbox = os.environ.get("MPESA_SANDBOX", "true").lower() == "true"
|
|
260
|
+
typer.echo(f"\n Mode: {'SANDBOX' if sandbox else 'PRODUCTION'}")
|
|
261
|
+
typer.echo(f" Base URL: {_base_url()}")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def main() -> None:
|
|
265
|
+
app()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
main()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pesa-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance, config management.
|
|
5
|
+
Author-email: Gabriel Mahia <contact@aikungfu.dev>
|
|
6
|
+
License: CC-BY-NC-ND-4.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/gabrielmahia/pesa-cli
|
|
8
|
+
Keywords: mpesa,safaricom,daraja,cli,kenya,fintech,python,typer
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: typer>=0.12
|
|
20
|
+
Requires-Dist: requests>=2.28
|
|
21
|
+
Requires-Dist: rich>=13
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# pesa-cli
|
|
28
|
+
|
|
29
|
+
**Command-line tool for M-Pesa Daraja v3 — STK Push, B2C, balance check, config management.**
|
|
30
|
+
|
|
31
|
+
[](https://github.com/gabrielmahia/pesa-cli/actions)
|
|
32
|
+
[](#)
|
|
33
|
+
[](#)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
Test integrations, trigger payments, and check balances from your terminal —
|
|
37
|
+
without opening the Safaricom portal or writing a script.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install pesa-cli
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Setup
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Set credentials once — stored in ~/.pesa/config.json (mode 600)
|
|
53
|
+
pesa config set --key consumer-key --value YOUR_CONSUMER_KEY
|
|
54
|
+
pesa config set --key consumer-secret --value YOUR_CONSUMER_SECRET
|
|
55
|
+
pesa config set --key shortcode --value 174379
|
|
56
|
+
pesa config set --key passkey --value YOUR_PASSKEY
|
|
57
|
+
pesa config set --key environment --value sandbox # or production
|
|
58
|
+
|
|
59
|
+
# Or use environment variables (useful in CI/CD)
|
|
60
|
+
export DARAJA_CONSUMER_KEY=...
|
|
61
|
+
export DARAJA_CONSUMER_SECRET=...
|
|
62
|
+
|
|
63
|
+
# Test credentials
|
|
64
|
+
pesa auth
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
### `pesa auth`
|
|
72
|
+
Test credentials and verify connectivity.
|
|
73
|
+
```
|
|
74
|
+
✓ Authenticated
|
|
75
|
+
Environment: sandbox
|
|
76
|
+
Token: mock_token_ab...3456
|
|
77
|
+
Expires in: ~60 minutes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `pesa stk push PHONE AMOUNT`
|
|
81
|
+
Initiate an STK Push payment request. The customer receives a PIN prompt on their phone.
|
|
82
|
+
```bash
|
|
83
|
+
pesa stk push 0712345678 500
|
|
84
|
+
pesa stk push 0712345678 500 --ref "Invoice001" --desc "Monthly contribution"
|
|
85
|
+
```
|
|
86
|
+
```
|
|
87
|
+
✓ STK Push sent
|
|
88
|
+
Phone: +254712345678
|
|
89
|
+
Amount: KES 500
|
|
90
|
+
Reference: Invoice001
|
|
91
|
+
Checkout ID: ws_CO_150120241430221234567890
|
|
92
|
+
|
|
93
|
+
Run: pesa stk query ws_CO_150120241430221234567890
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `pesa stk query CHECKOUT_ID`
|
|
97
|
+
Poll the status of an STK Push.
|
|
98
|
+
```bash
|
|
99
|
+
pesa stk query ws_CO_150120241430221234567890
|
|
100
|
+
```
|
|
101
|
+
```
|
|
102
|
+
✓ The service request is processed successfully.
|
|
103
|
+
Result Code: 0
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `pesa b2c PHONE AMOUNT`
|
|
107
|
+
Send money from your shortcode to a phone number.
|
|
108
|
+
```bash
|
|
109
|
+
pesa b2c 0712345678 1000 --remarks "Chama disbursement"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `pesa balance`
|
|
113
|
+
Request account balance (result posted to your ResultURL asynchronously).
|
|
114
|
+
```bash
|
|
115
|
+
pesa balance
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `pesa config show`
|
|
119
|
+
Display current configuration with secrets masked.
|
|
120
|
+
```
|
|
121
|
+
┌──────────────────────────────────────────────────┐
|
|
122
|
+
│ pesa configuration │
|
|
123
|
+
├──────────────────┬─────────────┬─────────────────┤
|
|
124
|
+
│ consumer_key │ config file │ ************3456 │
|
|
125
|
+
│ consumer_secret │ config file │ ************abcd │
|
|
126
|
+
│ shortcode │ config file │ 174379 │
|
|
127
|
+
│ environment │ config file │ sandbox │
|
|
128
|
+
└──────────────────┴─────────────┴─────────────────┘
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Credentials
|
|
134
|
+
|
|
135
|
+
Credentials are resolved in this order: **environment variable** → **config file**.
|
|
136
|
+
|
|
137
|
+
| Credential | Config key | Environment variable |
|
|
138
|
+
|------------|-----------|----------------------|
|
|
139
|
+
| Consumer Key | `consumer_key` | `DARAJA_CONSUMER_KEY` |
|
|
140
|
+
| Consumer Secret | `consumer_secret` | `DARAJA_CONSUMER_SECRET` |
|
|
141
|
+
| Shortcode | `shortcode` | `DARAJA_SHORTCODE` |
|
|
142
|
+
| Passkey | `passkey` | `DARAJA_PASSKEY` |
|
|
143
|
+
| Initiator Name | `initiator_name` | `DARAJA_INITIATOR_NAME` |
|
|
144
|
+
| Security Credential | `security_credential` | `DARAJA_SECURITY_CREDENTIAL` |
|
|
145
|
+
| Environment | `environment` | `DARAJA_ENVIRONMENT` |
|
|
146
|
+
|
|
147
|
+
Config file is stored at `~/.pesa/config.json` with mode 600 (owner read/write only).
|
|
148
|
+
Override location with `PESA_CONFIG=/path/to/config.json`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Use with daraja-mock
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
# Start daraja-mock
|
|
156
|
+
python -m daraja_mock --port 8765
|
|
157
|
+
|
|
158
|
+
# Point pesa-cli at the mock
|
|
159
|
+
export PESA_BASE_URL=http://localhost:8765
|
|
160
|
+
pesa auth
|
|
161
|
+
pesa stk push 0712345678 100 --ref "Test"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Design decisions
|
|
167
|
+
|
|
168
|
+
**Credentials resolved env → file.** CI/CD environments should use env vars.
|
|
169
|
+
Developer machines use the config file. The CLI never asks for credentials
|
|
170
|
+
interactively — that would break scripting.
|
|
171
|
+
|
|
172
|
+
**Secrets masked at 600.** The config file is created with `chmod 600` so
|
|
173
|
+
other users on shared systems cannot read Daraja credentials.
|
|
174
|
+
|
|
175
|
+
**AccountReference truncated silently.** Daraja rejects `AccountReference` > 12 chars
|
|
176
|
+
with a cryptic error. The CLI truncates to 12 and shows what was sent.
|
|
177
|
+
|
|
178
|
+
**Rich output, not JSON by default.** For human use. Add `--output json` flag
|
|
179
|
+
if scripting against pesa-cli output is needed.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
*Part of the [nairobi-stack](https://github.com/gabrielmahia/nairobi-stack) East Africa engineering ecosystem.*
|
|
184
|
+
*Maintained by [Gabriel Mahia](https://github.com/gabrielmahia). Kenya × USA.*
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pesa_cli/__init__.py,sha256=Ve3bQpvLbYmTt2qBNPYwzcApA-8C_ZbZqVpHvVDkmLs,54
|
|
2
|
+
pesa_cli/__main__.py,sha256=d4F2wMiETFozbkxaPkB0nbnte2qDbnr2XG_11Nq2b0w,67
|
|
3
|
+
pesa_cli/cli.py,sha256=5jhDYO1xnX9T06fshQ_yNcaHKbH-FBPS3ekLwU1Hd78,17060
|
|
4
|
+
pesa_cli/main.py,sha256=g9TJZYw7HCQvFVZRkZRjOjMo8q6sh4sTfcM4P65E5RI,9183
|
|
5
|
+
pesa_cli-1.0.0.dist-info/licenses/LICENSE,sha256=ciwpMjQTMsbBPvPCwMoSZCVNcZV54s2JDp-16m57Dn0,168
|
|
6
|
+
pesa_cli-1.0.0.dist-info/METADATA,sha256=Fo5g3T-GYhhWywToJTxsksPYfwUYY5_0QtwGQ7KFnV4,5970
|
|
7
|
+
pesa_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
pesa_cli-1.0.0.dist-info/entry_points.txt,sha256=Gd19aESW0F9H7FYTFCZZYDH-fJsUogvDaWD7xgBildo,42
|
|
9
|
+
pesa_cli-1.0.0.dist-info/top_level.txt,sha256=vZOZjKhhuE7wM_BFaZ0TVcxoHOFgTGJDCIusJc6neig,9
|
|
10
|
+
pesa_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pesa_cli
|