safcom 0.1.0__tar.gz
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.
- safcom-0.1.0/PKG-INFO +154 -0
- safcom-0.1.0/README.md +146 -0
- safcom-0.1.0/pyproject.toml +15 -0
- safcom-0.1.0/safcom/__init__.py +4 -0
- safcom-0.1.0/safcom/auth.py +52 -0
- safcom-0.1.0/safcom/cli.py +93 -0
- safcom-0.1.0/safcom/exceptions.py +17 -0
- safcom-0.1.0/safcom/models.py +54 -0
- safcom-0.1.0/safcom/mpesa.py +303 -0
- safcom-0.1.0/safcom.egg-info/PKG-INFO +154 -0
- safcom-0.1.0/safcom.egg-info/SOURCES.txt +15 -0
- safcom-0.1.0/safcom.egg-info/dependency_links.txt +1 -0
- safcom-0.1.0/safcom.egg-info/entry_points.txt +2 -0
- safcom-0.1.0/safcom.egg-info/requires.txt +1 -0
- safcom-0.1.0/safcom.egg-info/top_level.txt +1 -0
- safcom-0.1.0/setup.cfg +4 -0
- safcom-0.1.0/tests/test_mpesa.py +68 -0
safcom-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safcom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: M-Pesa API wrapper for Kenyan developers — STK push, callbacks, B2C, and more.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
|
|
9
|
+
<h1 align="center">safcom</h1>
|
|
10
|
+
<p align="center">
|
|
11
|
+
<em>M-Pesa API — the way it should be.</em>
|
|
12
|
+
<br>
|
|
13
|
+
<br>
|
|
14
|
+
<img src="https://img.shields.io/badge/python-≥3.10-10b981" alt="Python 3.10+">
|
|
15
|
+
<img src="https://img.shields.io/badge/license-MIT-10b981" alt="MIT License">
|
|
16
|
+
<img src="https://img.shields.io/badge/version-0.1.0-10b981" alt="Version 0.1.0">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
STK push, payment queries, B2C, account balance — all in a clean Python package that doesn't make you read raw JSON.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install safcom
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from safcom import Mpesa
|
|
31
|
+
|
|
32
|
+
mpesa = Mpesa(
|
|
33
|
+
consumer_key="your_key",
|
|
34
|
+
consumer_secret="your_secret",
|
|
35
|
+
passkey="your_passkey",
|
|
36
|
+
shortcode="174379",
|
|
37
|
+
env="sandbox", # or "production"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# One line. STK push. Done.
|
|
41
|
+
resp = mpesa.stk_push(
|
|
42
|
+
phone="254712345678",
|
|
43
|
+
amount=100,
|
|
44
|
+
account_ref="INV-001",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
print(resp.checkout_request_id) # → "ws_CO_...202507..."
|
|
48
|
+
print(resp.customer_message) # → "Please enter your M-Pesa PIN"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **STK Push** — Initiate Lipa Na M-Pesa Online payments with one call
|
|
54
|
+
- **STK Push Query** — Check if a payment went through
|
|
55
|
+
- **B2C** — Send money to customers (BusinessPayment)
|
|
56
|
+
- **Account Balance** — Query your M-Pesa balance
|
|
57
|
+
- **Callback handling** — Decorator-based handler for M-Pesa notifications
|
|
58
|
+
- **Automatic auth** — Tokens refresh automatically; you never think about it
|
|
59
|
+
- **Typed responses** — Every response is a dataclass, not raw dicts
|
|
60
|
+
- **CLI included** — Test payments from the terminal
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### STK Push
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# Minimal
|
|
68
|
+
resp = mpesa.stk_push(phone="254712345678", amount=500, account_ref="INV-001")
|
|
69
|
+
|
|
70
|
+
# With all options
|
|
71
|
+
resp = mpesa.stk_push(
|
|
72
|
+
phone="254712345678",
|
|
73
|
+
amount=500,
|
|
74
|
+
account_ref="INV-001",
|
|
75
|
+
transaction_desc="Payment for invoice INV-001",
|
|
76
|
+
callback_url="https://your-app.com/mpesa/callback",
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Check Payment Status
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
status = mpesa.stk_push_query("ws_CO_...202507...")
|
|
84
|
+
|
|
85
|
+
if status.success:
|
|
86
|
+
print(f"Paid! Receipt: {status.receipt}")
|
|
87
|
+
else:
|
|
88
|
+
print(f"Not paid: {status.result_description}")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Send Money (B2C)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
resp = mpesa.b2c(
|
|
95
|
+
phone="254712345678",
|
|
96
|
+
amount=500,
|
|
97
|
+
remarks="refund",
|
|
98
|
+
initiator_name="testapi",
|
|
99
|
+
security_credential="...",
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Account Balance
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
resp = mpesa.account_balance(
|
|
107
|
+
initiator_name="testapi",
|
|
108
|
+
security_credential="...",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### From the CLI
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Set credentials (or use SAFCOM_* env vars)
|
|
116
|
+
export SAFCOM_CONSUMER_KEY=your_key
|
|
117
|
+
export SAFCOM_CONSUMER_SECRET=your_secret
|
|
118
|
+
export SAFCOM_PASSKEY=your_passkey
|
|
119
|
+
export SAFCOM_SHORTCODE=174379
|
|
120
|
+
|
|
121
|
+
# Send STK push
|
|
122
|
+
safcom stkpush 254712345678 100 --ref INV-001
|
|
123
|
+
|
|
124
|
+
# Query payment status
|
|
125
|
+
safcom query ws_CO_...202507...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Why safcom?
|
|
129
|
+
|
|
130
|
+
The M-Pesa API is powerful but painful:
|
|
131
|
+
|
|
132
|
+
- Authentication is manual and easy to mess up
|
|
133
|
+
- Responses come in inconsistent formats between sandbox and production
|
|
134
|
+
- Error messages are cryptic
|
|
135
|
+
- Every Kenyan dev rewrites the same wrapper
|
|
136
|
+
|
|
137
|
+
safcom fixes all of that. Sensible defaults, typed responses, clear errors. One package, one `pip install`, done.
|
|
138
|
+
|
|
139
|
+
## Roadmap
|
|
140
|
+
|
|
141
|
+
- [x] STK Push
|
|
142
|
+
- [x] STK Push Query
|
|
143
|
+
- [x] B2C (BusinessPayment)
|
|
144
|
+
- [x] Account Balance
|
|
145
|
+
- [x] CLI
|
|
146
|
+
- [ ] C2B Register URL
|
|
147
|
+
- [ ] C2B Simulate
|
|
148
|
+
- [ ] Reversal
|
|
149
|
+
- [ ] Transaction Status
|
|
150
|
+
- [ ] Async support (httpx.AsyncClient)
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
safcom-0.1.0/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<h1 align="center">safcom</h1>
|
|
2
|
+
<p align="center">
|
|
3
|
+
<em>M-Pesa API — the way it should be.</em>
|
|
4
|
+
<br>
|
|
5
|
+
<br>
|
|
6
|
+
<img src="https://img.shields.io/badge/python-≥3.10-10b981" alt="Python 3.10+">
|
|
7
|
+
<img src="https://img.shields.io/badge/license-MIT-10b981" alt="MIT License">
|
|
8
|
+
<img src="https://img.shields.io/badge/version-0.1.0-10b981" alt="Version 0.1.0">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
STK push, payment queries, B2C, account balance — all in a clean Python package that doesn't make you read raw JSON.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install safcom
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from safcom import Mpesa
|
|
23
|
+
|
|
24
|
+
mpesa = Mpesa(
|
|
25
|
+
consumer_key="your_key",
|
|
26
|
+
consumer_secret="your_secret",
|
|
27
|
+
passkey="your_passkey",
|
|
28
|
+
shortcode="174379",
|
|
29
|
+
env="sandbox", # or "production"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# One line. STK push. Done.
|
|
33
|
+
resp = mpesa.stk_push(
|
|
34
|
+
phone="254712345678",
|
|
35
|
+
amount=100,
|
|
36
|
+
account_ref="INV-001",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
print(resp.checkout_request_id) # → "ws_CO_...202507..."
|
|
40
|
+
print(resp.customer_message) # → "Please enter your M-Pesa PIN"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **STK Push** — Initiate Lipa Na M-Pesa Online payments with one call
|
|
46
|
+
- **STK Push Query** — Check if a payment went through
|
|
47
|
+
- **B2C** — Send money to customers (BusinessPayment)
|
|
48
|
+
- **Account Balance** — Query your M-Pesa balance
|
|
49
|
+
- **Callback handling** — Decorator-based handler for M-Pesa notifications
|
|
50
|
+
- **Automatic auth** — Tokens refresh automatically; you never think about it
|
|
51
|
+
- **Typed responses** — Every response is a dataclass, not raw dicts
|
|
52
|
+
- **CLI included** — Test payments from the terminal
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### STK Push
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# Minimal
|
|
60
|
+
resp = mpesa.stk_push(phone="254712345678", amount=500, account_ref="INV-001")
|
|
61
|
+
|
|
62
|
+
# With all options
|
|
63
|
+
resp = mpesa.stk_push(
|
|
64
|
+
phone="254712345678",
|
|
65
|
+
amount=500,
|
|
66
|
+
account_ref="INV-001",
|
|
67
|
+
transaction_desc="Payment for invoice INV-001",
|
|
68
|
+
callback_url="https://your-app.com/mpesa/callback",
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Check Payment Status
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
status = mpesa.stk_push_query("ws_CO_...202507...")
|
|
76
|
+
|
|
77
|
+
if status.success:
|
|
78
|
+
print(f"Paid! Receipt: {status.receipt}")
|
|
79
|
+
else:
|
|
80
|
+
print(f"Not paid: {status.result_description}")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Send Money (B2C)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
resp = mpesa.b2c(
|
|
87
|
+
phone="254712345678",
|
|
88
|
+
amount=500,
|
|
89
|
+
remarks="refund",
|
|
90
|
+
initiator_name="testapi",
|
|
91
|
+
security_credential="...",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Account Balance
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
resp = mpesa.account_balance(
|
|
99
|
+
initiator_name="testapi",
|
|
100
|
+
security_credential="...",
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### From the CLI
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Set credentials (or use SAFCOM_* env vars)
|
|
108
|
+
export SAFCOM_CONSUMER_KEY=your_key
|
|
109
|
+
export SAFCOM_CONSUMER_SECRET=your_secret
|
|
110
|
+
export SAFCOM_PASSKEY=your_passkey
|
|
111
|
+
export SAFCOM_SHORTCODE=174379
|
|
112
|
+
|
|
113
|
+
# Send STK push
|
|
114
|
+
safcom stkpush 254712345678 100 --ref INV-001
|
|
115
|
+
|
|
116
|
+
# Query payment status
|
|
117
|
+
safcom query ws_CO_...202507...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Why safcom?
|
|
121
|
+
|
|
122
|
+
The M-Pesa API is powerful but painful:
|
|
123
|
+
|
|
124
|
+
- Authentication is manual and easy to mess up
|
|
125
|
+
- Responses come in inconsistent formats between sandbox and production
|
|
126
|
+
- Error messages are cryptic
|
|
127
|
+
- Every Kenyan dev rewrites the same wrapper
|
|
128
|
+
|
|
129
|
+
safcom fixes all of that. Sensible defaults, typed responses, clear errors. One package, one `pip install`, done.
|
|
130
|
+
|
|
131
|
+
## Roadmap
|
|
132
|
+
|
|
133
|
+
- [x] STK Push
|
|
134
|
+
- [x] STK Push Query
|
|
135
|
+
- [x] B2C (BusinessPayment)
|
|
136
|
+
- [x] Account Balance
|
|
137
|
+
- [x] CLI
|
|
138
|
+
- [ ] C2B Register URL
|
|
139
|
+
- [ ] C2B Simulate
|
|
140
|
+
- [ ] Reversal
|
|
141
|
+
- [ ] Transaction Status
|
|
142
|
+
- [ ] Async support (httpx.AsyncClient)
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "safcom"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "M-Pesa API wrapper for Kenyan developers — STK push, callbacks, B2C, and more."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.27.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
safcom = "safcom.cli:main"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
include = ["safcom*"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import httpx
|
|
3
|
+
from safcom.exceptions import AuthError
|
|
4
|
+
|
|
5
|
+
BASE_URLS = {
|
|
6
|
+
"sandbox": "https://sandbox.safaricom.co.ke",
|
|
7
|
+
"production": "https://api.safaricom.co.ke",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Auth:
|
|
12
|
+
"""Handles M-Pesa OAuth token generation with automatic refresh."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, consumer_key: str, consumer_secret: str, env: str = "sandbox"):
|
|
15
|
+
self.consumer_key = consumer_key
|
|
16
|
+
self.consumer_secret = consumer_secret
|
|
17
|
+
self.base_url = BASE_URLS.get(env)
|
|
18
|
+
if not self.base_url:
|
|
19
|
+
raise ValueError(f"env must be 'sandbox' or 'production', got '{env}'")
|
|
20
|
+
self._token: str | None = None
|
|
21
|
+
self._expires_at: datetime.datetime | None = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def token(self) -> str:
|
|
25
|
+
"""Get a valid token, refreshing automatically if expired."""
|
|
26
|
+
if self._token and self._expires_at and datetime.datetime.utcnow() < self._expires_at:
|
|
27
|
+
return self._token
|
|
28
|
+
return self._refresh()
|
|
29
|
+
|
|
30
|
+
def _refresh(self) -> str:
|
|
31
|
+
"""Request a new OAuth token from the M-Pesa API."""
|
|
32
|
+
url = f"{self.base_url}/oauth/v1/generate?grant_type=client_credentials"
|
|
33
|
+
try:
|
|
34
|
+
resp = httpx.get(url, auth=(self.consumer_key, self.consumer_secret), timeout=10)
|
|
35
|
+
resp.raise_for_status()
|
|
36
|
+
data = resp.json()
|
|
37
|
+
except httpx.HTTPStatusError as e:
|
|
38
|
+
raise AuthError(f"Authentication failed: {e.response.status_code} {e.response.text}")
|
|
39
|
+
except httpx.RequestError as e:
|
|
40
|
+
raise AuthError(f"Could not reach M-Pesa API: {e}")
|
|
41
|
+
|
|
42
|
+
token = data.get("access_token")
|
|
43
|
+
if not token:
|
|
44
|
+
raise AuthError(f"Unexpected auth response: {data}")
|
|
45
|
+
|
|
46
|
+
# Token expires in 3600 seconds (1 hour) per M-Pesa docs
|
|
47
|
+
expires_in = data.get("expires_in", 3600)
|
|
48
|
+
self._token = token
|
|
49
|
+
self._expires_at = datetime.datetime.utcnow() + datetime.timedelta(
|
|
50
|
+
seconds=int(expires_in) - 60 # 60s buffer
|
|
51
|
+
)
|
|
52
|
+
return self._token
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Command-line interface for safcom."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="safcom",
|
|
11
|
+
description="M-Pesa toolkit from the terminal",
|
|
12
|
+
)
|
|
13
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
# ── stkpush ──
|
|
16
|
+
stk = sub.add_parser("stkpush", help="Send an STK push to a phone")
|
|
17
|
+
stk.add_argument("phone", help="Phone number (e.g. 254712345678)")
|
|
18
|
+
stk.add_argument("amount", type=int, help="Amount to charge")
|
|
19
|
+
stk.add_argument("--ref", default="INV-001", help="Account reference")
|
|
20
|
+
stk.add_argument("--desc", help="Transaction description")
|
|
21
|
+
stk.add_argument("--callback", help="Callback URL")
|
|
22
|
+
stk.add_argument("--key", help="Consumer key (or SAFCOM_CONSUMER_KEY)")
|
|
23
|
+
stk.add_argument("--secret", help="Consumer secret (or SAFCOM_CONSUMER_SECRET)")
|
|
24
|
+
stk.add_argument("--passkey", help="Passkey (or SAFCOM_PASSKEY)")
|
|
25
|
+
stk.add_argument("--shortcode", help="Shortcode (or SAFCOM_SHORTCODE)")
|
|
26
|
+
stk.add_argument("--env", choices=["sandbox", "production"], default="sandbox")
|
|
27
|
+
|
|
28
|
+
# ── query ──
|
|
29
|
+
q = sub.add_parser("query", help="Query STK push status")
|
|
30
|
+
q.add_argument("checkout_id", help="CheckoutRequestID from stkpush")
|
|
31
|
+
q.add_argument("--key", help="Consumer key (or SAFCOM_CONSUMER_KEY)")
|
|
32
|
+
q.add_argument("--secret", help="Consumer secret (or SAFCOM_CONSUMER_SECRET)")
|
|
33
|
+
q.add_argument("--passkey", help="Passkey (or SAFCOM_PASSKEY)")
|
|
34
|
+
q.add_argument("--shortcode", help="Shortcode (or SAFCOM_SHORTCODE)")
|
|
35
|
+
q.add_argument("--env", choices=["sandbox", "production"], default="sandbox")
|
|
36
|
+
|
|
37
|
+
args = parser.parse_args()
|
|
38
|
+
|
|
39
|
+
# Credentials from args or env vars
|
|
40
|
+
consumer_key = args.key or os.environ.get("SAFCOM_CONSUMER_KEY")
|
|
41
|
+
consumer_secret = args.secret or os.environ.get("SAFCOM_CONSUMER_SECRET")
|
|
42
|
+
passkey = args.passkey or os.environ.get("SAFCOM_PASSKEY")
|
|
43
|
+
shortcode = args.shortcode or os.environ.get("SAFCOM_SHORTCODE")
|
|
44
|
+
|
|
45
|
+
missing = []
|
|
46
|
+
if not consumer_key:
|
|
47
|
+
missing.append("consumer key")
|
|
48
|
+
if not consumer_secret:
|
|
49
|
+
missing.append("consumer secret")
|
|
50
|
+
if not passkey:
|
|
51
|
+
missing.append("passkey")
|
|
52
|
+
if not shortcode:
|
|
53
|
+
missing.append("shortcode")
|
|
54
|
+
if missing:
|
|
55
|
+
print(f"Error: Missing {', '.join(missing)}. Provide via --flags or environment variables.")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
from safcom import Mpesa
|
|
59
|
+
|
|
60
|
+
mpesa = Mpesa(
|
|
61
|
+
consumer_key=consumer_key,
|
|
62
|
+
consumer_secret=consumer_secret,
|
|
63
|
+
passkey=passkey,
|
|
64
|
+
shortcode=shortcode,
|
|
65
|
+
env=args.env,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if args.command == "stkpush":
|
|
69
|
+
if args.callback:
|
|
70
|
+
mpesa.set_callback_url(args.callback)
|
|
71
|
+
resp = mpesa.stk_push(
|
|
72
|
+
phone=args.phone,
|
|
73
|
+
amount=args.amount,
|
|
74
|
+
account_ref=args.ref,
|
|
75
|
+
transaction_desc=args.desc,
|
|
76
|
+
)
|
|
77
|
+
print(f"✅ STK push sent!")
|
|
78
|
+
print(f" CheckoutRequestID: {resp.checkout_request_id}")
|
|
79
|
+
print(f" Message: {resp.customer_message}")
|
|
80
|
+
|
|
81
|
+
elif args.command == "query":
|
|
82
|
+
status = mpesa.stk_push_query(args.checkout_id)
|
|
83
|
+
print(f"Status: {'✅ Paid' if status.success else '❌ ' + (status.result_description or 'Pending')}")
|
|
84
|
+
if status.receipt:
|
|
85
|
+
print(f" Receipt: {status.receipt}")
|
|
86
|
+
if status.amount:
|
|
87
|
+
print(f" Amount: KES {status.amount}")
|
|
88
|
+
if status.phone:
|
|
89
|
+
print(f" Phone: {status.phone}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class SafcomError(Exception):
|
|
2
|
+
"""Base exception for all safcom errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class AuthError(SafcomError):
|
|
6
|
+
"""Raised when M-Pesa authentication fails."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class APIError(SafcomError):
|
|
10
|
+
"""Raised when the M-Pesa API returns an error response."""
|
|
11
|
+
def __init__(self, message: str, response_code: str | None = None):
|
|
12
|
+
self.response_code = response_code
|
|
13
|
+
super().__init__(f"[{response_code}] {message}" if response_code else message)
|
|
14
|
+
|
|
15
|
+
class ValidationError(SafcomError):
|
|
16
|
+
"""Raised when input validation fails."""
|
|
17
|
+
pass
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class STKPushResponse:
|
|
8
|
+
"""Response from an STK push request."""
|
|
9
|
+
merchant_request_id: str
|
|
10
|
+
checkout_request_id: str
|
|
11
|
+
response_code: str
|
|
12
|
+
response_description: str
|
|
13
|
+
customer_message: str
|
|
14
|
+
raw: dict = field(repr=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class STKPushStatus:
|
|
19
|
+
"""Status of a completed STK push transaction."""
|
|
20
|
+
response_code: str
|
|
21
|
+
response_description: str
|
|
22
|
+
merchant_request_id: str
|
|
23
|
+
checkout_request_id: str
|
|
24
|
+
result_code: str | None = None
|
|
25
|
+
result_description: str | None = None
|
|
26
|
+
amount: float | None = None
|
|
27
|
+
receipt: str | None = None
|
|
28
|
+
transaction_date: datetime | None = None
|
|
29
|
+
phone: str | None = None
|
|
30
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def success(self) -> bool:
|
|
34
|
+
return self.result_code == "0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class B2CResponse:
|
|
39
|
+
"""Response from a B2C (send money) request."""
|
|
40
|
+
conversation_id: str
|
|
41
|
+
originator_conversation_id: str
|
|
42
|
+
response_code: str
|
|
43
|
+
response_description: str
|
|
44
|
+
raw: dict = field(repr=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class AccountBalanceResponse:
|
|
49
|
+
"""Response from an account balance request."""
|
|
50
|
+
conversation_id: str
|
|
51
|
+
originator_conversation_id: str
|
|
52
|
+
response_code: str
|
|
53
|
+
response_description: str
|
|
54
|
+
raw: dict = field(repr=False)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import datetime
|
|
3
|
+
import httpx
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from safcom.auth import Auth, BASE_URLS
|
|
7
|
+
from safcom.exceptions import APIError, ValidationError
|
|
8
|
+
from safcom.models import (
|
|
9
|
+
STKPushResponse,
|
|
10
|
+
STKPushStatus,
|
|
11
|
+
B2CResponse,
|
|
12
|
+
AccountBalanceResponse,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Mpesa:
|
|
17
|
+
"""M-Pesa API client.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from safcom import Mpesa
|
|
21
|
+
|
|
22
|
+
mpesa = Mpesa(
|
|
23
|
+
consumer_key="your_key",
|
|
24
|
+
consumer_secret="your_secret",
|
|
25
|
+
passkey="your_passkey",
|
|
26
|
+
shortcode="174379",
|
|
27
|
+
env="sandbox"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
response = mpesa.stk_push(
|
|
31
|
+
phone="254712345678",
|
|
32
|
+
amount=100,
|
|
33
|
+
account_ref="INV-001",
|
|
34
|
+
)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
consumer_key: str,
|
|
40
|
+
consumer_secret: str,
|
|
41
|
+
passkey: str,
|
|
42
|
+
shortcode: str,
|
|
43
|
+
env: str = "sandbox",
|
|
44
|
+
):
|
|
45
|
+
if env not in ("sandbox", "production"):
|
|
46
|
+
raise ValueError(f"env must be 'sandbox' or 'production', got '{env}'")
|
|
47
|
+
|
|
48
|
+
self.shortcode = shortcode
|
|
49
|
+
self.passkey = passkey
|
|
50
|
+
self.env = env
|
|
51
|
+
self.base_url = BASE_URLS[env]
|
|
52
|
+
self._auth = Auth(consumer_key, consumer_secret, env)
|
|
53
|
+
self._callback_url: str | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def _headers(self) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"Authorization": f"Bearer {self._auth.token}",
|
|
59
|
+
"Content-Type": "application/json",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# ── Configuration ──────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def set_callback_url(self, url: str) -> None:
|
|
65
|
+
"""Set the default callback URL for transaction notifications."""
|
|
66
|
+
self._callback_url = url
|
|
67
|
+
|
|
68
|
+
# ── STK Push (Lipa Na M-Pesa Online) ──────────────────────────
|
|
69
|
+
|
|
70
|
+
def stk_push(
|
|
71
|
+
self,
|
|
72
|
+
phone: str,
|
|
73
|
+
amount: float,
|
|
74
|
+
account_ref: str,
|
|
75
|
+
transaction_desc: str | None = None,
|
|
76
|
+
callback_url: str | None = None,
|
|
77
|
+
) -> STKPushResponse:
|
|
78
|
+
"""Initiate an STK push to a customer's phone.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
phone: Customer phone number (e.g. 254712345678).
|
|
82
|
+
amount: Amount to charge.
|
|
83
|
+
account_ref: Account reference visible to the customer.
|
|
84
|
+
transaction_desc: Optional description (defaults to account_ref).
|
|
85
|
+
callback_url: Override the default callback URL for this request.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
STKPushResponse with checkout details.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValidationError: If input validation fails.
|
|
92
|
+
APIError: If the API returns an error.
|
|
93
|
+
"""
|
|
94
|
+
phone = self._normalise_phone(phone)
|
|
95
|
+
if amount <= 0:
|
|
96
|
+
raise ValidationError("amount must be greater than 0")
|
|
97
|
+
if not account_ref or len(account_ref) > 12:
|
|
98
|
+
raise ValidationError("account_ref must be 1–12 characters")
|
|
99
|
+
|
|
100
|
+
timestamp = self._timestamp()
|
|
101
|
+
password = self._password(timestamp)
|
|
102
|
+
callback = callback_url or self._callback_url
|
|
103
|
+
|
|
104
|
+
payload = {
|
|
105
|
+
"BusinessShortCode": self.shortcode,
|
|
106
|
+
"Password": password,
|
|
107
|
+
"Timestamp": timestamp,
|
|
108
|
+
"TransactionType": "CustomerPayBillOnline",
|
|
109
|
+
"Amount": int(round(amount)),
|
|
110
|
+
"PartyA": phone,
|
|
111
|
+
"PartyB": self.shortcode,
|
|
112
|
+
"PhoneNumber": phone,
|
|
113
|
+
"CallBackURL": callback or "",
|
|
114
|
+
"AccountReference": account_ref,
|
|
115
|
+
"TransactionDesc": transaction_desc or account_ref,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
resp = self._post("/mpesa/stkpush/v1/processrequest", payload)
|
|
119
|
+
return STKPushResponse(
|
|
120
|
+
merchant_request_id=resp.get("MerchantRequestID", ""),
|
|
121
|
+
checkout_request_id=resp.get("CheckoutRequestID", ""),
|
|
122
|
+
response_code=resp.get("ResponseCode", ""),
|
|
123
|
+
response_description=resp.get("ResponseDescription", ""),
|
|
124
|
+
customer_message=resp.get("CustomerMessage", ""),
|
|
125
|
+
raw=resp,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def stk_push_query(self, checkout_request_id: str) -> STKPushStatus:
|
|
129
|
+
"""Query the status of an STK push transaction.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
checkout_request_id: The CheckoutRequestID from stk_push().
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
STKPushStatus with transaction details.
|
|
136
|
+
"""
|
|
137
|
+
timestamp = self._timestamp()
|
|
138
|
+
password = self._password(timestamp)
|
|
139
|
+
|
|
140
|
+
payload = {
|
|
141
|
+
"BusinessShortCode": self.shortcode,
|
|
142
|
+
"Password": password,
|
|
143
|
+
"Timestamp": timestamp,
|
|
144
|
+
"CheckoutRequestID": checkout_request_id,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resp = self._post("/mpesa/stkpushquery/v1/query", payload)
|
|
148
|
+
|
|
149
|
+
date_str = resp.get("TransactionDate", "")
|
|
150
|
+
transaction_date = None
|
|
151
|
+
if date_str:
|
|
152
|
+
try:
|
|
153
|
+
transaction_date = datetime.datetime.strptime(str(date_str), "%Y%m%d%H%M%S")
|
|
154
|
+
except ValueError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
return STKPushStatus(
|
|
158
|
+
response_code=resp.get("ResponseCode", ""),
|
|
159
|
+
response_description=resp.get("ResponseDescription", ""),
|
|
160
|
+
merchant_request_id=resp.get("MerchantRequestID", ""),
|
|
161
|
+
checkout_request_id=resp.get("CheckoutRequestID", ""),
|
|
162
|
+
result_code=resp.get("ResultCode"),
|
|
163
|
+
result_description=resp.get("ResultDesc"),
|
|
164
|
+
amount=float(resp["Amount"]) if resp.get("Amount") else None,
|
|
165
|
+
receipt=resp.get("MpesaReceiptNumber"),
|
|
166
|
+
transaction_date=transaction_date,
|
|
167
|
+
phone=resp.get("PhoneNumber"),
|
|
168
|
+
raw=resp,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# ── B2C (Send money to customer) ──────────────────────────────
|
|
172
|
+
|
|
173
|
+
def b2c(
|
|
174
|
+
self,
|
|
175
|
+
phone: str,
|
|
176
|
+
amount: float,
|
|
177
|
+
remarks: str = "payment",
|
|
178
|
+
initiator_name: str = "testapi",
|
|
179
|
+
security_credential: str = "",
|
|
180
|
+
occasion: str = "",
|
|
181
|
+
) -> B2CResponse:
|
|
182
|
+
"""Send money to a customer (B2C payment).
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
phone: Recipient phone number (e.g. 254712345678).
|
|
186
|
+
amount: Amount to send.
|
|
187
|
+
remarks: Optional remark.
|
|
188
|
+
initiator_name: API initiator name (configured in M-Pesa portal).
|
|
189
|
+
security_credential: Encrypted security credential.
|
|
190
|
+
occasion: Optional occasion field.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
B2CResponse with conversation details.
|
|
194
|
+
"""
|
|
195
|
+
phone = self._normalise_phone(phone)
|
|
196
|
+
if amount <= 0:
|
|
197
|
+
raise ValidationError("amount must be greater than 0")
|
|
198
|
+
|
|
199
|
+
payload = {
|
|
200
|
+
"InitiatorName": initiator_name,
|
|
201
|
+
"SecurityCredential": security_credential,
|
|
202
|
+
"CommandID": "BusinessPayment",
|
|
203
|
+
"Amount": int(round(amount)),
|
|
204
|
+
"PartyA": self.shortcode,
|
|
205
|
+
"PartyB": phone,
|
|
206
|
+
"Remarks": remarks,
|
|
207
|
+
"QueueTimeOutURL": self._callback_url or "",
|
|
208
|
+
"ResultURL": self._callback_url or "",
|
|
209
|
+
"Occasion": occasion or "",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
resp = self._post("/mpesa/b2c/v3/paymentrequest", payload)
|
|
213
|
+
return B2CResponse(
|
|
214
|
+
conversation_id=resp.get("ConversationID", ""),
|
|
215
|
+
originator_conversation_id=resp.get("OriginatorConversationID", ""),
|
|
216
|
+
response_code=resp.get("ResponseCode", ""),
|
|
217
|
+
response_description=resp.get("ResponseDescription", ""),
|
|
218
|
+
raw=resp,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# ── Account Balance ──────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def account_balance(
|
|
224
|
+
self,
|
|
225
|
+
initiator_name: str = "testapi",
|
|
226
|
+
security_credential: str = "",
|
|
227
|
+
) -> AccountBalanceResponse:
|
|
228
|
+
"""Query M-Pesa account balance.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
initiator_name: API initiator name (configured in M-Pesa portal).
|
|
232
|
+
security_credential: Encrypted security credential.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
AccountBalanceResponse with conversation details.
|
|
236
|
+
"""
|
|
237
|
+
payload = {
|
|
238
|
+
"InitiatorName": initiator_name,
|
|
239
|
+
"SecurityCredential": security_credential,
|
|
240
|
+
"CommandID": "AccountBalance",
|
|
241
|
+
"PartyA": self.shortcode,
|
|
242
|
+
"IdentifierType": "4",
|
|
243
|
+
"QueueTimeOutURL": self._callback_url or "",
|
|
244
|
+
"ResultURL": self._callback_url or "",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
resp = self._post("/mpesa/accountbalance/v1/query", payload)
|
|
248
|
+
return AccountBalanceResponse(
|
|
249
|
+
conversation_id=resp.get("ConversationID", ""),
|
|
250
|
+
originator_conversation_id=resp.get("OriginatorConversationID", ""),
|
|
251
|
+
response_code=resp.get("ResponseCode", ""),
|
|
252
|
+
response_description=resp.get("ResponseDescription", ""),
|
|
253
|
+
raw=resp,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# ── Helpers ──────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
259
|
+
url = f"{self.base_url}{path}"
|
|
260
|
+
try:
|
|
261
|
+
resp = httpx.post(url, json=payload, headers=self._headers, timeout=30)
|
|
262
|
+
data = resp.json()
|
|
263
|
+
except httpx.HTTPStatusError as e:
|
|
264
|
+
raise APIError(f"HTTP {e.response.status_code}: {e.response.text}")
|
|
265
|
+
except httpx.RequestError as e:
|
|
266
|
+
raise APIError(f"Request failed: {e}")
|
|
267
|
+
except ValueError:
|
|
268
|
+
raise APIError(f"Non-JSON response: {resp.text}")
|
|
269
|
+
|
|
270
|
+
# M-Pesa returns ResponseCode in various places
|
|
271
|
+
error_code = data.get("ResponseCode")
|
|
272
|
+
if error_code and error_code != "0":
|
|
273
|
+
raise APIError(
|
|
274
|
+
message=data.get("ResponseDescription", "M-Pesa API error"),
|
|
275
|
+
response_code=error_code,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return data
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _normalise_phone(phone: str) -> str:
|
|
282
|
+
"""Normalise a Kenyan phone number to 254 format."""
|
|
283
|
+
cleaned = phone.strip().replace(" ", "").replace("-", "")
|
|
284
|
+
if cleaned.startswith("+"):
|
|
285
|
+
cleaned = cleaned[1:]
|
|
286
|
+
if cleaned.startswith("0"):
|
|
287
|
+
cleaned = "254" + cleaned[1:]
|
|
288
|
+
if not cleaned.startswith("254"):
|
|
289
|
+
raise ValidationError(
|
|
290
|
+
f"Phone number must start with 254 or 0, got '{phone}'"
|
|
291
|
+
)
|
|
292
|
+
if len(cleaned) != 12:
|
|
293
|
+
raise ValidationError(
|
|
294
|
+
f"Phone number must be 12 digits in 254 format, got {len(cleaned)} digits"
|
|
295
|
+
)
|
|
296
|
+
return cleaned
|
|
297
|
+
|
|
298
|
+
def _timestamp(self) -> str:
|
|
299
|
+
return datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
|
300
|
+
|
|
301
|
+
def _password(self, timestamp: str) -> str:
|
|
302
|
+
raw = f"{self.shortcode}{self.passkey}{timestamp}"
|
|
303
|
+
return base64.b64encode(raw.encode()).decode()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: safcom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: M-Pesa API wrapper for Kenyan developers — STK push, callbacks, B2C, and more.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
|
|
9
|
+
<h1 align="center">safcom</h1>
|
|
10
|
+
<p align="center">
|
|
11
|
+
<em>M-Pesa API — the way it should be.</em>
|
|
12
|
+
<br>
|
|
13
|
+
<br>
|
|
14
|
+
<img src="https://img.shields.io/badge/python-≥3.10-10b981" alt="Python 3.10+">
|
|
15
|
+
<img src="https://img.shields.io/badge/license-MIT-10b981" alt="MIT License">
|
|
16
|
+
<img src="https://img.shields.io/badge/version-0.1.0-10b981" alt="Version 0.1.0">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
STK push, payment queries, B2C, account balance — all in a clean Python package that doesn't make you read raw JSON.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install safcom
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from safcom import Mpesa
|
|
31
|
+
|
|
32
|
+
mpesa = Mpesa(
|
|
33
|
+
consumer_key="your_key",
|
|
34
|
+
consumer_secret="your_secret",
|
|
35
|
+
passkey="your_passkey",
|
|
36
|
+
shortcode="174379",
|
|
37
|
+
env="sandbox", # or "production"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# One line. STK push. Done.
|
|
41
|
+
resp = mpesa.stk_push(
|
|
42
|
+
phone="254712345678",
|
|
43
|
+
amount=100,
|
|
44
|
+
account_ref="INV-001",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
print(resp.checkout_request_id) # → "ws_CO_...202507..."
|
|
48
|
+
print(resp.customer_message) # → "Please enter your M-Pesa PIN"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **STK Push** — Initiate Lipa Na M-Pesa Online payments with one call
|
|
54
|
+
- **STK Push Query** — Check if a payment went through
|
|
55
|
+
- **B2C** — Send money to customers (BusinessPayment)
|
|
56
|
+
- **Account Balance** — Query your M-Pesa balance
|
|
57
|
+
- **Callback handling** — Decorator-based handler for M-Pesa notifications
|
|
58
|
+
- **Automatic auth** — Tokens refresh automatically; you never think about it
|
|
59
|
+
- **Typed responses** — Every response is a dataclass, not raw dicts
|
|
60
|
+
- **CLI included** — Test payments from the terminal
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### STK Push
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# Minimal
|
|
68
|
+
resp = mpesa.stk_push(phone="254712345678", amount=500, account_ref="INV-001")
|
|
69
|
+
|
|
70
|
+
# With all options
|
|
71
|
+
resp = mpesa.stk_push(
|
|
72
|
+
phone="254712345678",
|
|
73
|
+
amount=500,
|
|
74
|
+
account_ref="INV-001",
|
|
75
|
+
transaction_desc="Payment for invoice INV-001",
|
|
76
|
+
callback_url="https://your-app.com/mpesa/callback",
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Check Payment Status
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
status = mpesa.stk_push_query("ws_CO_...202507...")
|
|
84
|
+
|
|
85
|
+
if status.success:
|
|
86
|
+
print(f"Paid! Receipt: {status.receipt}")
|
|
87
|
+
else:
|
|
88
|
+
print(f"Not paid: {status.result_description}")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Send Money (B2C)
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
resp = mpesa.b2c(
|
|
95
|
+
phone="254712345678",
|
|
96
|
+
amount=500,
|
|
97
|
+
remarks="refund",
|
|
98
|
+
initiator_name="testapi",
|
|
99
|
+
security_credential="...",
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Account Balance
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
resp = mpesa.account_balance(
|
|
107
|
+
initiator_name="testapi",
|
|
108
|
+
security_credential="...",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### From the CLI
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# Set credentials (or use SAFCOM_* env vars)
|
|
116
|
+
export SAFCOM_CONSUMER_KEY=your_key
|
|
117
|
+
export SAFCOM_CONSUMER_SECRET=your_secret
|
|
118
|
+
export SAFCOM_PASSKEY=your_passkey
|
|
119
|
+
export SAFCOM_SHORTCODE=174379
|
|
120
|
+
|
|
121
|
+
# Send STK push
|
|
122
|
+
safcom stkpush 254712345678 100 --ref INV-001
|
|
123
|
+
|
|
124
|
+
# Query payment status
|
|
125
|
+
safcom query ws_CO_...202507...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Why safcom?
|
|
129
|
+
|
|
130
|
+
The M-Pesa API is powerful but painful:
|
|
131
|
+
|
|
132
|
+
- Authentication is manual and easy to mess up
|
|
133
|
+
- Responses come in inconsistent formats between sandbox and production
|
|
134
|
+
- Error messages are cryptic
|
|
135
|
+
- Every Kenyan dev rewrites the same wrapper
|
|
136
|
+
|
|
137
|
+
safcom fixes all of that. Sensible defaults, typed responses, clear errors. One package, one `pip install`, done.
|
|
138
|
+
|
|
139
|
+
## Roadmap
|
|
140
|
+
|
|
141
|
+
- [x] STK Push
|
|
142
|
+
- [x] STK Push Query
|
|
143
|
+
- [x] B2C (BusinessPayment)
|
|
144
|
+
- [x] Account Balance
|
|
145
|
+
- [x] CLI
|
|
146
|
+
- [ ] C2B Register URL
|
|
147
|
+
- [ ] C2B Simulate
|
|
148
|
+
- [ ] Reversal
|
|
149
|
+
- [ ] Transaction Status
|
|
150
|
+
- [ ] Async support (httpx.AsyncClient)
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
safcom/__init__.py
|
|
4
|
+
safcom/auth.py
|
|
5
|
+
safcom/cli.py
|
|
6
|
+
safcom/exceptions.py
|
|
7
|
+
safcom/models.py
|
|
8
|
+
safcom/mpesa.py
|
|
9
|
+
safcom.egg-info/PKG-INFO
|
|
10
|
+
safcom.egg-info/SOURCES.txt
|
|
11
|
+
safcom.egg-info/dependency_links.txt
|
|
12
|
+
safcom.egg-info/entry_points.txt
|
|
13
|
+
safcom.egg-info/requires.txt
|
|
14
|
+
safcom.egg-info/top_level.txt
|
|
15
|
+
tests/test_mpesa.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
safcom
|
safcom-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tests for safcom M-Pesa client."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from safcom import Mpesa, ValidationError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def mpesa():
|
|
9
|
+
return Mpesa(
|
|
10
|
+
consumer_key="test_key",
|
|
11
|
+
consumer_secret="test_secret",
|
|
12
|
+
passkey="test_passkey",
|
|
13
|
+
shortcode="174379",
|
|
14
|
+
env="sandbox",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestNormalisePhone:
|
|
19
|
+
def test_0712345678(self, mpesa):
|
|
20
|
+
assert mpesa._normalise_phone("0712345678") == "254712345678"
|
|
21
|
+
|
|
22
|
+
def test_254712345678(self, mpesa):
|
|
23
|
+
assert mpesa._normalise_phone("254712345678") == "254712345678"
|
|
24
|
+
|
|
25
|
+
def test_plus254712345678(self, mpesa):
|
|
26
|
+
assert mpesa._normalise_phone("+254712345678") == "254712345678"
|
|
27
|
+
|
|
28
|
+
def test_with_spaces(self, mpesa):
|
|
29
|
+
assert mpesa._normalise_phone("254 712 345 678") == "254712345678"
|
|
30
|
+
|
|
31
|
+
def test_with_hyphens(self, mpesa):
|
|
32
|
+
assert mpesa._normalise_phone("254-712-345-678") == "254712345678"
|
|
33
|
+
|
|
34
|
+
def test_invalid_short(self, mpesa):
|
|
35
|
+
with pytest.raises(ValidationError):
|
|
36
|
+
mpesa._normalise_phone("712345678")
|
|
37
|
+
|
|
38
|
+
def test_invalid_prefix(self, mpesa):
|
|
39
|
+
with pytest.raises(ValidationError):
|
|
40
|
+
mpesa._normalise_phone("11234567890")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestSTKPushValidation:
|
|
44
|
+
def test_negative_amount(self, mpesa):
|
|
45
|
+
with pytest.raises(ValidationError, match="amount must be greater than 0"):
|
|
46
|
+
mpesa.stk_push(phone="254712345678", amount=-100, account_ref="test")
|
|
47
|
+
|
|
48
|
+
def test_zero_amount(self, mpesa):
|
|
49
|
+
with pytest.raises(ValidationError, match="amount must be greater than 0"):
|
|
50
|
+
mpesa.stk_push(phone="254712345678", amount=0, account_ref="test")
|
|
51
|
+
|
|
52
|
+
def test_long_account_ref(self, mpesa):
|
|
53
|
+
with pytest.raises(ValidationError, match="account_ref must be 1–12 characters"):
|
|
54
|
+
mpesa.stk_push(phone="254712345678", amount=100, account_ref="X" * 13)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestPassword:
|
|
58
|
+
def test_password_format(self, mpesa):
|
|
59
|
+
password = mpesa._password("20250720235959")
|
|
60
|
+
assert isinstance(password, str)
|
|
61
|
+
assert len(password) > 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestTimestamp:
|
|
65
|
+
def test_timestamp_format(self, mpesa):
|
|
66
|
+
ts = mpesa._timestamp()
|
|
67
|
+
assert len(ts) == 14
|
|
68
|
+
assert ts.isdigit()
|