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,19 +1,18 @@
1
1
  """Order-related models for the ZenoPay SDK."""
2
2
 
3
- from datetime import datetime
4
- from typing import Any, Dict, Optional, Union
5
-
3
+ from typing import Any, Dict, List, Optional
6
4
  from pydantic import BaseModel, ConfigDict, Field, field_validator
7
5
 
8
6
 
9
7
  class OrderBase(BaseModel):
10
8
  """Base order model with common fields."""
11
9
 
10
+ order_id: str = Field(..., description="Unique order id")
12
11
  buyer_email: str = Field(..., description="Buyer's email address")
13
12
  buyer_name: str = Field(..., description="Buyer's full name")
14
13
  buyer_phone: str = Field(..., description="Buyer's phone number")
15
14
  amount: int = Field(..., gt=0, description="Order amount in smallest currency unit")
16
- webhook_url: Optional[str] = Field(None, description="URL to receive webhook notifications")
15
+ webhook_url: Optional[str] = Field(default=None, description="URL to receive webhook notifications")
17
16
 
18
17
  @field_validator("buyer_email")
19
18
  def validate_email(cls, v: str) -> str:
@@ -25,7 +24,6 @@ class OrderBase(BaseModel):
25
24
  @field_validator("buyer_phone")
26
25
  def validate_phone(cls, v: str) -> str:
27
26
  """Validate phone number format."""
28
- # Remove any non-digit characters except +
29
27
  cleaned = "".join(c for c in v if c.isdigit() or c == "+")
30
28
  if len(cleaned) < 10:
31
29
  raise ValueError("Phone number must be at least 10 digits")
@@ -44,7 +42,7 @@ class OrderBase(BaseModel):
44
42
  class NewOrder(OrderBase):
45
43
  """Model for creating a new order."""
46
44
 
47
- metadata: Optional[Dict[str, Any]] = Field(None, description="Additional order metadata")
45
+ metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional order metadata")
48
46
 
49
47
  @field_validator("metadata")
50
48
  def validate_metadata(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
@@ -95,11 +93,6 @@ class Order(BaseModel):
95
93
  webhook_url: Optional[str] = Field(None, description="Webhook URL")
96
94
  metadata: Optional[Dict[str, Any]] = Field(None, description="Order metadata")
97
95
 
98
- # Timestamps
99
- created_at: Optional[datetime] = Field(None, description="Order creation time")
100
- updated_at: Optional[datetime] = Field(None, description="Last update time")
101
- completed_at: Optional[datetime] = Field(None, description="Payment completion time")
102
-
103
96
  @field_validator("payment_status")
104
97
  def validate_payment_status(cls, v: str) -> str:
105
98
  """Validate payment status."""
@@ -155,9 +148,6 @@ class Order(BaseModel):
155
148
  "size": "L",
156
149
  "custom_notes": "Please gift-wrap this item.",
157
150
  },
158
- "created_at": "2025-06-15T10:00:00Z",
159
- "updated_at": "2025-06-15T10:05:00Z",
160
- "completed_at": "2025-06-15T10:05:00Z",
161
151
  }
162
152
  }
163
153
  )
@@ -166,86 +156,37 @@ class Order(BaseModel):
166
156
  class OrderResponse(BaseModel):
167
157
  """Response model for order operations."""
168
158
 
169
- status: str = Field("success", description="Status of the operation (success or error)")
170
- message: str = Field(
171
- "Order created successfully",
172
- description="Message describing the operation result",
173
- )
174
- order_id: str = Field(..., description="ID of the created or updated order")
159
+ status: str
160
+ message: str
161
+ resultcode: int
162
+ order_id: str
175
163
 
176
164
  model_config = ConfigDict(
177
165
  json_schema_extra={
178
166
  "example": {
167
+ "order_id": "2cd93967-0d48-46c7-a9ab-f0a0a21a11cd",
168
+ "resultcode": "000",
179
169
  "status": "success",
180
170
  "message": "Order created successfully",
181
- "order_id": "66c4bb9c9abb1",
182
171
  }
183
172
  }
184
173
  )
185
174
 
186
175
 
