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 ADDED
@@ -0,0 +1,2 @@
1
+ from .main import app, main
2
+ __all__ = ["app", "main"]
pesa_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pesa_cli.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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
+ [![CI](https://github.com/gabrielmahia/pesa-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/gabrielmahia/pesa-cli/actions)
32
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](#)
33
+ [![Tests](https://img.shields.io/badge/tests-34%20passing-brightgreen)](#)
34
+ [![License](https://img.shields.io/badge/License-CC%20BY--NC--ND%204.0-lightgrey)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pesa = pesa_cli.cli:app
@@ -0,0 +1,3 @@
1
+ Creative Commons Attribution-NonCommercial-NoDerivatives 4.0
2
+ Copyright (c) 2026 Gabriel Mahia <contact@aikungfu.dev>
3
+ https://creativecommons.org/licenses/by-nc-nd/4.0/
@@ -0,0 +1 @@
1
+ pesa_cli