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/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)
|
clickpesa/exceptions.py
ADDED
|
@@ -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)
|