187
- class OrderStatusResponse(BaseModel):
188
- status: str = Field("success", description="Status of the operation (success or error)")
189
- order_id: str = Field(..., description="ID of the order")
190
- message: str = Field(
191
- "Order status retrieved successfully",
192
- description="Message describing the operation result",
193
- )
194
- payment_status: str = Field("PENDING", description="Current payment status of the order")
195
-
196
-
197
- class OrderListParams(BaseModel):
198
- """Parameters for listing orders."""
199
-
200
- status: Optional[str] = Field(None, description="Filter by payment status")
201
- buyer_email: Optional[str] = Field(None, description="Filter by buyer email")
202
- date_from: Optional[Union[datetime, str]] = Field(None, description="Filter orders from this date")
203
- date_to: Optional[Union[datetime, str]] = Field(None, description="Filter orders to this date")
204
- limit: Optional[int] = Field(50, ge=1, le=100, description="Number of orders to return")
205
-
206
- @field_validator("status")
207
- def validate_status(cls, v: Optional[str]) -> Optional[str]:
208
- """Validate status filter."""
209
- if v is not None:
210
- valid_statuses = ["PENDING", "COMPLETED", "FAILED", "CANCELLED"]
211
- if v not in valid_statuses:
212
- raise ValueError(f"Invalid status. Must be one of: {', '.join(valid_statuses)}")
213
- return v
214
-
215
- @field_validator("date_from", mode="before")
216
- def parse_date_from(cls, v: Union[str, datetime, None]) -> Union[datetime, None]:
217
- """Parse string dates to datetime objects for date_from."""
218
- if isinstance(v, str):
219
- try:
220
- return datetime.fromisoformat(v.replace("Z", "+00:00"))
221
- except ValueError:
222
- try:
223
- return datetime.strptime(v, "%Y-%m-%d")
224
- except ValueError:
225
- raise ValueError(f"Invalid date format: {v}")
226
- return v
176
+ class OrderData(BaseModel):
177
+ order_id: str
178
+ creation_date: str
179
+ amount: str
180
+ payment_status: str
181
+ transid: Optional[str] = None
182
+ channel: Optional[str] = None
183
+ reference: Optional[str] = None
184
+ msisdn: Optional[str] = None
227
185
 
228
- @field_validator("date_to", mode="before")
229
- def parse_date_to(cls, v: Union[str, datetime, None]) -> Union[datetime, None]:
230
- """Parse string dates to datetime objects for date_to."""
231
- if isinstance(v, str):
232
- try:
233
- return datetime.fromisoformat(v.replace("Z", "+00:00"))
234
- except ValueError:
235
- try:
236
- return datetime.strptime(v, "%Y-%m-%d")
237
- except ValueError:
238
- raise ValueError(f"Invalid date format: {v}")
239
- return v
240
186
 
241
- model_config = ConfigDict(
242
- json_schema_extra={
243
- "example": {
244
- "status": "COMPLETED",
245
- "buyer_email": "jackson@gmail.com",
246
- "date_from": "2025-06-01",
247
- "date_to": "2025-06-15",
248
- "limit": 50,
249
- }
250
- }
251
- )
187
+ class OrderStatusResponse(BaseModel):
188
+ reference: str
189
+ resultcode: str
190
+ result: str
191
+ message: str
192
+ data: List[OrderData]
@@ -1,7 +1,5 @@
1
1
  from elusion.zenopay.services.orders import OrderService
2
2
  from elusion.zenopay.services.webhooks import WebhookService
3
+ from elusion.zenopay.services.disbursements import DisbursementService
3
4
 
4
- __all__ = [
5
- "OrderService",
6
- "WebhookService",
7
- ]
5
+ __all__ = ["OrderService", "WebhookService", "DisbursementService"]
@@ -1,6 +1,6 @@
1
1
  """Base service class for all ZenoPay SDK services."""
2
2
 
3
- from typing import Any, Dict, Type, TypeVar, Union
3
+ from typing import Any, Dict, Type, TypeVar, Union, Optional
4
4
 
5
5
  from pydantic import BaseModel, ValidationError
6
6
 
@@ -53,16 +53,29 @@ class BaseService:
53
53
  else:
54
54
  request_data = data.copy()
55
55
 
