zenopay-sdk 0.0.1__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.
@@ -0,0 +1,175 @@
1
+ """Base service class for all ZenoPay SDK services."""
2
+
3
+ from typing import Any, Dict, Type, TypeVar, Union
4
+
5
+ from pydantic import BaseModel, ValidationError
6
+
7
+ from elusion.zenopay.config import ZenoPayConfig
8
+ from elusion.zenopay.exceptions import ZenoPayValidationError
9
+ from elusion.zenopay.http import HTTPClient
10
+ from elusion.zenopay.models.common import APIResponse
11
+
12
+ T = TypeVar("T", bound=BaseModel)
13
+
14
+
15
+ class BaseService:
16
+ """Base class for all API services."""
17
+
18
+ def __init__(self, http_client: HTTPClient, config: ZenoPayConfig) -> None:
19
+ """Initialize the service.
20
+
21
+ Args:
22
+ http_client: HTTP client instance.
23
+ config: ZenoPay configuration.
24
+ """
25
+ self.http_client = http_client
26
+ self.config = config
27
+
28
+ def _build_url(self, endpoint: str) -> str:
29
+ """Build a full URL for an API endpoint.
30
+
31
+ Args:
32
+ endpoint: The endpoint name from config.ENDPOINTS.
33
+
34
+ Returns:
35
+ Full URL for the endpoint.
36
+ """
37
+ return self.config.get_endpoint_url(endpoint)
38
+
39
+ def _prepare_request_data(self, data: Union[BaseModel, Dict[str, Any]]) -> Dict[str, Any]:
40
+ """Prepare and validate data for API requests.
41
+
42
+ Args:
43
+ data: Data to prepare for the request.
44
+
45
+ Returns:
46
+ Prepared data dictionary for form submission.
47
+
48
+ Raises:
49
+ ZenoPayValidationError: If validation fails.
50
+ """
51
+ if isinstance(data, BaseModel):
52
+ request_data = data.model_dump(exclude_unset=True, by_alias=True)
53
+ else:
54
+ request_data = data.copy()
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
+ return request_data
65
+
66
+ def _parse_response(
67
+ self,
68
+ response_data: Dict[str, Any],
69
+ model_class: Type[T],
70
+ ) -> APIResponse[T]:
71
+ """Parse API response into typed models.
72
+
73
+ Args:
74
+ response_data: Raw response data from API.
75
+ model_class: Pydantic model class to parse data into.
76
+
77
+ Returns:
78
+ Parsed response with typed data.
79
+
80
+ Raises:
81
+ ZenoPayValidationError: If response parsing fails.
82
+ """
83
+ 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
101
+
102
+ return APIResponse[model_class](
103
+ success=success,
104
+ data=parsed_data,
105
+ message=message,
106
+ error=error,
107
+ )
108
+
109
+ except ValidationError as e:
110
+ raise ZenoPayValidationError(
111
+ f"Failed to parse response: {str(e)}",
112
+ validation_errors={"errors": e.errors()},
113
+ ) from e
114
+
115
+ async def post_async(
116
+ self,
117
+ endpoint: str,
118
+ data: Union[BaseModel, Dict[str, Any]],
119
+ model_class: Type[T],
120
+ ) -> APIResponse[T]:
121
+ """Make an async POST request.
122
+
123
+ Args:
124
+ endpoint: API endpoint name.
125
+ data: Data to send in the request.
126
+ model_class: Model class to parse response into.
127
+
128
+ Returns:
129
+ Parsed API response.
130
+ """
131
+ url = self._build_url(endpoint)
132
+ prepared_data = self._prepare_request_data(data)
133
+
134
+ response_data = await self.http_client.post(url, data=prepared_data)
135
+ return self._parse_response(response_data, model_class)
136
+
137
+ async def _post(
138
+ self,
139
+ endpoint: str,
140
+ data: Union[BaseModel, Dict[str, Any]],
141
+ model_class: Type[T],
142
+ ) -> APIResponse[T]:
143
+ """Make a POST request (legacy method name).
144
+
145
+ Args:
146
+ endpoint: API endpoint name.
147
+ data: Data to send in the request.
148
+ model_class: Model class to parse response into.
149
+
150
+ Returns:
151
+ Parsed API response.
152
+ """
153
+ return await self.post_async(endpoint, data, model_class)
154
+
155
+ def post_sync(
156
+ self,
157
+ endpoint: str,
158
+ data: Union[BaseModel, Dict[str, Any]],
159
+ model_class: Type[T],
160
+ ) -> APIResponse[T]:
161
+ """Make a sync POST request.
162
+
163
+ Args:
164
+ endpoint: API endpoint name.
165
+ data: Data to send in the request.
166
+ model_class: Model class to parse response into.
167
+
168
+ Returns:
169
+ Parsed API response.
170
+ """
171
+ url = self._build_url(endpoint)
172
+ prepared_data = self._prepare_request_data(data)
173
+
174
+ response_data = self.http_client.post_sync(url, data=prepared_data)
175
+ return self._parse_response(response_data, model_class)
@@ -0,0 +1,135 @@
1
+ """Order service for the ZenoPay SDK"""
2
+
3
+ from typing import Dict, Union
4
+
5
+ from elusion.zenopay.config import ZenoPayConfig
6
+ from elusion.zenopay.http import HTTPClient
7
+ from elusion.zenopay.models.common import APIResponse, StatusCheckRequest
8
+ from elusion.zenopay.models.order import (
9
+ NewOrder,
10
+ OrderResponse,
11
+ OrderStatusResponse,
12
+ )
13
+ from elusion.zenopay.services.base import BaseService
14
+
15
+
16
+ class OrderSyncMethods(BaseService):
17
+ """Sync methods for OrderService - inherits from BaseService for direct access."""
18
+
19
+ def create(self, order_data: Union[NewOrder, Dict[str, str]]) -> APIResponse[OrderResponse]:
20
+ """Create a new order and initiate USSD payment (sync).
21
+
22
+ Args:
23
+ order_data: Order creation data.
24
+
25
+ Returns:
26
+ Created order response with order_id and status.
27
+
28
+ Examples:
29
+ >>> with zenopay_client:
30
+ ... response = zenopay_client.orders.sync.create(order_data)
31
+ ... print(f"Order created: {response.data.order_id}")
32
+ """
33
+ # ✅ Direct access to post_sync - no parent needed
34
+ return self.post_sync("create_order", order_data, OrderResponse)
35
+
36
+ def get_status(self, order_id: str) -> APIResponse[OrderStatusResponse]:
37
+ """Check the status of an existing order (sync).
38
+
39
+ Args:
40
+ order_id: The order ID to check status for.
41
+
42
+ Returns:
43
+ Order status response with payment details.
44
+ """
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)
54
+
55
+ def check_payment(self, order_id: str) -> bool:
56
+ """Check if an order has been paid (sync)."""
57
+ try:
58
+ status_response = self.get_status(order_id)
59
+ return status_response.data.payment_status == "COMPLETED"
60
+ except Exception:
61
+ return False
62
+
63
+ def wait_for_payment(self, order_id: str, timeout: int = 300, poll_interval: int = 10) -> APIResponse[OrderStatusResponse]:
64
+ """Wait for an order to be paid (sync)."""
65
+ import time
66
+
67
+ start_time = time.time()
68
+
69
+ while True:
70
+ status_response = self.get_status(order_id)
71
+
72
+ if status_response.data.payment_status == "COMPLETED":
73
+ return status_response
74
+
75
+ if status_response.data.payment_status == "FAILED":
76
+ raise Exception(f"Payment failed for order {order_id}")
77
+
78
+ elapsed = time.time() - start_time
79
+ if elapsed >= timeout:
80
+ raise TimeoutError(f"Payment timeout after {timeout} seconds")
81
+
82
+ time.sleep(poll_interval)
83
+
84
+
85
+ class OrderService(BaseService):
86
+ """Service for managing orders and payments."""
87
+
88
+ def __init__(self, http_client: HTTPClient, config: ZenoPayConfig):
89
+ """Initialize OrderService with sync namespace."""
90
+ super().__init__(http_client, config)
91
+ self.sync = OrderSyncMethods(http_client, config)
92
+
93
+ 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)
107
+
108
+ async def check_payment(self, order_id: str) -> bool:
109
+ """Check if an order has been paid."""
110
+ try:
111
+ status_response = await self.get_status(order_id)
112
+ return status_response.data.payment_status == "COMPLETED"
113
+ except Exception:
114
+ return False
115
+
116
+ 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."""
118
+ import asyncio
119
+
120
+ start_time = asyncio.get_event_loop().time()
121
+
122
+ while True:
123
+ status_response = await self.get_status(order_id)
124
+
125
+ if status_response.data.payment_status == "COMPLETED":
126
+ return status_response
127
+
128
+ if status_response.data.payment_status == "FAILED":
129
+ raise Exception(f"Payment failed for order {order_id}")
130
+
131
+ elapsed = asyncio.get_event_loop().time() - start_time
132
+ if elapsed >= timeout:
133
+ raise TimeoutError(f"Payment timeout after {timeout} seconds")
134
+
135
+ await asyncio.sleep(poll_interval)
@@ -0,0 +1,188 @@
1
+ """Webhook service for the ZenoPay SDK."""
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import Any, Callable, Dict, Optional
7
+
8
+ from elusion.zenopay.exceptions import ZenoPayWebhookError
9
+ from elusion.zenopay.models.webhook import WebhookEvent, WebhookResponse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class WebhookService:
15
+ """Service for handling ZenoPay webhooks."""
16
+
17
+ def __init__(self) -> None:
18
+ """Initialize the webhook service."""
19
+ self._handlers: Dict[str, Callable[[WebhookEvent], Any]] = {}
20
+
21
+ def parse_webhook(self, raw_data: str, signature: Optional[str] = None) -> WebhookEvent:
22
+ """Parse raw webhook data into a WebhookEvent.
23
+
24
+ Args:
25
+ raw_data: Raw JSON string from the webhook request.
26
+ signature: Optional webhook signature for verification.
27
+
28
+ Returns:
29
+ Parsed WebhookEvent.
30
+
31
+ Raises:
32
+ ZenoPayWebhookError: If the webhook data is invalid.
33
+
34
+ Examples:
35
+ >>> webhook_service = WebhookService()
36
+ >>> raw_data = '''{"order_id":"677e43274d7cb","payment_status":"COMPLETED","reference":"1003020496"}'''
37
+ >>> event = webhook_service.parse_webhook(raw_data)
38
+ >>> print(f"Order {event.payload.order_id} is {event.payload.payment_status}")
39
+ """
40
+ try:
41
+ event = WebhookEvent.from_raw_data(raw_data)
42
+ event.signature = signature
43
+ event.timestamp = datetime.now().isoformat()
44
+
45
+ logger.info(f"Parsed webhook for order {event.payload.order_id}: {event.payload.payment_status}")
46
+ return event
47
+
48
+ except Exception as e:
49
+ logger.error(f"Failed to parse webhook: {e}")
50
+ raise ZenoPayWebhookError(f"Invalid webhook data: {e}", {"raw_data": raw_data})
51
+
52
+ def register_handler(self, event_type: str, handler: Callable[[WebhookEvent], Any]) -> None:
53
+ """Register a handler for specific webhook events.
54
+
55
+ Args:
56
+ event_type: Type of event to handle (e.g., "COMPLETED", "FAILED").
57
+ handler: Function to call when this event type is received.
58
+
59
+ Examples:
60
+ >>> def payment_completed_handler(event: WebhookEvent):
61
+ ... print(f"Payment completed for order {event.payload.order_id}")
62
+ ... # Update database, send emails, etc.
63
+ >>>
64
+ >>> webhook_service.register_handler("COMPLETED", payment_completed_handler)
65
+ """
66
+ self._handlers[event_type] = handler
67
+
68
+ def handle_webhook(self, event: WebhookEvent) -> WebhookResponse:
69
+ """Handle a parsed webhook event.
70
+
71
+ Args:
72
+ event: Parsed webhook event.
73
+
74
+ Returns:
75
+ Webhook response to send back to ZenoPay.
76
+
77
+ Examples:
78
+ >>> event = webhook_service.parse_webhook(raw_data)
79
+ >>> response = webhook_service.handle_webhook(event)
80
+ >>> print(response.message) # "Webhook received and processed"
81
+ """
82
+ try:
83
+ event_type = event.payload.payment_status
84
+
85
+ if event_type in self._handlers:
86
+ self._handlers[event_type](event)
87
+ else:
88
+ logger.warning(f"No handler registered for event type: {event_type}")
89
+
90
+ self._log_webhook_event(event)
91
+
92
+ return WebhookResponse(
93
+ status="success",
94
+ message=f"Webhook received and processed for order {event.payload.order_id}",
95
+ )
96
+
97
+ except Exception as e:
98
+ logger.error(f"Error handling webhook: {e}")
99
+ return WebhookResponse(status="error", message=f"Error processing webhook: {str(e)}")
100
+
101
+ def process_webhook_request(self, raw_data: str, signature: Optional[str] = None) -> WebhookResponse:
102
+ """Process a complete webhook request from raw data to response.
103
+
104
+ Args:
105
+ raw_data: Raw JSON string from the webhook request.
106
+ signature: Optional webhook signature.
107
+
108
+ Returns:
109
+ Webhook response to send back to ZenoPay.
110
+
111
+ Examples:
112
+ >>> # In your Flask/FastAPI endpoint:
113
+ >>> @app.route('/webhook', methods=['POST'])
114
+ >>> def handle_zenopay_webhook():
115
+ ... raw_data = request.data.decode('utf-8')
116
+ ... response = webhook_service.process_webhook_request(raw_data)
117
+ ... return {"status": response.status, "message": response.message}
118
+ """
119
+ try:
120
+ event = self.parse_webhook(raw_data, signature)
121
+
122
+ response = self.handle_webhook(event)
123
+
124
+ logger.info(f"Successfully processed webhook for order {event.payload.order_id}")
125
+ return response
126
+
127
+ except ZenoPayWebhookError as e:
128
+ logger.error(f"Webhook error: {e}")
129
+ return WebhookResponse(status="error", message=str(e))
130
+ except Exception as e:
131
+ logger.error(f"Unexpected error processing webhook: {e}")
132
+ return WebhookResponse(status="error", message="Internal error processing webhook")
133
+
134
+ def _log_webhook_event(self, event: WebhookEvent) -> None:
135
+ """Log webhook event for debugging and audit purposes.
136
+
137
+ Args:
138
+ event: The webhook event to log.
139
+ """
140
+ log_data: Dict[str, Any] = {
141
+ "timestamp": event.timestamp,
142
+ "order_id": event.payload.order_id,
143
+ "payment_status": event.payload.payment_status,
144
+ "reference": event.payload.reference,
145
+ }
146
+
147
+ logger.info(f"Webhook event logged: {json.dumps(log_data)}")
148
+
149
+ def create_test_webhook(self, order_id: str, payment_status: str = "COMPLETED") -> WebhookEvent:
150
+ """Create a test webhook event for development/testing.
151
+
152
+ Args:
153
+ order_id: Order ID for the test webhook.
154
+ payment_status: Payment status for the test webhook.
155
+
156
+ Returns:
157
+ Test webhook event.
158
+
159
+ Examples:
160
+ >>> # For testing your webhook handlers
161
+ >>> test_event = webhook_service.create_test_webhook("test-123", "COMPLETED")
162
+ >>> response = webhook_service.handle_webhook(test_event)
163
+ """
164
+ test_payload: Dict[str, Any] = {
165
+ "order_id": order_id,
166
+ "payment_status": payment_status,
167
+ "reference": "TEST-" + str(int(datetime.now().timestamp())),
168
+ "metadata": {"test": True, "created_at": datetime.now().isoformat()},
169
+ }
170
+
171
+ raw_data = json.dumps(test_payload)
172
+ return self.parse_webhook(raw_data)
173
+
174
+ def on_payment_completed(self, handler: Callable[[WebhookEvent], Any]) -> None:
175
+ """Register handler for payment completed events."""
176
+ self.register_handler("COMPLETED", handler)
177
+
178
+ def on_payment_failed(self, handler: Callable[[WebhookEvent], Any]) -> None:
179
+ """Register handler for payment failed events."""
180
+ self.register_handler("FAILED", handler)
181
+
182
+ def on_payment_pending(self, handler: Callable[[WebhookEvent], Any]) -> None:
183
+ """Register handler for payment pending events."""
184
+ self.register_handler("PENDING", handler)
185
+
186
+ def on_payment_cancelled(self, handler: Callable[[WebhookEvent], Any]) -> None:
187
+ """Register handler for payment cancelled events."""
188
+ self.register_handler("CANCELLED", handler)
@@ -0,0 +1,9 @@
1
+ from elusion.zenopay.utils.helpers import (
2
+ format_amount,
3
+ parse_amount,
4
+ )
5
+
6
+ __all__ = [
7
+ "format_amount",
8
+ "parse_amount",
9
+ ]
@@ -0,0 +1,35 @@
1
+ def format_amount(amount: int, currency: str = "TZS") -> str:
2
+ """Format payment amount for display.
3
+
4
+ Args:
5
+ amount: Amount in smallest currency unit.
6
+ currency: Currency code.
7
+
8
+ Returns:
9
+ Formatted amount string.
10
+ """
11
+ if currency == "TZS":
12
+ return f"{amount:,} {currency}"
13
+ else:
14
+ amount_decimal = amount / 100
15
+ return f"{amount_decimal:.2f} {currency}"
16
+
17
+
18
+ def parse_amount(amount_str: str, currency: str = "TZS") -> int:
19
+ """Parse amount string to integer in smallest currency unit.
20
+
21
+ Args:
22
+ amount_str: Amount string to parse.
23
+ currency: Currency code.
24
+
25
+ Returns:
26
+ Amount as integer in smallest currency unit.
27
+ """
28
+ try:
29
+ amount_float = float(amount_str.replace(",", ""))
30
+ if currency == "TZS":
31
+ return int(amount_float)
32
+ else:
33
+ return int(amount_float * 100)
34
+ except (ValueError, TypeError):
35
+ raise ValueError(f"Invalid amount format: {amount_str}")