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/client.py ADDED
@@ -0,0 +1,302 @@
1
+ """
2
+ Synchronous ClickPesa HTTP client.
3
+
4
+ Handles authentication, checksum injection, retries, and error mapping for
5
+ all blocking (sync) API calls. For async usage see :mod:`clickpesa.async_client`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ import threading
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from .exceptions import (
18
+ AuthenticationError,
19
+ ClickPesaError,
20
+ ConflictError,
21
+ ForbiddenError,
22
+ InsufficientFundsError,
23
+ NotFoundError,
24
+ RateLimitError,
25
+ ServerError,
26
+ ValidationError,
27
+ )
28
+ from .security import SecurityManager
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _SANDBOX_URL = "https://api-sandbox.clickpesa.com"
33
+ _PRODUCTION_URL = "https://api.clickpesa.com"
34
+ _AUTH_PATH = "/third-parties/generate-token"
35
+ # Tokens are valid for 1 hour; refresh 5 minutes before expiry.
36
+ _TOKEN_TTL = 3300 # seconds
37
+ _DEFAULT_TIMEOUT = 30.0
38
+ _MAX_RETRIES = 3
39
+ _RETRY_STATUSES = {500, 502, 503, 504}
40
+ _INSUFFICIENT_FUNDS_PHRASE = "Insufficient balance"
41
+
42
+
43
+ class ClickPesaClient:
44
+ """
45
+ Production-grade synchronous HTTP client for the ClickPesa API.
46
+
47
+ Features
48
+ --------
49
+ - Automatic JWT token acquisition and thread-safe caching (55-minute window).
50
+ - Optional HMAC-SHA256 checksum injection on every mutating request.
51
+ - Exponential-backoff retries on transient 5xx errors.
52
+ - Structured exception hierarchy — never raises a bare ``Exception``.
53
+ - Context-manager support (``with`` statement).
54
+
55
+ Parameters
56
+ ----------
57
+ client_id:
58
+ Your ClickPesa application Client ID.
59
+ api_key:
60
+ Your ClickPesa application API key.
61
+ checksum_key:
62
+ Optional checksum secret. When provided every POST/PUT/PATCH request
63
+ automatically receives a ``checksum`` field.
64
+ sandbox:
65
+ Set ``True`` to target the sandbox environment.
66
+ timeout:
67
+ Request timeout in seconds (default: 30).
68
+ max_retries:
69
+ Maximum number of retry attempts on transient server errors (default: 3).
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ client_id: str,
75
+ api_key: str,
76
+ checksum_key: str | None = None,
77
+ sandbox: bool = False,
78
+ timeout: float = _DEFAULT_TIMEOUT,
79
+ max_retries: int = _MAX_RETRIES,
80
+ ) -> None:
81
+ self.client_id = client_id
82
+ self.api_key = api_key
83
+ self.checksum_key = checksum_key
84
+ self.base_url = _SANDBOX_URL if sandbox else _PRODUCTION_URL
85
+ self.timeout = timeout
86
+ self.max_retries = max_retries
87
+
88
+ # Thread-safe token cache
89
+ self._token: str | None = None
90
+ self._token_expires_at: float = 0.0
91
+ self._lock = threading.Lock()
92
+
93
+ self._http = httpx.Client(
94
+ base_url=self.base_url,
95
+ headers={"Content-Type": "application/json"},
96
+ timeout=self.timeout,
97
+ )
98
+
99
+ # ------------------------------------------------------------------
100
+ # Authentication
101
+ # ------------------------------------------------------------------
102
+
103
+ def _authenticate(self) -> str:
104
+ """Return a valid Bearer token, refreshing if necessary."""
105
+ with self._lock:
106
+ now = time.monotonic()
107
+ if self._token and now < self._token_expires_at:
108
+ return self._token
109
+
110
+ logger.debug("Refreshing ClickPesa access token …")
111
+ try:
112
+ response = self._http.post(
113
+ _AUTH_PATH,
114
+ headers={
115
+ "client-id": self.client_id,
116
+ "api-key": self.api_key,
117
+ },
118
+ )
119
+ except httpx.TransportError as exc:
120
+ raise ClickPesaError(f"Network error during authentication: {exc}") from exc
121
+
122
+ if response.status_code == 401:
123
+ raise AuthenticationError(
124
+ "Invalid client-id or api-key", status_code=401
125
+ )
126
+ if response.status_code == 403:
127
+ data = _safe_json(response)
128
+ raise ForbiddenError(
129
+ data.get("message", "Forbidden"),
130
+ status_code=403,
131
+ response=data,
132
+ )
133
+ if not response.is_success:
134
+ raise ClickPesaError(
135
+ f"Authentication failed ({response.status_code}): {response.text}",
136
+ status_code=response.status_code,
137
+ )
138
+
139
+ data = _safe_json(response)
140
+ token = data.get("token")
141
+ if not token:
142
+ raise ClickPesaError("Authentication response missing 'token' field")
143
+
144
+ self._token = token
145
+ self._token_expires_at = now + _TOKEN_TTL
146
+ logger.debug("Access token cached for %d seconds.", _TOKEN_TTL)
147
+ return self._token
148
+
149
+ # ------------------------------------------------------------------
150
+ # Core request dispatcher
151
+ # ------------------------------------------------------------------
152
+
153
+ def request(
154
+ self,
155
+ method: str,
156
+ endpoint: str,
157
+ json: dict[str, Any] | None = None,
158
+ params: dict[str, Any] | None = None,
159
+ ) -> Any:
160
+ """
161
+ Execute an authenticated API request with automatic retry on 5xx errors.
162
+
163
+ Parameters
164
+ ----------
165
+ method: HTTP verb (``"GET"``, ``"POST"``, ``"PATCH"``, etc.).
166
+ endpoint: API path relative to the base URL (leading slash optional).
167
+ json: Request body — will NOT be mutated; a shallow copy is made.
168
+ params: Query-string parameters.
169
+
170
+ Returns
171
+ -------
172
+ Parsed JSON response body.
173
+
174
+ Raises
175
+ ------
176
+ :class:`~clickpesa.exceptions.ClickPesaError` or one of its subclasses.
177
+ """
178
+ token = self._authenticate()
179
+ path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
180
+
181
+ # Build payload copy so the caller's dict is never mutated.
182
+ payload: dict[str, Any] | None = None
183
+ if json is not None:
184
+ payload = dict(json)
185
+ if self.checksum_key and method.upper() in {"POST", "PUT", "PATCH"}:
186
+ if "checksum" not in payload:
187
+ payload["checksum"] = SecurityManager.create_checksum(
188
+ self.checksum_key, payload
189
+ )
190
+
191
+ last_exc: Exception | None = None
192
+ for attempt in range(1, self.max_retries + 1):
193
+ try:
194
+ response = self._http.request(
195
+ method=method,
196
+ url=path,
197
+ json=payload,
198
+ params=params,
199
+ headers={"Authorization": token},
200
+ )
201
+ except httpx.TransportError as exc:
202
+ last_exc = exc
203
+ if attempt < self.max_retries:
204
+ _backoff(attempt)
205
+ continue
206
+ raise ClickPesaError(f"Network error: {exc}") from exc
207
+
208
+ if response.status_code in _RETRY_STATUSES and attempt < self.max_retries:
209
+ logger.warning(
210
+ "Received %d on attempt %d/%d — retrying …",
211
+ response.status_code,
212
+ attempt,
213
+ self.max_retries,
214
+ )
215
+ _backoff(attempt)
216
+ continue
217
+
218
+ return self._handle_response(response)
219
+
220
+ raise ClickPesaError(
221
+ f"Request failed after {self.max_retries} attempts"
222
+ ) from last_exc
223
+
224
+ # ------------------------------------------------------------------
225
+ # Response handling
226
+ # ------------------------------------------------------------------
227
+
228
+ @staticmethod
229
+ def _handle_response(response: httpx.Response) -> Any:
230
+ """Map HTTP status codes to structured exceptions."""
231
+ data = _safe_json(response)
232
+
233
+ if response.is_success:
234
+ return data
235
+
236
+ msg = data.get("message", "Unknown API error") if isinstance(data, dict) else str(data)
237
+ status = response.status_code
238
+
239
+ if status == 400:
240
+ if _INSUFFICIENT_FUNDS_PHRASE in msg:
241
+ raise InsufficientFundsError(msg, status_code=status, response=data)
242
+ raise ValidationError(msg, status_code=status, response=data)
243
+ if status == 401:
244
+ raise AuthenticationError(msg, status_code=status, response=data)
245
+ if status == 403:
246
+ raise ForbiddenError(msg, status_code=status, response=data)
247
+ if status == 404:
248
+ raise NotFoundError(msg, status_code=status, response=data)
249
+ if status == 409:
250
+ raise ConflictError(msg, status_code=status, response=data)
251
+ if status == 429:
252
+ raise RateLimitError(msg, status_code=status, response=data)
253
+ if status >= 500:
254
+ raise ServerError(msg, status_code=status, response=data)
255
+
256
+ raise ClickPesaError(msg, status_code=status, response=data)
257
+
258
+ # ------------------------------------------------------------------
259
+ # Utilities
260
+ # ------------------------------------------------------------------
261
+
262
+ def is_healthy(self) -> bool:
263
+ """
264
+ Perform a lightweight connectivity and credential check.
265
+
266
+ Returns ``True`` if the API is reachable and credentials are valid.
267
+ """
268
+ try:
269
+ self.request("GET", "/third-parties/account/balance")
270
+ return True
271
+ except Exception:
272
+ return False
273
+
274
+ def close(self) -> None:
275
+ """Close the underlying HTTP connection pool."""
276
+ self._http.close()
277
+
278
+ # Context-manager protocol
279
+ def __enter__(self) -> "ClickPesaClient":
280
+ return self
281
+
282
+ def __exit__(self, *_: Any) -> None:
283
+ self.close()
284
+
285
+
286
+ # ------------------------------------------------------------------
287
+ # Private helpers
288
+ # ------------------------------------------------------------------
289
+
290
+ def _safe_json(response: httpx.Response) -> Any:
291
+ """Parse JSON without raising; fall back to a message dict."""
292
+ try:
293
+ return response.json()
294
+ except Exception:
295
+ return {"message": response.text}
296
+
297
+
298
+ def _backoff(attempt: int) -> None:
299
+ """Exponential backoff: 1 s, 2 s, 4 s …"""
300
+ delay = 2 ** (attempt - 1)
301
+ logger.debug("Retrying in %d second(s) …", delay)
302
+ time.sleep(delay)
@@ -0,0 +1,100 @@
1
+ """
2
+ ClickPesa SDK exception hierarchy.
3
+
4
+ All exceptions inherit from ``ClickPesaError`` so callers can catch the
5
+ base class when they don't need to distinguish between error types.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class ClickPesaError(Exception):
14
+ """Base exception for all ClickPesa SDK errors."""
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ status_code: int | None = None,
20
+ response: dict[str, Any] | None = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.status_code = status_code
24
+ self.response = response
25
+
26
+ def __repr__(self) -> str:
27
+ return (
28
+ f"{self.__class__.__name__}("
29
+ f"message={str(self)!r}, "
30
+ f"status_code={self.status_code!r})"
31
+ )
32
+
33
+
34
+ class AuthenticationError(ClickPesaError):
35
+ """
36
+ Raised on HTTP 401.
37
+ Credentials (client-id / api-key) are invalid or the JWT token has expired.
38
+ """
39
+
40
+
41
+ class ForbiddenError(ClickPesaError):
42
+ """
43
+ Raised on HTTP 403.
44
+ The API key is valid but does not have access to the requested feature.
45
+ """
46
+
47
+
48
+ class ValidationError(ClickPesaError):
49
+ """
50
+ Raised on HTTP 400.
51
+ The request payload failed server-side validation.
52
+ """
53
+
54
+
55
+ class InsufficientFundsError(ValidationError):
56
+ """
57
+ Raised on HTTP 400 when the error message indicates insufficient balance.
58
+ Subclass of ValidationError so it is caught by the same broad handler.
59
+ """
60
+
61
+
62
+ class NotFoundError(ClickPesaError):
63
+ """
64
+ Raised on HTTP 404.
65
+ The requested resource (payment, payout, BillPay number, etc.) does not exist.
66
+ """
67
+
68
+
69
+ class ConflictError(ClickPesaError):
70
+ """
71
+ Raised on HTTP 409.
72
+ Typically means the ``orderReference`` or ``billReference`` has already been used.
73
+ """
74
+
75
+
76
+ class RateLimitError(ClickPesaError):
77
+ """
78
+ Raised on HTTP 429.
79
+ A payout request is already in progress; retry after the indicated delay.
80
+ """
81
+
82
+
83
+ class ServerError(ClickPesaError):
84
+ """
85
+ Raised on HTTP 5xx.
86
+ An unexpected error occurred on the ClickPesa server side.
87
+ """
88
+
89
+
90
+ __all__ = [
91
+ "ClickPesaError",
92
+ "AuthenticationError",
93
+ "ForbiddenError",
94
+ "ValidationError",
95
+ "InsufficientFundsError",
96
+ "NotFoundError",
97
+ "ConflictError",
98
+ "RateLimitError",
99
+ "ServerError",
100
+ ]
clickpesa/py.typed ADDED
File without changes
clickpesa/security.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ ClickPesa HMAC-SHA256 security utilities.
3
+
4
+ Used for generating request checksums and verifying incoming webhook signatures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hmac
10
+ import hashlib
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SecurityManager:
17
+ @staticmethod
18
+ def create_checksum(checksum_key: str, payload: dict) -> str:
19
+ """
20
+ Generate a ClickPesa-compatible HMAC-SHA256 checksum for a request payload.
21
+
22
+ Algorithm:
23
+ 1. Sort payload keys alphabetically.
24
+ 2. Concatenate the string representation of all top-level scalar values
25
+ (nested dicts and lists are excluded, matching ClickPesa's specification).
26
+ 3. Return the hex digest of HMAC-SHA256(key, concatenated_string).
27
+
28
+ Args:
29
+ checksum_key: Your application's checksum secret key.
30
+ payload: The request body dict (before the checksum field is added).
31
+
32
+ Returns:
33
+ Hex-encoded HMAC-SHA256 string, or ``""`` if ``checksum_key`` is falsy.
34
+ """
35
+ if not checksum_key:
36
+ return ""
37
+
38
+ sorted_keys = sorted(payload.keys())
39
+ payload_string = "".join(
40
+ str(payload[k])
41
+ for k in sorted_keys
42
+ if not isinstance(payload[k], (dict, list))
43
+ )
44
+
45
+ return hmac.new(
46
+ checksum_key.encode("utf-8"),
47
+ payload_string.encode("utf-8"),
48
+ hashlib.sha256,
49
+ ).hexdigest()
50
+
51
+ @staticmethod
52
+ def verify_webhook(checksum_key: str, payload: dict, signature: str) -> bool:
53
+ """
54
+ Verify an incoming ClickPesa webhook signature.
55
+
56
+ Uses ``hmac.compare_digest`` for constant-time comparison to prevent
57
+ timing-based side-channel attacks.
58
+
59
+ Args:
60
+ checksum_key: Your application's checksum secret key.
61
+ payload: The parsed webhook body dict.
62
+ signature: The ``X-ClickPesa-Signature`` header value.
63
+
64
+ Returns:
65
+ ``True`` if the signature is valid, ``False`` otherwise.
66
+ """
67
+ if not signature:
68
+ return False
69
+
70
+ computed = SecurityManager.create_checksum(checksum_key, payload)
71
+ return hmac.compare_digest(computed, signature)
72
+
73
+
74
+ __all__ = ["SecurityManager"]
@@ -0,0 +1,21 @@
1
+ from .payments import PaymentService, AsyncPaymentService
2
+ from .payouts import PayoutService, AsyncPayoutService
3
+ from .billpay import BillPayService, AsyncBillPayService
4
+ from .account import AccountService, AsyncAccountService
5
+ from .exchange import ExchangeService, AsyncExchangeService
6
+ from .links import LinkService, AsyncLinkService
7
+
8
+ __all__ = [
9
+ "PaymentService",
10
+ "AsyncPaymentService",
11
+ "PayoutService",
12
+ "AsyncPayoutService",
13
+ "BillPayService",
14
+ "AsyncBillPayService",
15
+ "AccountService",
16
+ "AsyncAccountService",
17
+ "ExchangeService",
18
+ "AsyncExchangeService",
19
+ "LinkService",
20
+ "AsyncLinkService",
21
+ ]
@@ -0,0 +1,87 @@
1
+ """
2
+ Account services — balance and statement.
3
+
4
+ Sync: ``AccountService`` — attach to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Async: ``AsyncAccountService`` — attach to :class:`~clickpesa.async_client.AsyncClickPesaClient`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from ..client import ClickPesaClient
14
+ from ..async_client import AsyncClickPesaClient
15
+
16
+ _BASE = "/third-parties/account"
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Sync
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class AccountService:
24
+ """Synchronous account information methods."""
25
+
26
+ def __init__(self, client: "ClickPesaClient") -> None:
27
+ self._c = client
28
+
29
+ def get_balance(self) -> list[dict[str, Any]]:
30
+ """
31
+ Retrieve account balances for all active currencies.
32
+
33
+ Returns:
34
+ List of ``{"currency": "TZS", "balance": 12345.00}`` dicts.
35
+ """
36
+ return self._c.request("GET", f"{_BASE}/balance")
37
+
38
+ def get_statement(
39
+ self,
40
+ currency: str = "TZS",
41
+ start_date: str | None = None,
42
+ end_date: str | None = None,
43
+ ) -> dict[str, Any]:
44
+ """
45
+ Fetch a transaction statement for a given currency.
46
+
47
+ Args:
48
+ currency: ``"TZS"`` (default) or ``"USD"``. Required by the API.
49
+ start_date: Optional filter — ``YYYY-MM-DD`` or ``DD-MM-YYYY``.
50
+ end_date: Optional filter — ``YYYY-MM-DD`` or ``DD-MM-YYYY``.
51
+
52
+ Returns:
53
+ Dict with ``accountDetails`` and ``transactions`` list.
54
+ """
55
+ params: dict[str, Any] = {"currency": currency}
56
+ if start_date is not None:
57
+ params["startDate"] = start_date
58
+ if end_date is not None:
59
+ params["endDate"] = end_date
60
+ return self._c.request("GET", f"{_BASE}/statement", params=params)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Async
65
+ # ---------------------------------------------------------------------------
66
+
67
+ class AsyncAccountService:
68
+ """Asynchronous account information methods (mirrors :class:`AccountService`)."""
69
+
70
+ def __init__(self, client: "AsyncClickPesaClient") -> None:
71
+ self._c = client
72
+
73
+ async def get_balance(self) -> list[dict[str, Any]]:
74
+ return await self._c.request("GET", f"{_BASE}/balance")
75
+
76
+ async def get_statement(
77
+ self,
78
+ currency: str = "TZS",
79
+ start_date: str | None = None,
80
+ end_date: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ params: dict[str, Any] = {"currency": currency}
83
+ if start_date is not None:
84
+ params["startDate"] = start_date
85
+ if end_date is not None:
86
+ params["endDate"] = end_date
87
+ return await self._c.request("GET", f"{_BASE}/statement", params=params)