56
- request_data.update(
57
- {
58
- "account_id": self.config.account_id,
59
- "api_key": self.config.api_key or "null",
60
- "secret_key": self.config.secret_key or "null",
61
- }
62
- )
63
-
64
56
  return request_data
65
57
 
58
+ def _prepare_query_params(self, params: Optional[Union[BaseModel, Dict[str, Any]]] = None) -> Dict[str, Any]:
59
+ """Prepare and validate query parameters for GET requests.
60
+
61
+ Args:
62
+ params: Parameters to prepare for the request.
63
+
64
+ Returns:
65
+ Prepared query parameters dictionary.
66
+
67
+ Raises:
68
+ ZenoPayValidationError: If validation fails.
69
+ """
70
+ if params is None:
71
+ query_params = {}
72
+ elif isinstance(params, BaseModel):
73
+ query_params = params.model_dump(exclude_unset=True, by_alias=True)
74
+ else:
75
+ query_params = params.copy()
76
+
77
+ return query_params
78
+
66
79
  def _parse_response(
67
80
  self,
68
81
  response_data: Dict[str, Any],
@@ -81,27 +94,14 @@ class BaseService:
81
94
  ZenoPayValidationError: If response parsing fails.
82
95
  """
83
96
  try:
84
- if "success" in response_data:
85
- success = response_data.get("success", True)
86
- data = response_data.get("data", response_data)
87
- message = response_data.get("message")
88
- error = response_data.get("error")
89
-
90
- if success and data:
91
- parsed_data = model_class.model_validate(data)
92
- else:
93
- # Error response
94
- raise ZenoPayValidationError(error or "Unknown API error")
95
- else:
96
- # Direct data response
97
- parsed_data = model_class.model_validate(response_data)
98
- success = True
99
- message = None
100
- error = None
97
+ parsed_data = model_class.model_validate(response_data)
98
+ success = True
99
+ message = response_data.get("message", None)
100
+ error = None
101
101
 
102
102
  return APIResponse[model_class](
103
103
  success=success,
104
- data=parsed_data,
104
+ results=parsed_data,
105
105
  message=message,
106
106
  error=error,
107
107
  )
@@ -131,16 +131,16 @@ class BaseService:
131
131
  url = self._build_url(endpoint)
132
132
  prepared_data = self._prepare_request_data(data)
133
133
 
134
- response_data = await self.http_client.post(url, data=prepared_data)
134
+ response_data = await self.http_client.post(url, json=prepared_data)
135
135
  return self._parse_response(response_data, model_class)
136
136
 
137
- async def _post(
137
+ def post_sync(
138
138
  self,
139
139
  endpoint: str,
140
140
  data: Union[BaseModel, Dict[str, Any]],
141
141
  model_class: Type[T],
142
142
  ) -> APIResponse[T]:
143
- """Make a POST request (legacy method name).
143
+ """Make a sync POST request.
144
144
 
145
145
  Args:
146
146
  endpoint: API endpoint name.
@@ -150,26 +150,52 @@ class BaseService:
150
150
  Returns:
151
151
  Parsed API response.
152
152
  """
153
- return await self.post_async(endpoint, data, model_class)
153
+ url = self._build_url(endpoint)
154
+ prepared_data = self._prepare_request_data(data)
154
155
 
155
- def post_sync(
156
+ response_data = self.http_client.post_sync(url, json=prepared_data)
157
+ return self._parse_response(response_data, model_class)
158
+
159
+ async def get_async(
156
160
  self,
157
161
  endpoint: str,
158
- data: Union[BaseModel, Dict[str, Any]],
159
162
  model_class: Type[T],
163
+ params: Optional[Union[BaseModel, Dict[str, Any]]] = None,
160
164
  ) -> APIResponse[T]:
161
- """Make a sync POST request.
165
+ """Make an async GET request.
162
166
 
163
167
  Args:
164
168
  endpoint: API endpoint name.
165
- data: Data to send in the request.
166
169
  model_class: Model class to parse response into.
170
+ params: Optional query parameters to send with the request.
167
171
 
168
172
  Returns:
169
173
  Parsed API response.
170
174
  """
171
175
  url = self._build_url(endpoint)
172
- prepared_data = self._prepare_request_data(data)
176
+ query_params = self._prepare_query_params(params)
177
+
178
+ response_data = await self.http_client.get(url, params=query_params)
179
+ return self._parse_response(response_data, model_class)
180
+
181
+ def get_sync(
182
+ self,
183
+ endpoint: str,
184
+ model_class: Type[T],
185
+ params: Optional[Union[BaseModel, Dict[str, Any]]] = None,
186
+ ) -> APIResponse[T]:
187
+ """Make a sync GET request.
188
+
189
+ Args:
190
+ endpoint: API endpoint name.
191
+ model_class: Model class to parse response into.
192
+ params: Optional query parameters to send with the request.
193
+
194
+ Returns:
195
+ Parsed API response.
196
+ """
197
+ url = self._build_url(endpoint)
198
+ query_params = self._prepare_query_params(params)
173
199
 
174
- response_data = self.http_client.post_sync(url, data=prepared_data)
200
+ response_data = self.http_client.get_sync(url, params=query_params)
175
201
  return self._parse_response(response_data, model_class)
@@ -0,0 +1,45 @@
1
+ """Disbursement service for the ZenoPay SDK"""
2
+
3
+ from elusion.zenopay.config import ZenoPayConfig
4
+ from elusion.zenopay.http import HTTPClient
5
+ from elusion.zenopay.models.common import APIResponse
6
+ from elusion.zenopay.models.disbursement import (
7
+ NewDisbursement,
8
+ DisbursementSuccessResponse,
9
+ )
10
+ from elusion.zenopay.services.base import BaseService
11
+
12
+
13
+ class DisbursementSyncMethods(BaseService):
14
+ """Sync methods for DisbursementService - inherits from BaseService for direct access."""
15
+
16
+ def disburse(self, disbursement_data: NewDisbursement) -> APIResponse[DisbursementSuccessResponse]:
17
+ """Send money to mobile wallet (sync).
18
+
19
+ Args:
20
+ disbursement_data: Disbursement data with recipient details.
21
+
22
+ Returns:
23
+ Disbursement response with transaction details and fees.
24
+ """
25
+ return self.post_sync("disbursement", disbursement_data, DisbursementSuccessResponse)
26
+
27
+
28
+ class DisbursementService(BaseService):
29
+ """Service for sending money to mobile wallets."""
30
+
31
+ def __init__(self, http_client: HTTPClient, config: ZenoPayConfig):
32
+ """Initialize DisbursementService with sync namespace."""
33
+ super().__init__(http_client, config)
34
+ self.sync = DisbursementSyncMethods(http_client, config)
35
+
36
+ async def disburse(self, disbursement_data: NewDisbursement) -> APIResponse[DisbursementSuccessResponse]:
37
+ """Send money to mobile wallet (async).
38
+
39
+ Args:
40
+ disbursement_data: Disbursement data with recipient details.
41
+
42
+ Returns:
43
+ Disbursement response with transaction details and fees.
44
+ """
45
+ return await self.post_async("disbursement", disbursement_data, DisbursementSuccessResponse)
@@ -1,10 +1,10 @@
1
1
  """Order service for the ZenoPay SDK"""
2
2
 
3
- from typing import Dict, Union
3
+ from typing import Any, Dict, Union
4
4
 
5
5
  from elusion.zenopay.config import ZenoPayConfig
6
6
  from elusion.zenopay.http import HTTPClient
7
- from elusion.zenopay.models.common import APIResponse, StatusCheckRequest
7
+ from elusion.zenopay.models.common import APIResponse
8
8
  from elusion.zenopay.models.order import (
9
9
  NewOrder,
10
10
  OrderResponse,
@@ -16,7 +16,7 @@ from elusion.zenopay.services.base import BaseService
16
16
  class OrderSyncMethods(BaseService):
17
17
  """Sync methods for OrderService - inherits from BaseService for direct access."""
18
18
 
19
- def create(self, order_data: Union[NewOrder, Dict[str, str]]) -> APIResponse[OrderResponse]:
19
+ def create(self, order_data: NewOrder) -> APIResponse[OrderResponse]:
20
20
  """Create a new order and initiate USSD payment (sync).
21
21
 
22
22
  Args:
@@ -30,11 +30,10 @@ class OrderSyncMethods(BaseService):
30
30
  ... response = zenopay_client.orders.sync.create(order_data)
31
31
  ... print(f"Order created: {response.data.order_id}")
32
32
  """
33
- # ✅ Direct access to post_sync - no parent needed
34
33
  return self.post_sync("create_order", order_data, OrderResponse)
35
34
 
36
- def get_status(self, order_id: str) -> APIResponse[OrderStatusResponse]:
37
- """Check the status of an existing order (sync).
35
+ def check_status(self, order_id: str) -> APIResponse[OrderStatusResponse]:
36
+ """Check the status of an existing order using GET request (sync).
38
37
 
39
38
  Args:
40
39
  order_id: The order ID to check status for.
@@ -42,21 +41,16 @@ class OrderSyncMethods(BaseService):
42
41
  Returns:
43
42
  Order status response with payment details.
44
43
  """
45
- status_request = StatusCheckRequest(
46
- order_id=order_id,
47
- api_key=self.config.api_key,
48
- secret_key=self.config.secret_key,
49
- account_id=self.config.account_id or "",
50
- check_status=1,
51
- )
52
- # ✅ Direct access - clean and simple
53
- return self.post_sync("order_status", status_request, OrderStatusResponse)
44
+ params: Dict[str, Any] = {
45
+ "order_id": order_id,
46
+ }
47
+ return self.get_sync("order_status", OrderStatusResponse, params=params)
54
48
 
55
49
  def check_payment(self, order_id: str) -> bool:
56
50
  """Check if an order has been paid (sync)."""
57
51
  try:
58
- status_response = self.get_status(order_id)
59
- return status_response.data.payment_status == "COMPLETED"
52
+ status_response = self.check_status(order_id)
53
+ return status_response.results.data[0].payment_status == "COMPLETED"
60
54
  except Exception:
61
55
  return False
62
56
 
@@ -67,12 +61,12 @@ class OrderSyncMethods(BaseService):
67
61
  start_time = time.time()
68
62
 
69
63
  while True:
70
- status_response = self.get_status(order_id)
64
+ status_response = self.check_status(order_id)
71
65
 
72
- if status_response.data.payment_status == "COMPLETED":
66
+ if status_response.results.data[0].payment_status == "COMPLETED":
73
67
  return status_response
74
68
 
75
- if status_response.data.payment_status == "FAILED":
69
+ if status_response.results.data[0].payment_status == "FAILED":
76
70
  raise Exception(f"Payment failed for order {order_id}")
77
71
 
78
72
  elapsed = time.time() - start_time
@@ -91,41 +85,51 @@ class OrderService(BaseService):
91
85
  self.sync = OrderSyncMethods(http_client, config)
92
86
 
93
87
  async def create(self, order_data: Union[NewOrder, Dict[str, str]]) -> APIResponse[OrderResponse]:
94
- """Create a new order and initiate USSD payment."""
95
- return await self._post("create_order", order_data, OrderResponse)
96
-
97
- async def get_status(self, order_id: str) -> APIResponse[OrderStatusResponse]:
98
- """Check the status of an existing order."""
99
- status_request = StatusCheckRequest(
100
- order_id=order_id,
101
- api_key=self.config.api_key,
102
- secret_key=self.config.secret_key,
103
- account_id=self.config.account_id or "",
104
- check_status=1,
105
- )
106
- return await self._post("order_status", status_request, OrderStatusResponse)
88
+ """Create a new order and initiate USSD payment (async).
89
+
90
+ Args:
91
+ order_data: Order creation data.
92
+
93
+ Returns:
94
+ Created order response with order_id and status.
95
+ """
96
+ return await self.post_async("create_order", order_data, OrderResponse)
97
+
98
+ async def check_status(self, order_id: str) -> APIResponse[OrderStatusResponse]:
99
+ """Check the status of an existing order using GET request (async).
100
+
101
+ Args:
102
+ order_id: The order ID to check status for.
103
+
104
+ Returns:
105
+ Order status response with payment details.
106
+ """
107
+ params: Dict[str, Any] = {
108
+ "order_id": order_id,
109
+ }
110
+ return await self.get_async("order_status", OrderStatusResponse, params=params)
107
111
 
108
112
  async def check_payment(self, order_id: str) -> bool:
109
- """Check if an order has been paid."""
113
+ """Check if an order has been paid (async)."""
110
114
  try:
111
- status_response = await self.get_status(order_id)
112
- return status_response.data.payment_status == "COMPLETED"
115
+ status_response = await self.check_status(order_id)
116
+ return status_response.results.data[0].payment_status == "COMPLETED"
113
117
  except Exception:
114
118
  return False
115
119
 
116
120
  async def wait_for_payment(self, order_id: str, timeout: int = 300, poll_interval: int = 10) -> APIResponse[OrderStatusResponse]:
117
- """Wait for an order to be paid."""
121
+ """Wait for an order to be paid (async)."""
118
122
  import asyncio
119
123
 
120
124
  start_time = asyncio.get_event_loop().time()
121
125
 
122
126
  while True:
123
- status_response = await self.get_status(order_id)
127
+ status_response = await self.check_status(order_id)
124
128
 
125
- if status_response.data.payment_status == "COMPLETED":
129
+ if status_response.results.data[0].payment_status == "COMPLETED":
126
130
  return status_response
127
131
 
128
- if status_response.data.payment_status == "FAILED":
132
+ if status_response.results.data[0].payment_status == "FAILED":
129
133
  raise Exception(f"Payment failed for order {order_id}")
130
134
 
131
135
  elapsed = asyncio.get_event_loop().time() - start_time
@@ -1,9 +1,3 @@
1
- from elusion.zenopay.utils.helpers import (
2
- format_amount,
3
- parse_amount,
4
- )
1
+ from elusion.zenopay.utils.helpers import format_amount, parse_amount, generate_order_id, generate_short_order_id
5
2
 
6
- __all__ = [
7
- "format_amount",
8
- "parse_amount",
9
- ]
3
+ __all__ = ["format_amount", "parse_amount", "generate_order_id", "generate_short_order_id"]
@@ -1,3 +1,7 @@
1
+ import uuid
2
+ from typing import Optional
3
+
4
+
1
5
  def format_amount(amount: int, currency: str = "TZS") -> str:
2
6
  """Format payment amount for display.
3
7
 
@@ -33,3 +37,55 @@ def parse_amount(amount_str: str, currency: str = "TZS") -> int:
33
37
  return int(amount_float * 100)
34
38
  except (ValueError, TypeError):
35
39
  raise ValueError(f"Invalid amount format: {amount_str}")
40
+
41
+
42
+ def generate_order_id(prefix: Optional[str] = None) -> str:
43
+ """Generate a unique order ID using UUID.
44
+
45
+ Args:
46
+ prefix: Optional prefix to add to the order ID.
47
+
48
+ Returns:
49
+ Unique order ID string.
50
+
51
+ Examples:
52
+ >>> generate_order_id()
53
+ 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
54
+
55
+ >>> generate_order_id(prefix="ORDER")
56
+ 'ORDER_f47ac10b-58cc-4372-a567-0e02b2c3d479'
57
+
58
+ >>> generate_order_id(prefix="SHOP")
59
+ 'SHOP_f47ac10b-58cc-4372-a567-0e02b2c3d479'
60
+ """
61
+ unique_id = str(uuid.uuid4())
62
+
63
+ if prefix:
64
+ return f"{prefix}_{unique_id}"
65
+
66
+ return unique_id
67
+
68
+
69
+ def generate_short_order_id(prefix: Optional[str] = None, length: int = 8) -> str:
70
+ """Generate a shorter unique order ID using UUID.
71
+
72
+ Args:
73
+ prefix: Optional prefix to add to the order ID.
74
+ length: Length of the UUID portion (default: 8 characters).
75
+
76
+ Returns:
77
+ Shorter unique order ID string.
78
+
79
+ Examples:
80
+ >>> generate_short_order_id()
81
+ 'f47ac10b'
82
+
83
+ >>> generate_short_order_id(prefix="ORD", length=12)
84
+ 'ORD_f47ac10b58cc'
85
+ """
86
+ unique_id = str(uuid.uuid4()).replace("-", "")[:length]
87
+
88
+ if prefix:
89
+ return f"{prefix}_{unique_id}"
90
+
91
+ return unique_id