zenopay-sdk 0.1.0__py3-none-any.whl → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  """HTTP client for the ZenoPay SDK."""
2
2
 
3
3
  import logging
4
- from typing import Any, Dict, Optional
4
+ from typing import Any, Dict, List, Optional
5
5
 
6
6
  import httpx
7
7
 
@@ -74,11 +74,50 @@ class HTTPClient:
74
74
  self._sync_client.close()
75
75
  self._sync_client = None
76
76
 
77
+ def _clean_params(self, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
78
+ """Clean query parameters by removing None values and converting to strings.
79
+
80
+ Args:
81
+ params: Query parameters to clean.
82
+
83
+ Returns:
84
+ Cleaned parameters dictionary or None.
85
+ """
86
+ if not params:
87
+ return None
88
+
89
+ cleaned_params: Dict[str, str] = {}
90
+ for key, value in params.items():
91
+ if value is not None:
92
+ cleaned_params[str(key)] = str(value)
93
+
94
+ return cleaned_params if cleaned_params else None
95
+
96
+ def _clean_data(self, data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
97
+ """Clean form data by removing None values and converting to strings.
98
+
99
+ Args:
100
+ data: Form data to clean.
101
+
102
+ Returns:
103
+ Cleaned data dictionary or None.
104
+ """
105
+ if not data:
106
+ return None
107
+
108
+ cleaned_data: Dict[str, Any] = {}
109
+ for key, value in data.items():
110
+ if value is not None:
111
+ cleaned_data[key] = str(value)
112
+
113
+ return cleaned_data if cleaned_data else None
114
+
77
115
  async def request(
78
116
  self,
79
117
  method: str,
80
118
  url: str,
81
119
  data: Optional[Dict[str, Any]] = None,
120
+ params: Optional[Dict[str, Any]] = None,
82
121
  headers: Optional[Dict[str, str]] = None,
83
122
  **kwargs: Any,
84
123
  ) -> Dict[str, Any]:
@@ -87,7 +126,8 @@ class HTTPClient:
87
126
  Args:
88
127
  method: HTTP method (GET, POST, PUT, DELETE, etc.).
89
128
  url: Request URL.
90
- data: Form data to send.
129
+ data: Form data to send (for POST/PUT requests).
130
+ params: Query parameters to send (for GET requests).
91
131
  headers: Additional headers.
92
132
  **kwargs: Additional arguments for httpx.
93
133
 
@@ -105,15 +145,10 @@ class HTTPClient:
105
145
  if headers:
106
146
  request_headers.update(headers)
107
147
 
108
- if data:
109
- cleaned_data = {}
110
- for key, value in data.items():
111
- if value is not None:
112
- cleaned_data[key] = str(value)
113
- data = cleaned_data
148
+ cleaned_data = self._clean_data(data)
149
+ cleaned_params = self._clean_params(params)
114
150
 
115
151
  try:
116
- logger.debug(f"Making {method} request to {url}")
117
152
 
118
153
  if self._client is None:
119
154
  raise ZenoPayNetworkError("Async HTTP client is not initialized.", None)
@@ -121,7 +156,8 @@ class HTTPClient:
121
156
  response = await self._client.request(
122
157
  method=method,
123
158
  url=url,
124
- data=data,
159
+ data=cleaned_data,
160
+ params=cleaned_params,
125
161
  headers=request_headers,
126
162
  **kwargs,
127
163
  )
@@ -129,16 +165,13 @@ class HTTPClient:
129
165
  return await self._handle_response(response)
130
166
 
131
167
  except httpx.TimeoutException as e:
132
- logger.error(f"Request timeout: {e}")
133
168
  raise ZenoPayTimeoutError(
134
169
  f"Request timeout after {self.config.timeout} seconds",
135
170
  self.config.timeout,
136
171
  ) from e
137
172
  except httpx.NetworkError as e:
138
- logger.error(f"Network error: {e}")
139
173
  raise ZenoPayNetworkError(f"Network error: {str(e)}", e) from e
140
174
  except Exception as e:
141
- logger.error(f"Unexpected error: {e}")
142
175
  raise ZenoPayNetworkError(f"Unexpected error: {str(e)}", e) from e
143
176
 
144
177
  def request_sync(
@@ -146,6 +179,7 @@ class HTTPClient:
146
179
  method: str,
147
180
  url: str,
148
181
  data: Optional[Dict[str, Any]] = None,
182
+ params: Optional[Dict[str, Any]] = None,
149
183
  headers: Optional[Dict[str, str]] = None,
150
184
  **kwargs: Any,
151
185
  ) -> Dict[str, Any]:
@@ -154,7 +188,8 @@ class HTTPClient:
154
188
  Args:
155
189
  method: HTTP method (GET, POST, PUT, DELETE, etc.).
156
190
  url: Request URL.
157
- data: Form data to send.
191
+ data: Form data to send (for POST/PUT requests).
192
+ params: Query parameters to send (for GET requests).
158
193
  headers: Additional headers.
159
194
  **kwargs: Additional arguments for httpx.
160
195
 
@@ -172,15 +207,10 @@ class HTTPClient:
172
207
  if headers:
173
208
  request_headers.update(headers)
174
209
 
175
- if data:
176
- cleaned_data = {}
177
- for key, value in data.items():
178
- if value is not None:
179
- cleaned_data[key] = str(value)
180
- data = cleaned_data
210
+ cleaned_data = self._clean_data(data)
211
+ cleaned_params = self._clean_params(params)
181
212
 
182
213
  try:
183
- logger.debug(f"Making {method} request to {url}")
184
214
 
185
215
  if self._sync_client is None:
186
216
  raise ZenoPayNetworkError("Sync HTTP client is not initialized.", None)
@@ -188,24 +218,24 @@ class HTTPClient:
188
218
  response = self._sync_client.request(
189
219
  method=method,
190
220
  url=url,
191
- data=data,
221
+ data=cleaned_data,
222
+ params=cleaned_params,
192
223
  headers=request_headers,
193
224
  **kwargs,
194
225
  )
195
226
 
227
+ # Log response details for debugging
228
+
196
229
  return self._handle_response_sync(response)
197
230
 
198
231
  except httpx.TimeoutException as e:
199
- logger.error(f"Request timeout: {e}")
200
232
  raise ZenoPayTimeoutError(
201
233
  f"Request timeout after {self.config.timeout} seconds",
202
234
  self.config.timeout,
203
235
  ) from e
204
236
  except httpx.NetworkError as e:
205
- logger.error(f"Network error: {e}")
206
237
  raise ZenoPayNetworkError(f"Network error: {str(e)}", e) from e
207
238
  except Exception as e:
208
- logger.error(f"Unexpected error: {e}")
209
239
  raise ZenoPayNetworkError(f"Unexpected error: {str(e)}", e) from e
210
240
 
211
241
  async def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
@@ -220,17 +250,14 @@ class HTTPClient:
220
250
  Raises:
221
251
  ZenoPayAPIError: For API errors.
222
252
  """
223
- logger.debug(f"Response status: {response.status_code}")
253
+
254
+ response_data: Dict[str, Any] = {}
224
255
 
225
256
  try:
226
- # Try to parse as JSON first
227
257
  response_data = response.json()
228
258
  except Exception:
229
- # If JSON parsing fails, treat as text response
230
259
  response_text = response.text
231
- logger.debug(f"Non-JSON response: {response_text}")
232
260
 
233
- # For successful responses that aren't JSON, create a basic structure
234
261
  if response.is_success:
235
262
  return {
236
263
  "success": True,
@@ -238,20 +265,18 @@ class HTTPClient:
238
265
  "message": "Request successful",
239
266
  }
240
267
  else:
241
- response_data: Dict[str, Any] = {
268
+ response_data = {
242
269
  "success": False,
243
270
  "error": response_text or f"HTTP {response.status_code}",
244
271
  "message": f"Request failed with status {response.status_code}",
272
+ "status_code": response.status_code,
245
273
  }
246
274
 
247
275
  if response.is_success:
248
276
  return response_data
249
277
 
250
- # Handle API errors
251
- error_message = response_data.get("error", f"HTTP {response.status_code}")
252
- error_code = response_data.get("code")
253
-
254
- logger.error(f"API error: {response.status_code} - {error_message}")
278
+ error_message = self._extract_error_message(response_data, response)
279
+ error_code = response_data.get("code") or response_data.get("error_code")
255
280
 
256
281
  raise create_api_error(
257
282
  status_code=response.status_code,
@@ -272,17 +297,14 @@ class HTTPClient:
272
297
  Raises:
273
298
  ZenoPayAPIError: For API errors.
274
299
  """
275
- logger.debug(f"Response status: {response.status_code}")
300
+
301
+ response_data: Dict[str, Any] = {}
276
302
 
277
303
  try:
278
- # Try to parse as JSON first
279
304
  response_data = response.json()
280
305
  except Exception:
281
- # If JSON parsing fails, treat as text response
282
306
  response_text = response.text
283
- logger.debug(f"Non-JSON response: {response_text}")
284
307
 
285
- # For successful responses that aren't JSON, create a basic structure
286
308
  if response.is_success:
287
309
  return {
288
310
  "success": True,
@@ -290,19 +312,18 @@ class HTTPClient:
290
312
  "message": "Request successful",
291
313
  }
292
314
  else:
293
- response_data: Dict[str, Any] = {
315
+ response_data = {
294
316
  "success": False,
295
317
  "error": response_text or f"HTTP {response.status_code}",
296
318
  "message": f"Request failed with status {response.status_code}",
319
+ "status_code": response.status_code,
297
320
  }
298
321
 
299
322
  if response.is_success:
300
323
  return response_data
301
324
 
302
- error_message = response_data.get("error", f"HTTP {response.status_code}")
303
- error_code = response_data.get("code")
304
-
305
- logger.error(f"API error: {response.status_code} - {error_message}")
325
+ error_message = self._extract_error_message(response_data, response)
326
+ error_code = response_data.get("code") or response_data.get("error_code")
306
327
 
307
328
  raise create_api_error(
308
329
  status_code=response.status_code,
@@ -311,10 +332,103 @@ class HTTPClient:
311
332
  error_code=error_code,
312
333
  )
313
334
 
335
+ def _extract_error_message(self, response_data: Dict[str, Any], response: httpx.Response) -> str:
336
+ """Extract detailed error message from response data.
337
+
338
+ Args:
339
+ response_data: Parsed response data
340
+ response: HTTP response object
341
+
342
+ Returns:
343
+ Detailed error message
344
+ """
345
+
346
+ error_fields = ["error_description", "error", "message", "detail", "details", "error_message", "msg", "description", "reason"]
347
+
348
+ for field in error_fields:
349
+ if field in response_data and response_data[field]:
350
+ error_msg: str = response_data[field]
351
+ if isinstance(error_msg, dict):
352
+ return str(error_msg)
353
+ return str(error_msg)
354
+
355
+ if "errors" in response_data:
356
+ errors: Dict[str, Any] = response_data["errors"]
357
+ error_parts: List[str] = []
358
+ for field, field_errors in errors.items():
359
+ field_name: str = field
360
+ if isinstance(field_errors, list):
361
+ error_strs = [str(e) for e in field_errors] # type: ignore
362
+ error_parts.append(f"{field_name}: {', '.join(error_strs)}")
363
+ else:
364
+ error_parts.append(f"{field_name}: {str(field_errors)}")
365
+ formatted_errors = "; ".join(error_parts)
366
+ return formatted_errors
367
+
368
+ if "success" in response_data and response_data["success"] is False:
369
+ print("DEBUG: Response indicates failure, checking for error details")
370
+
371
+ if "error" in response_data and isinstance(response_data["error"], dict):
372
+ nested_error: Dict[str, Any] = response_data["error"] # type: ignore
373
+ for field in error_fields:
374
+ if field in nested_error:
375
+ return str(nested_error[field])
376
+
377
+ response_text = response.text
378
+ if response_text and len(response_text) < 1000:
379
+ return f"HTTP {response.status_code}: {response_text}"
380
+
381
+ fallback = f"HTTP {response.status_code} - {response.reason_phrase or 'Unknown error'}"
382
+ return fallback
383
+
314
384
  async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
315
- """Make a POST request."""
385
+ """Make an async POST request.
386
+
387
+ Args:
388
+ url: Request URL.
389
+ data: Form data to send.
390
+ **kwargs: Additional arguments for httpx.
391
+
392
+ Returns:
393
+ Parsed response data.
394
+ """
316
395
  return await self.request("POST", url, data=data, **kwargs)
317
396
 
318
397
  def post_sync(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
319
- """Make a sync POST request."""
398
+ """Make a sync POST request.
399
+
400
+ Args:
401
+ url: Request URL.
402
+ data: Form data to send.
403
+ **kwargs: Additional arguments for httpx.
404
+
405
+ Returns:
406
+ Parsed response data.
407
+ """
320
408
  return self.request_sync("POST", url, data=data, **kwargs)
409
+
410
+ async def get(self, url: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
411
+ """Make an async GET request.
412
+
413
+ Args:
414
+ url: Request URL.
415
+ params: Query parameters to send.
416
+ **kwargs: Additional arguments for httpx.
417
+
418
+ Returns:
419
+ Parsed response data.
420
+ """
421
+ return await self.request("GET", url, params=params, **kwargs)
422
+
423
+ def get_sync(self, url: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
424
+ """Make a sync GET request.
425
+
426
+ Args:
427
+ url: Request URL.
428
+ params: Query parameters to send.
429
+ **kwargs: Additional arguments for httpx.
430
+
431
+ Returns:
432
+ Parsed response data.
433
+ """
434
+ return self.request_sync("GET", url, params=params, **kwargs)
@@ -1,10 +1,6 @@
1
1
  """Models package for the ZenoPay SDK."""
2
2
 
3
- from elusion.zenopay.models.common import (
4
- PAYMENT_STATUSES,
5
- APIResponse,
6
- StatusCheckRequest,
7
- )
3
+ from elusion.zenopay.models.common import PAYMENT_STATUSES, APIResponse, StatusCheckRequest, UtilityCodes
8
4
 
9
5
  from elusion.zenopay.models.order import (
10
6
  OrderBase,
@@ -13,7 +9,6 @@ from elusion.zenopay.models.order import (
13
9
  Order,
14
10
  OrderResponse,
15
11
  OrderStatusResponse,
16
- OrderListParams,
17
12
  )
18
13
 
19
14
  from elusion.zenopay.models.webhook import (
@@ -22,12 +17,18 @@ from elusion.zenopay.models.webhook import (
22
17
  WebhookResponse,
23
18
  )
24
19
 
20
+ from elusion.zenopay.models.disbursement import (
21
+ NewDisbursement,
22
+ DisbursementSuccessResponse,
23
+ )
24
+
25
25
  __all__ = [
26
26
  # Constants and utilities
27
27
  "PAYMENT_STATUSES",
28
28
  # Common models
29
29
  "APIResponse",
30
30
  "StatusCheckRequest",
31
+ "UtilityCodes",
31
32
  # Order models
32
33
  "OrderBase",
33
34
  "NewOrder",
@@ -35,9 +36,11 @@ __all__ = [
35
36
  "Order",
36
37
  "OrderResponse",
37
38
  "OrderStatusResponse",
38
- "OrderListParams",
39
39
  # Webhook models
40
40
  "WebhookPayload",
41
41
  "WebhookEvent",
42
42
  "WebhookResponse",
43
+ # Disbursement
44
+ "NewDisbursement",
45
+ "DisbursementSuccessResponse",
43
46
  ]
@@ -1,6 +1,7 @@
1
1
  """Common models and types used across the ZenoPay SDK."""
2
2
 
3
3
  from datetime import datetime
4
+ from enum import Enum
4
5
  from typing import Generic, List, Optional, TypeVar
5
6
 
6
7
  from pydantic import BaseModel, ConfigDict, Field
@@ -12,7 +13,7 @@ class APIResponse(BaseModel, Generic[T]):
12
13
  """Generic API response wrapper."""
13
14
 
14
15
  success: bool = Field(..., description="Whether the request was successful")
15
- data: T = Field(..., description="Response data")
16
+ results: T = Field(..., description="Response data")
16
17
  message: Optional[str] = Field(None, description="Response message")
17
18
  error: Optional[str] = Field(None, description="Error message if applicable")
18
19
 
@@ -43,10 +44,6 @@ class ValidationError(BaseModel):
43
44
  class ZenoPayAPIRequest(BaseModel):
44
45
  """Base model for ZenoPay API requests."""
45
46
 
46
- api_key: Optional[str] = Field(None, description="API key (usually null in requests)")
47
- secret_key: Optional[str] = Field(None, description="Secret key (usually null in requests)")
48
- account_id: str = Field(..., description="ZenoPay account ID")
49
-
50
47
  def to_form_data(self) -> dict[str, str]:
51
48
  """Convert to form data format as expected by ZenoPay API."""
52
49
  data = self.model_dump(exclude_unset=True, by_alias=True)
@@ -62,22 +59,27 @@ class ZenoPayAPIRequest(BaseModel):
62
59
  class StatusCheckRequest(ZenoPayAPIRequest):
63
60
  """Request model for checking order status."""
64
61
 
65
- check_status: int = Field(1, description="Always 1 for status check requests")
66
62
  order_id: str = Field(..., description="Order ID to check")
67
63
 
68
64
  model_config = ConfigDict(
69
65
  json_schema_extra={
70
66
  "example": {
71
- "check_status": 1,
72
67
  "order_id": "66c4bb9c9abb1",
73
- "account_id": "zp87778",
74
- "api_key": "null",
75
- "secret_key": "null",
76
68
  }
77
69
  }
78
70
  )
79
71
 
80
72
 
73
+ class UtilityCodes(str, Enum):
74
+ """Uility codes"""
75
+
76
+ CASHIN = "CASHIN"
77
+
78
+ def __str__(self) -> str:
79
+ """String representation of the utility codes."""
80
+ return self.value
81
+
82
+
81
83
  # Common status constants
82
84
  PAYMENT_STATUSES = {
83
85
  "PENDING": "PENDING",
@@ -0,0 +1,70 @@
1
+ from typing import Any, Dict, List
2
+ from pydantic import BaseModel, ConfigDict, Field
3
+ from elusion.zenopay.models.common import UtilityCodes
4
+
5
+
6
+ class NewDisbursement(BaseModel):
7
+ transid: str = Field(..., description="Unique transaction ID (e.g., UUID) to prevent duplication.")
8
+ utilitycode: str = Field(default=UtilityCodes.CASHIN, description='Set to "CASHIN" for disbursements.')
9
+ utilityref: str = Field(..., description="Mobile number to receive the funds (e.g., 0744963858).")
10
+ amount: int = Field(..., description="Amount to send in Tanzanian Shillings (TZS).")
11
+ pin: int = Field(..., description="4-digit wallet PIN to authorize the transaction.", ge=1000, le=9999)
12
+
13
+ model_config = ConfigDict(
14
+ json_schema_extra={
15
+ "example": {"transid": "7pbBX-lnnASw-erwnn-nrrr09AZ", "utilitycode": "CASHIN", "utilityref": "07XXXXXXXX", "amount": 1000, "pin": 0000}
16
+ }
17
+ )
18
+
19
+
20
+ class ZenoPayResponse(BaseModel):
21
+ reference: str
22
+ transid: str
23
+ resultcode: str
24
+ result: str
25
+ message: str
26
+ data: List[Dict[str, Any]]
27
+
28
+ model_config = ConfigDict(
29
+ json_schema_extra={
30
+ "example": {
31
+ "reference": "0949694808",
32
+ "transid": "7pbBXlnnASwerdsadasdwnnnrrr09AZ",
33
+ "resultcode": "000",
34
+ "result": "SUCCESS",
35
+ "message": "\nMpesa\nTo JOHN DOE(2557XXXXXXXX)\nFrom ZENO\nAmount 1,000.00\n\nReference 0949694808\n26/06/2025 7:21:24 PM",
36
+ "data": [],
37
+ }
38
+ }
39
+ )
40
+
41
+
42
+ class DisbursementSuccessResponse(BaseModel):
43
+ status: str
44
+ message: str
45
+ fee: int
46
+ amount_sent_to_customer: int
47
+ total_deducted: int
48
+ new_balance: str
49
+ zenopay_response: ZenoPayResponse
50
+
51
+ model_config = ConfigDict(
52
+ json_schema_extra={
53
+ "example": {
54
+ "status": "success",
55
+ "message": "Wallet Cashin processed successfully.",
56
+ "fee": 1500,
57
+ "amount_sent_to_customer": 3000,
58
+ "total_deducted": 4500,
59
+ "new_balance": "62984034.00",
60
+ "zenopay_response": {
61
+ "reference": "0949694808",
62
+ "transid": "7pbBXlnnASwerdsadasdwnnnrrr09AZ",
63
+ "resultcode": "000",
64
+ "result": "SUCCESS",
65
+ "message": "\nMpesa\nTo JOHN DOE(2557XXXXXXXX)\nFrom ZENO\nAmount 1,000.00\n\nReference 0949694808\n26/06/2025 7:21:24 PM",
66
+ "data": [],
67
+ },
68
+ }
69
+ }
70
+ )