clickpesa-python-sdk 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.
- clickpesa/__init__.py +146 -0
- clickpesa/_version.py +1 -0
- clickpesa/async_client.py +307 -0
- clickpesa/client.py +302 -0
- clickpesa/exceptions.py +100 -0
- clickpesa/py.typed +0 -0
- clickpesa/security.py +74 -0
- clickpesa/services/__init__.py +21 -0
- clickpesa/services/account.py +87 -0
- clickpesa/services/billpay.py +340 -0
- clickpesa/services/exchange.py +74 -0
- clickpesa/services/links.py +169 -0
- clickpesa/services/payments.py +248 -0
- clickpesa/services/payouts.py +299 -0
- clickpesa/webhooks.py +42 -0
- clickpesa_python_sdk-0.1.0.dist-info/METADATA +512 -0
- clickpesa_python_sdk-0.1.0.dist-info/RECORD +20 -0
- clickpesa_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- clickpesa_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- clickpesa_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
clickpesa/__init__.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ClickPesa Python SDK
|
|
3
|
+
====================
|
|
4
|
+
|
|
5
|
+
Sync usage::
|
|
6
|
+
|
|
7
|
+
from clickpesa import ClickPesa
|
|
8
|
+
|
|
9
|
+
with ClickPesa(client_id="…", api_key="…", sandbox=True) as cp:
|
|
10
|
+
balance = cp.account.get_balance()
|
|
11
|
+
cp.payments.initiate_ussd_push(amount="5000", phone="255712345678", order_id="ORD001")
|
|
12
|
+
|
|
13
|
+
Async usage::
|
|
14
|
+
|
|
15
|
+
from clickpesa import AsyncClickPesa
|
|
16
|
+
|
|
17
|
+
async with AsyncClickPesa(client_id="…", api_key="…", sandbox=True) as cp:
|
|
18
|
+
balance = await cp.account.get_balance()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .client import ClickPesaClient
|
|
25
|
+
from .async_client import AsyncClickPesaClient
|
|
26
|
+
from .security import SecurityManager
|
|
27
|
+
from .webhooks import WebhookValidator
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
ClickPesaError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
ForbiddenError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
InsufficientFundsError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
ConflictError,
|
|
36
|
+
RateLimitError,
|
|
37
|
+
ServerError,
|
|
38
|
+
)
|
|
39
|
+
from .services.payments import PaymentService, AsyncPaymentService
|
|
40
|
+
from .services.payouts import PayoutService, AsyncPayoutService
|
|
41
|
+
from .services.billpay import BillPayService, AsyncBillPayService
|
|
42
|
+
from .services.account import AccountService, AsyncAccountService
|
|
43
|
+
from .services.exchange import ExchangeService, AsyncExchangeService
|
|
44
|
+
from .services.links import LinkService, AsyncLinkService
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClickPesa(ClickPesaClient):
|
|
48
|
+
"""
|
|
49
|
+
Synchronous ClickPesa client with all service namespaces attached.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
payments: :class:`~clickpesa.services.payments.PaymentService`
|
|
53
|
+
payouts: :class:`~clickpesa.services.payouts.PayoutService`
|
|
54
|
+
billpay: :class:`~clickpesa.services.billpay.BillPayService`
|
|
55
|
+
account: :class:`~clickpesa.services.account.AccountService`
|
|
56
|
+
exchange: :class:`~clickpesa.services.exchange.ExchangeService`
|
|
57
|
+
links: :class:`~clickpesa.services.links.LinkService`
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
client_id: str,
|
|
63
|
+
api_key: str,
|
|
64
|
+
checksum_key: str | None = None,
|
|
65
|
+
sandbox: bool = False,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
max_retries: int = 3,
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__(client_id, api_key, checksum_key, sandbox, timeout, max_retries)
|
|
70
|
+
self.payments = PaymentService(self)
|
|
71
|
+
self.payouts = PayoutService(self)
|
|
72
|
+
self.billpay = BillPayService(self)
|
|
73
|
+
self.account = AccountService(self)
|
|
74
|
+
self.exchange = ExchangeService(self)
|
|
75
|
+
self.links = LinkService(self)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AsyncClickPesa(AsyncClickPesaClient):
|
|
79
|
+
"""
|
|
80
|
+
Asynchronous ClickPesa client with all service namespaces attached.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
payments: :class:`~clickpesa.services.payments.AsyncPaymentService`
|
|
84
|
+
payouts: :class:`~clickpesa.services.payouts.AsyncPayoutService`
|
|
85
|
+
billpay: :class:`~clickpesa.services.billpay.AsyncBillPayService`
|
|
86
|
+
account: :class:`~clickpesa.services.account.AsyncAccountService`
|
|
87
|
+
exchange: :class:`~clickpesa.services.exchange.AsyncExchangeService`
|
|
88
|
+
links: :class:`~clickpesa.services.links.AsyncLinkService`
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
client_id: str,
|
|
94
|
+
api_key: str,
|
|
95
|
+
checksum_key: str | None = None,
|
|
96
|
+
sandbox: bool = False,
|
|
97
|
+
timeout: float = 30.0,
|
|
98
|
+
max_retries: int = 3,
|
|
99
|
+
) -> None:
|
|
100
|
+
super().__init__(client_id, api_key, checksum_key, sandbox, timeout, max_retries)
|
|
101
|
+
self.payments = AsyncPaymentService(self)
|
|
102
|
+
self.payouts = AsyncPayoutService(self)
|
|
103
|
+
self.billpay = AsyncBillPayService(self)
|
|
104
|
+
self.account = AsyncAccountService(self)
|
|
105
|
+
self.exchange = AsyncExchangeService(self)
|
|
106
|
+
self.links = AsyncLinkService(self)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__version__ = __version__
|
|
110
|
+
|
|
111
|
+
__all__ = [
|
|
112
|
+
# Main clients
|
|
113
|
+
"ClickPesa",
|
|
114
|
+
"AsyncClickPesa",
|
|
115
|
+
# Base clients (for advanced subclassing)
|
|
116
|
+
"ClickPesaClient",
|
|
117
|
+
"AsyncClickPesaClient",
|
|
118
|
+
# Utilities
|
|
119
|
+
"SecurityManager",
|
|
120
|
+
"WebhookValidator",
|
|
121
|
+
# Exceptions
|
|
122
|
+
"ClickPesaError",
|
|
123
|
+
"AuthenticationError",
|
|
124
|
+
"ForbiddenError",
|
|
125
|
+
"ValidationError",
|
|
126
|
+
"InsufficientFundsError",
|
|
127
|
+
"NotFoundError",
|
|
128
|
+
"ConflictError",
|
|
129
|
+
"RateLimitError",
|
|
130
|
+
"ServerError",
|
|
131
|
+
# Services (for type hints)
|
|
132
|
+
"PaymentService",
|
|
133
|
+
"AsyncPaymentService",
|
|
134
|
+
"PayoutService",
|
|
135
|
+
"AsyncPayoutService",
|
|
136
|
+
"BillPayService",
|
|
137
|
+
"AsyncBillPayService",
|
|
138
|
+
"AccountService",
|
|
139
|
+
"AsyncAccountService",
|
|
140
|
+
"ExchangeService",
|
|
141
|
+
"AsyncExchangeService",
|
|
142
|
+
"LinkService",
|
|
143
|
+
"AsyncLinkService",
|
|
144
|
+
# Version
|
|
145
|
+
"__version__",
|
|
146
|
+
]
|
clickpesa/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asynchronous ClickPesa HTTP client.
|
|
3
|
+
|
|
4
|
+
Drop-in async counterpart to :class:`~clickpesa.client.ClickPesaClient`.
|
|
5
|
+
Requires Python 3.10+ and ``httpx`` with async support (included by default).
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
async with AsyncClickPesaClient(client_id="…", api_key="…") as client:
|
|
10
|
+
balance = await client.account.get_balance()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
ClickPesaError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
ForbiddenError,
|
|
27
|
+
InsufficientFundsError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
ServerError,
|
|
31
|
+
ValidationError,
|
|
32
|
+
)
|
|
33
|
+
from .security import SecurityManager
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_SANDBOX_URL = "https://api-sandbox.clickpesa.com"
|
|
38
|
+
_PRODUCTION_URL = "https://api.clickpesa.com"
|
|
39
|
+
_AUTH_PATH = "/third-parties/generate-token"
|
|
40
|
+
_TOKEN_TTL = 3300
|
|
41
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
42
|
+
_MAX_RETRIES = 3
|
|
43
|
+
_RETRY_STATUSES = {500, 502, 503, 504}
|
|
44
|
+
_INSUFFICIENT_FUNDS_PHRASE = "Insufficient balance"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncClickPesaClient:
|
|
48
|
+
"""
|
|
49
|
+
Production-grade asynchronous HTTP client for the ClickPesa API.
|
|
50
|
+
|
|
51
|
+
Features
|
|
52
|
+
--------
|
|
53
|
+
- Automatic JWT token acquisition and async-safe caching via ``asyncio.Lock``.
|
|
54
|
+
- Optional HMAC-SHA256 checksum injection on every mutating request.
|
|
55
|
+
- Exponential-backoff retries on transient 5xx errors.
|
|
56
|
+
- Structured exception hierarchy — never raises a bare ``Exception``.
|
|
57
|
+
- Async context-manager support (``async with`` statement).
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
client_id:
|
|
62
|
+
Your ClickPesa application Client ID.
|
|
63
|
+
api_key:
|
|
64
|
+
Your ClickPesa application API key.
|
|
65
|
+
checksum_key:
|
|
66
|
+
Optional checksum secret. When provided every POST/PUT/PATCH request
|
|
67
|
+
automatically receives a ``checksum`` field.
|
|
68
|
+
sandbox:
|
|
69
|
+
Set ``True`` to target the sandbox environment.
|
|
70
|
+
timeout:
|
|
71
|
+
Request timeout in seconds (default: 30).
|
|
72
|
+
max_retries:
|
|
73
|
+
Maximum number of retry attempts on transient server errors (default: 3).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
client_id: str,
|
|
79
|
+
api_key: str,
|
|
80
|
+
checksum_key: str | None = None,
|
|
81
|
+
sandbox: bool = False,
|
|
82
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
83
|
+
max_retries: int = _MAX_RETRIES,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.client_id = client_id
|
|
86
|
+
self.api_key = api_key
|
|
87
|
+
self.checksum_key = checksum_key
|
|
88
|
+
self.base_url = _SANDBOX_URL if sandbox else _PRODUCTION_URL
|
|
89
|
+
self.timeout = timeout
|
|
90
|
+
self.max_retries = max_retries
|
|
91
|
+
|
|
92
|
+
# Async-safe token cache
|
|
93
|
+
self._token: str | None = None
|
|
94
|
+
self._token_expires_at: float = 0.0
|
|
95
|
+
self._lock: asyncio.Lock | None = None # created lazily inside the event loop
|
|
96
|
+
|
|
97
|
+
self._http = httpx.AsyncClient(
|
|
98
|
+
base_url=self.base_url,
|
|
99
|
+
headers={"Content-Type": "application/json"},
|
|
100
|
+
timeout=self.timeout,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _get_lock(self) -> asyncio.Lock:
|
|
104
|
+
"""Lazily create the asyncio.Lock inside the running event loop."""
|
|
105
|
+
if self._lock is None:
|
|
106
|
+
self._lock = asyncio.Lock()
|
|
107
|
+
return self._lock
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Authentication
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async def _authenticate(self) -> str:
|
|
114
|
+
"""Return a valid Bearer token, refreshing if necessary."""
|
|
115
|
+
async with self._get_lock():
|
|
116
|
+
now = time.monotonic()
|
|
117
|
+
if self._token and now < self._token_expires_at:
|
|
118
|
+
return self._token
|
|
119
|
+
|
|
120
|
+
logger.debug("Refreshing ClickPesa access token …")
|
|
121
|
+
try:
|
|
122
|
+
response = await self._http.post(
|
|
123
|
+
_AUTH_PATH,
|
|
124
|
+
headers={
|
|
125
|
+
"client-id": self.client_id,
|
|
126
|
+
"api-key": self.api_key,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
except httpx.TransportError as exc:
|
|
130
|
+
raise ClickPesaError(
|
|
131
|
+
f"Network error during authentication: {exc}"
|
|
132
|
+
) from exc
|
|
133
|
+
|
|
134
|
+
if response.status_code == 401:
|
|
135
|
+
raise AuthenticationError(
|
|
136
|
+
"Invalid client-id or api-key", status_code=401
|
|
137
|
+
)
|
|
138
|
+
if response.status_code == 403:
|
|
139
|
+
data = _safe_json(response)
|
|
140
|
+
raise ForbiddenError(
|
|
141
|
+
data.get("message", "Forbidden"),
|
|
142
|
+
status_code=403,
|
|
143
|
+
response=data,
|
|
144
|
+
)
|
|
145
|
+
if not response.is_success:
|
|
146
|
+
raise ClickPesaError(
|
|
147
|
+
f"Authentication failed ({response.status_code}): {response.text}",
|
|
148
|
+
status_code=response.status_code,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
data = _safe_json(response)
|
|
152
|
+
token = data.get("token")
|
|
153
|
+
if not token:
|
|
154
|
+
raise ClickPesaError("Authentication response missing 'token' field")
|
|
155
|
+
|
|
156
|
+
self._token = token
|
|
157
|
+
self._token_expires_at = now + _TOKEN_TTL
|
|
158
|
+
logger.debug("Access token cached for %d seconds.", _TOKEN_TTL)
|
|
159
|
+
return self._token
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Core request dispatcher
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async def request(
|
|
166
|
+
self,
|
|
167
|
+
method: str,
|
|
168
|
+
endpoint: str,
|
|
169
|
+
json: dict[str, Any] | None = None,
|
|
170
|
+
params: dict[str, Any] | None = None,
|
|
171
|
+
) -> Any:
|
|
172
|
+
"""
|
|
173
|
+
Execute an authenticated API request with automatic retry on 5xx errors.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
method: HTTP verb (``"GET"``, ``"POST"``, ``"PATCH"``, etc.).
|
|
178
|
+
endpoint: API path relative to the base URL (leading slash optional).
|
|
179
|
+
json: Request body — will NOT be mutated; a shallow copy is made.
|
|
180
|
+
params: Query-string parameters.
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
Parsed JSON response body.
|
|
185
|
+
|
|
186
|
+
Raises
|
|
187
|
+
------
|
|
188
|
+
:class:`~clickpesa.exceptions.ClickPesaError` or one of its subclasses.
|
|
189
|
+
"""
|
|
190
|
+
token = await self._authenticate()
|
|
191
|
+
path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
|
192
|
+
|
|
193
|
+
payload: dict[str, Any] | None = None
|
|
194
|
+
if json is not None:
|
|
195
|
+
payload = dict(json)
|
|
196
|
+
if self.checksum_key and method.upper() in {"POST", "PUT", "PATCH"}:
|
|
197
|
+
if "checksum" not in payload:
|
|
198
|
+
payload["checksum"] = SecurityManager.create_checksum(
|
|
199
|
+
self.checksum_key, payload
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
last_exc: Exception | None = None
|
|
203
|
+
for attempt in range(1, self.max_retries + 1):
|
|
204
|
+
try:
|
|
205
|
+
response = await self._http.request(
|
|
206
|
+
method=method,
|
|
207
|
+
url=path,
|
|
208
|
+
json=payload,
|
|
209
|
+
params=params,
|
|
210
|
+
headers={"Authorization": token},
|
|
211
|
+
)
|
|
212
|
+
except httpx.TransportError as exc:
|
|
213
|
+
last_exc = exc
|
|
214
|
+
if attempt < self.max_retries:
|
|
215
|
+
await _async_backoff(attempt)
|
|
216
|
+
continue
|
|
217
|
+
raise ClickPesaError(f"Network error: {exc}") from exc
|
|
218
|
+
|
|
219
|
+
if response.status_code in _RETRY_STATUSES and attempt < self.max_retries:
|
|
220
|
+
logger.warning(
|
|
221
|
+
"Received %d on attempt %d/%d — retrying …",
|
|
222
|
+
response.status_code,
|
|
223
|
+
attempt,
|
|
224
|
+
self.max_retries,
|
|
225
|
+
)
|
|
226
|
+
await _async_backoff(attempt)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
return _handle_response(response)
|
|
230
|
+
|
|
231
|
+
raise ClickPesaError(
|
|
232
|
+
f"Request failed after {self.max_retries} attempts"
|
|
233
|
+
) from last_exc
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
# Utilities
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
async def is_healthy(self) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Perform a lightweight connectivity and credential check.
|
|
242
|
+
|
|
243
|
+
Returns ``True`` if the API is reachable and credentials are valid.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
await self.request("GET", "/third-parties/account/balance")
|
|
247
|
+
return True
|
|
248
|
+
except Exception:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
async def close(self) -> None:
|
|
252
|
+
"""Close the underlying async HTTP connection pool."""
|
|
253
|
+
await self._http.aclose()
|
|
254
|
+
|
|
255
|
+
# Async context-manager protocol
|
|
256
|
+
async def __aenter__(self) -> "AsyncClickPesaClient":
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
260
|
+
await self.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
# Private helpers (module-level to share with sync client)
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def _safe_json(response: httpx.Response) -> Any:
|
|
268
|
+
try:
|
|
269
|
+
return response.json()
|
|
270
|
+
except Exception:
|
|
271
|
+
return {"message": response.text}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _handle_response(response: httpx.Response) -> Any:
|
|
275
|
+
"""Map HTTP status codes to structured exceptions."""
|
|
276
|
+
data = _safe_json(response)
|
|
277
|
+
|
|
278
|
+
if response.is_success:
|
|
279
|
+
return data
|
|
280
|
+
|
|
281
|
+
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else str(data)
|
|
282
|
+
status = response.status_code
|
|
283
|
+
|
|
284
|
+
if status == 400:
|
|
285
|
+
if _INSUFFICIENT_FUNDS_PHRASE in msg:
|
|
286
|
+
raise InsufficientFundsError(msg, status_code=status, response=data)
|
|
287
|
+
raise ValidationError(msg, status_code=status, response=data)
|
|
288
|
+
if status == 401:
|
|
289
|
+
raise AuthenticationError(msg, status_code=status, response=data)
|
|
290
|
+
if status == 403:
|
|
291
|
+
raise ForbiddenError(msg, status_code=status, response=data)
|
|
292
|
+
if status == 404:
|
|
293
|
+
raise NotFoundError(msg, status_code=status, response=data)
|
|
294
|
+
if status == 409:
|
|
295
|
+
raise ConflictError(msg, status_code=status, response=data)
|
|
296
|
+
if status == 429:
|
|
297
|
+
raise RateLimitError(msg, status_code=status, response=data)
|
|
298
|
+
if status >= 500:
|
|
299
|
+
raise ServerError(msg, status_code=status, response=data)
|
|
300
|
+
|
|
301
|
+
raise ClickPesaError(msg, status_code=status, response=data)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _async_backoff(attempt: int) -> None:
|
|
305
|
+
delay = 2 ** (attempt - 1)
|
|
306
|
+
logger.debug("Retrying in %d second(s) …", delay)
|
|
307
|
+
await asyncio.sleep(delay)
|