safcom 0.1.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.
safcom/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from safcom.mpesa import Mpesa
2
+ from safcom.exceptions import SafcomError, AuthError, APIError, ValidationError
3
+
4
+ __all__ = ["Mpesa", "SafcomError", "AuthError", "APIError", "ValidationError"]
safcom/auth.py ADDED
@@ -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
safcom/cli.py ADDED
@@ -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()
safcom/exceptions.py ADDED
@@ -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
safcom/models.py ADDED
@@ -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)
safcom/mpesa.py ADDED
@@ -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,11 @@
1
+ safcom/__init__.py,sha256=BtMGb_FsMrht4WR2w8aU5SM5GW-OBTlvik6uMGp-8Ss,191
2
+ safcom/auth.py,sha256=vXVwLbxN175DCjVjPoZ3aKmP21ToVWq5rdiFeDyfMps,2005
3
+ safcom/cli.py,sha256=klbpjmJo25Y8KCZOCJt3f1x-xkVR6ay-G7wI1g5cpaU,3524
4
+ safcom/exceptions.py,sha256=ZAX_E807F880f5cTcDrqp7h3QAbH6v90HuUaF4OcjTQ,566
5
+ safcom/models.py,sha256=RrtKl0sXqz_tCpD1Wrdo5ybfE5HkYozpy1Wd-hcVmOM,1380
6
+ safcom/mpesa.py,sha256=q8cFBms0DuSL2xcVHxFQZrOXOGsjU0bRUL33prECbR8,10821
7
+ safcom-0.1.0.dist-info/METADATA,sha256=1lchWfZezsr9jWt_ZtBZRfnmexSxrQ63-7PLN-lZwGg,3764
8
+ safcom-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ safcom-0.1.0.dist-info/entry_points.txt,sha256=yMODPL8ZiuzAAOvRfUshgiZsErb8Z2Pd2WfCAC6cZ6U,43
10
+ safcom-0.1.0.dist-info/top_level.txt,sha256=_fG1svGN3DahdFMbP9w1HQyYVlCh8qaZrKwiuOcdhsc,7
11
+ safcom-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ safcom = safcom.cli:main
@@ -0,0 +1 @@
1
+ safcom