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.
- elusion/__init__.py +3 -0
- elusion/zenopay/__init__.py +42 -0
- elusion/zenopay/client.py +207 -0
- elusion/zenopay/config.py +116 -0
- elusion/zenopay/exceptions.py +227 -0
- elusion/zenopay/http/__init__.py +5 -0
- elusion/zenopay/http/client.py +320 -0
- elusion/zenopay/models/__init__.py +43 -0
- elusion/zenopay/models/common.py +93 -0
- elusion/zenopay/models/order.py +251 -0
- elusion/zenopay/models/payment.py +304 -0
- elusion/zenopay/models/webhook.py +122 -0
- elusion/zenopay/services/__init__.py +7 -0
- elusion/zenopay/services/base.py +175 -0
- elusion/zenopay/services/orders.py +135 -0
- elusion/zenopay/services/webhooks.py +188 -0
- elusion/zenopay/utils/__init__.py +9 -0
- elusion/zenopay/utils/helpers.py +35 -0
- zenopay_sdk-0.0.1.dist-info/METADATA +387 -0
- zenopay_sdk-0.0.1.dist-info/RECORD +22 -0
- zenopay_sdk-0.0.1.dist-info/WHEEL +4 -0
- zenopay_sdk-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,251 @@
|
|
1
|
+
"""Order-related models for the ZenoPay SDK."""
|
2
|
+
|
3
|
+
from datetime import datetime
|
4
|
+
from typing import Any, Dict, Optional, Union
|
5
|
+
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
7
|
+
|
8
|
+
|
9
|
+
class OrderBase(BaseModel):
|
10
|
+
"""Base order model with common fields."""
|
11
|
+
|
12
|
+
buyer_email: str = Field(..., description="Buyer's email address")
|
13
|
+
buyer_name: str = Field(..., description="Buyer's full name")
|
14
|
+
buyer_phone: str = Field(..., description="Buyer's phone number")
|
15
|
+
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")
|
17
|
+
|
18
|
+
@field_validator("buyer_email")
|
19
|
+
def validate_email(cls, v: str) -> str:
|
20
|
+
"""Validate email format."""
|
21
|
+
if "@" not in v or "." not in v:
|
22
|
+
raise ValueError("Invalid email format")
|
23
|
+
return v.lower().strip()
|
24
|
+
|
25
|
+
@field_validator("buyer_phone")
|
26
|
+
def validate_phone(cls, v: str) -> str:
|
27
|
+
"""Validate phone number format."""
|
28
|
+
# Remove any non-digit characters except +
|
29
|
+
cleaned = "".join(c for c in v if c.isdigit() or c == "+")
|
30
|
+
if len(cleaned) < 10:
|
31
|
+
raise ValueError("Phone number must be at least 10 digits")
|
32
|
+
return cleaned
|
33
|
+
|
34
|
+
@field_validator("webhook_url")
|
35
|
+
def validate_webhook_url(cls, v: Optional[str]) -> Optional[str]:
|
36
|
+
"""Validate webhook URL format."""
|
37
|
+
if v is not None:
|
38
|
+
v = v.strip()
|
39
|
+
if not v.startswith(("http://", "https://")):
|
40
|
+
raise ValueError("Webhook URL must start with http:// or https://")
|
41
|
+
return v
|
42
|
+
|
43
|
+
|
44
|
+
class NewOrder(OrderBase):
|
45
|
+
"""Model for creating a new order."""
|
46
|
+
|
47
|
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional order metadata")
|
48
|
+
|
49
|
+
@field_validator("metadata")
|
50
|
+
def validate_metadata(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
51
|
+
"""Keep metadata as dict, convert to JSON only when needed."""
|
52
|
+
return v
|
53
|
+
|
54
|
+
model_config = ConfigDict(
|
55
|
+
json_schema_extra={
|
56
|
+
"example": {
|
57
|
+
"buyer_email": "jackson@gmail.com",
|
58
|
+
"buyer_name": "Jackson Dastani",
|
59
|
+
"buyer_phone": "0652449389",
|
60
|
+
"amount": 1000,
|
61
|
+
"webhook_url": "https://yourwebsite.com/webhook",
|
62
|
+
"metadata": {
|
63
|
+
"product_id": "12345",
|
64
|
+
"color": "blue",
|
65
|
+
"size": "L",
|
66
|
+
"custom_notes": "Please gift-wrap this item.",
|
67
|
+
},
|
68
|
+
}
|
69
|
+
}
|
70
|
+
)
|
71
|
+
|
72
|
+
|
73
|
+
class OrderStatus(BaseModel):
|
74
|
+
"""Model for checking order status."""
|
75
|
+
|
76
|
+
order_id: str = Field(..., description="Order ID to check status for")
|
77
|
+
|
78
|
+
model_config = ConfigDict(json_schema_extra={"example": {"order_id": "66c4bb9c9abb1"}})
|
79
|
+
|
80
|
+
|
81
|
+
class Order(BaseModel):
|
82
|
+
"""Complete order model with all fields."""
|
83
|
+
|
84
|
+
# Order details
|
85
|
+
buyer_email: str = Field(..., description="Buyer's email address")
|
86
|
+
buyer_name: str = Field(..., description="Buyer's full name")
|
87
|
+
buyer_phone: str = Field(..., description="Buyer's phone number")
|
88
|
+
amount: int = Field(..., description="Order amount")
|
89
|
+
|
90
|
+
# Payment details
|
91
|
+
payment_status: str = Field("PENDING", description="Current payment status")
|
92
|
+
reference: Optional[str] = Field(None, description="Payment reference number")
|
93
|
+
|
94
|
+
# Additional information
|
95
|
+
webhook_url: Optional[str] = Field(None, description="Webhook URL")
|
96
|
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Order metadata")
|
97
|
+
|
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
|
+
@field_validator("payment_status")
|
104
|
+
def validate_payment_status(cls, v: str) -> str:
|
105
|
+
"""Validate payment status."""
|
106
|
+
valid_statuses = ["PENDING", "COMPLETED", "FAILED", "CANCELLED"]
|
107
|
+
if v not in valid_statuses:
|
108
|
+
raise ValueError(f"Invalid payment status. Must be one of: {', '.join(valid_statuses)}")
|
109
|
+
return v
|
110
|
+
|
111
|
+
@field_validator("metadata")
|
112
|
+
def validate_metadata(cls, v: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
113
|
+
return v
|
114
|
+
|
115
|
+
@property
|
116
|
+
def is_paid(self) -> bool:
|
117
|
+
"""Check if the order has been paid."""
|
118
|
+
return self.payment_status == "COMPLETED"
|
119
|
+
|
120
|
+
@property
|
121
|
+
def is_pending(self) -> bool:
|
122
|
+
"""Check if the order is still pending."""
|
123
|
+
return self.payment_status == "PENDING"
|
124
|
+
|
125
|
+
@property
|
126
|
+
def has_failed(self) -> bool:
|
127
|
+
"""Check if the payment has failed."""
|
128
|
+
return self.payment_status == "FAILED"
|
129
|
+
|
130
|
+
@property
|
131
|
+
def is_cancelled(self) -> bool:
|
132
|
+
"""Check if the order has been cancelled."""
|
133
|
+
return self.payment_status == "CANCELLED"
|
134
|
+
|
135
|
+
def get_metadata_value(self, key: str, default: Any = None) -> Any:
|
136
|
+
"""Get a specific value from the metadata."""
|
137
|
+
if self.metadata:
|
138
|
+
return self.metadata.get(key, default)
|
139
|
+
return default
|
140
|
+
|
141
|
+
model_config = ConfigDict(
|
142
|
+
json_schema_extra={
|
143
|
+
"example": {
|
144
|
+
"id": "66c4bb9c9abb1",
|
145
|
+
"buyer_email": "jackson@gmail.com",
|
146
|
+
"buyer_name": "Jackson Dastani",
|
147
|
+
"buyer_phone": "0652449389",
|
148
|
+
"amount": 1000,
|
149
|
+
"payment_status": "COMPLETED",
|
150
|
+
"reference": "1003020496",
|
151
|
+
"webhook_url": "https://yourwebsite.com/webhook",
|
152
|
+
"metadata": {
|
153
|
+
"product_id": "12345",
|
154
|
+
"color": "blue",
|
155
|
+
"size": "L",
|
156
|
+
"custom_notes": "Please gift-wrap this item.",
|
157
|
+
},
|
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
|
+
}
|
162
|
+
}
|
163
|
+
)
|
164
|
+
|
165
|
+
|
166
|
+
class OrderResponse(BaseModel):
|
167
|
+
"""Response model for order operations."""
|
168
|
+
|
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")
|
175
|
+
|
176
|
+
model_config = ConfigDict(
|
177
|
+
json_schema_extra={
|
178
|
+
"example": {
|
179
|
+
"status": "success",
|
180
|
+
"message": "Order created successfully",
|
181
|
+
"order_id": "66c4bb9c9abb1",
|
182
|
+
}
|
183
|
+
}
|
184
|
+
)
|
185
|
+
|
186
|
+
|
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
|
227
|
+
|
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
|
+
|
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
|
+
)
|
@@ -0,0 +1,304 @@
|
|
1
|
+
"""Payment models for the ZenoPay SDK."""
|
2
|
+
|
3
|
+
from datetime import datetime
|
4
|
+
from enum import Enum
|
5
|
+
from typing import Any, Dict, Optional, Union
|
6
|
+
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
8
|
+
|
9
|
+
|
10
|
+
class PaymentStatus(str, Enum):
|
11
|
+
"""Enumeration of payment statuses."""
|
12
|
+
|
13
|
+
PENDING = "PENDING"
|
14
|
+
COMPLETED = "COMPLETED"
|
15
|
+
FAILED = "FAILED"
|
16
|
+
CANCELLED = "CANCELLED"
|
17
|
+
|
18
|
+
@classmethod
|
19
|
+
def from_string(cls, status: str) -> "PaymentStatus":
|
20
|
+
"""Create PaymentStatus from string.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
status: Status string.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
PaymentStatus enum value.
|
27
|
+
|
28
|
+
Raises:
|
29
|
+
ValueError: If status is invalid.
|
30
|
+
"""
|
31
|
+
try:
|
32
|
+
return cls(status.upper())
|
33
|
+
except ValueError:
|
34
|
+
raise ValueError(f"Invalid payment status: {status}")
|
35
|
+
|
36
|
+
@property
|
37
|
+
def is_final(self) -> bool:
|
38
|
+
"""Check if this is a final status (completed, failed, or cancelled)."""
|
39
|
+
return self in (self.COMPLETED, self.FAILED, self.CANCELLED)
|
40
|
+
|
41
|
+
@property
|
42
|
+
def is_successful(self) -> bool:
|
43
|
+
"""Check if this represents a successful payment."""
|
44
|
+
return self == self.COMPLETED
|
45
|
+
|
46
|
+
def __str__(self) -> str:
|
47
|
+
"""String representation of the status."""
|
48
|
+
return self.value
|
49
|
+
|
50
|
+
|
51
|
+
class PaymentMethod(str, Enum):
|
52
|
+
"""Enumeration of payment methods."""
|
53
|
+
|
54
|
+
USSD = "USSD"
|
55
|
+
MOBILE_MONEY = "MOBILE_MONEY"
|
56
|
+
BANK_TRANSFER = "BANK_TRANSFER"
|
57
|
+
CARD = "CARD"
|
58
|
+
|
59
|
+
def __str__(self) -> str:
|
60
|
+
"""String representation of the payment method."""
|
61
|
+
return self.value
|
62
|
+
|
63
|
+
|
64
|
+
class PaymentProvider(str, Enum):
|
65
|
+
"""Enumeration of payment providers."""
|
66
|
+
|
67
|
+
VODACOM = "VODACOM"
|
68
|
+
AIRTEL = "AIRTEL"
|
69
|
+
TIGO = "TIGO"
|
70
|
+
HALOPESA = "HALOPESA"
|
71
|
+
OTHER = "OTHER"
|
72
|
+
|
73
|
+
def __str__(self) -> str:
|
74
|
+
"""String representation of the payment provider."""
|
75
|
+
return self.value
|
76
|
+
|
77
|
+
|
78
|
+
class PaymentBase(BaseModel):
|
79
|
+
"""Base payment model with common fields."""
|
80
|
+
|
81
|
+
amount: int = Field(..., gt=0, description="Payment amount in smallest currency unit")
|
82
|
+
currency: str = Field("TZS", description="Payment currency code")
|
83
|
+
reference: Optional[str] = Field(None, description="Payment reference number")
|
84
|
+
status: PaymentStatus = Field(PaymentStatus.PENDING, description="Payment status")
|
85
|
+
|
86
|
+
@field_validator("currency")
|
87
|
+
def validate_currency(cls, v: str) -> str:
|
88
|
+
"""Validate currency code."""
|
89
|
+
valid_currencies = ["TZS", "USD", "EUR", "KES", "UGX"]
|
90
|
+
if v.upper() not in valid_currencies:
|
91
|
+
raise ValueError(f"Invalid currency. Must be one of: {', '.join(valid_currencies)}")
|
92
|
+
return v.upper()
|
93
|
+
|
94
|
+
@field_validator("status", mode="before")
|
95
|
+
def validate_status(cls, v: str) -> PaymentStatus:
|
96
|
+
"""Validate and convert payment status."""
|
97
|
+
return PaymentStatus.from_string(v)
|
98
|
+
|
99
|
+
|
100
|
+
class Payment(PaymentBase):
|
101
|
+
"""Complete payment model with all fields."""
|
102
|
+
|
103
|
+
# Order relationship
|
104
|
+
order_id: str = Field(..., description="Associated order ID")
|
105
|
+
|
106
|
+
# Payment details
|
107
|
+
method: Optional[PaymentMethod] = Field(None, description="Payment method used")
|
108
|
+
provider: Optional[PaymentProvider] = Field(None, description="Payment provider")
|
109
|
+
provider_reference: Optional[str] = Field(None, description="Provider's reference number")
|
110
|
+
|
111
|
+
# Customer details
|
112
|
+
customer_phone: Optional[str] = Field(None, description="Customer phone number")
|
113
|
+
customer_email: Optional[str] = Field(None, description="Customer email address")
|
114
|
+
|
115
|
+
# Transaction details
|
116
|
+
description: Optional[str] = Field(None, description="Payment description")
|
117
|
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional payment metadata")
|
118
|
+
|
119
|
+
# Fees and charges
|
120
|
+
fee_amount: Optional[int] = Field(None, description="Transaction fee amount")
|
121
|
+
net_amount: Optional[int] = Field(None, description="Net amount after fees")
|
122
|
+
|
123
|
+
# Timestamps
|
124
|
+
initiated_at: Optional[datetime] = Field(None, description="Payment initiation time")
|
125
|
+
completed_at: Optional[datetime] = Field(None, description="Payment completion time")
|
126
|
+
failed_at: Optional[datetime] = Field(None, description="Payment failure time")
|
127
|
+
expires_at: Optional[datetime] = Field(None, description="Payment expiration time")
|
128
|
+
|
129
|
+
# Error information
|
130
|
+
error_code: Optional[str] = Field(None, description="Error code if payment failed")
|
131
|
+
error_message: Optional[str] = Field(None, description="Error message if payment failed")
|
132
|
+
|
133
|
+
@property
|
134
|
+
def is_pending(self) -> bool:
|
135
|
+
"""Check if payment is pending."""
|
136
|
+
return self.status == PaymentStatus.PENDING
|
137
|
+
|
138
|
+
@property
|
139
|
+
def is_completed(self) -> bool:
|
140
|
+
"""Check if payment is completed."""
|
141
|
+
return self.status == PaymentStatus.COMPLETED
|
142
|
+
|
143
|
+
@property
|
144
|
+
def is_failed(self) -> bool:
|
145
|
+
"""Check if payment failed."""
|
146
|
+
return self.status == PaymentStatus.FAILED
|
147
|
+
|
148
|
+
@property
|
149
|
+
def is_cancelled(self) -> bool:
|
150
|
+
"""Check if payment was cancelled."""
|
151
|
+
return self.status == PaymentStatus.CANCELLED
|
152
|
+
|
153
|
+
@property
|
154
|
+
def is_final(self) -> bool:
|
155
|
+
"""Check if payment is in a final state."""
|
156
|
+
return self.status.is_final
|
157
|
+
|
158
|
+
@property
|
159
|
+
def formatted_amount(self) -> str:
|
160
|
+
"""Get formatted amount string."""
|
161
|
+
if self.currency == "TZS":
|
162
|
+
return f"{self.amount:,} {self.currency}"
|
163
|
+
else:
|
164
|
+
amount_decimal = self.amount / 100
|
165
|
+
return f"{amount_decimal:.2f} {self.currency}"
|
166
|
+
|
167
|
+
@property
|
168
|
+
def formatted_net_amount(self) -> str:
|
169
|
+
"""Get formatted net amount string."""
|
170
|
+
if self.net_amount is None:
|
171
|
+
return self.formatted_amount
|
172
|
+
|
173
|
+
if self.currency == "TZS":
|
174
|
+
return f"{self.net_amount:,} {self.currency}"
|
175
|
+
else:
|
176
|
+
net_decimal = self.net_amount / 100
|
177
|
+
return f"{net_decimal:.2f} {self.currency}"
|
178
|
+
|
179
|
+
def get_metadata_value(self, key: str, default: Any = None) -> Any:
|
180
|
+
"""Get a specific value from the metadata."""
|
181
|
+
if self.metadata:
|
182
|
+
return self.metadata.get(key, default)
|
183
|
+
return default
|
184
|
+
|
185
|
+
def calculate_fee_percentage(self) -> Optional[float]:
|
186
|
+
"""Calculate fee as percentage of total amount."""
|
187
|
+
if self.fee_amount is None or self.amount <= 0:
|
188
|
+
return None
|
189
|
+
return (self.fee_amount / self.amount) * 100
|
190
|
+
|
191
|
+
model_config = ConfigDict(
|
192
|
+
json_schema_extra={
|
193
|
+
"example": {
|
194
|
+
"id": "pay_677e43274d7cb",
|
195
|
+
"order_id": "ord_677e43274d7cb",
|
196
|
+
"amount": 1000,
|
197
|
+
"currency": "TZS",
|
198
|
+
"status": "COMPLETED",
|
199
|
+
"reference": "1003020496",
|
200
|
+
"method": "USSD",
|
201
|
+
"provider": "VODACOM",
|
202
|
+
"provider_reference": "MP240615001",
|
203
|
+
"customer_phone": "0652449389",
|
204
|
+
"customer_email": "jackson@gmail.com",
|
205
|
+
"description": "Payment for Order #12345",
|
206
|
+
"fee_amount": 50,
|
207
|
+
"net_amount": 950,
|
208
|
+
"initiated_at": "2025-06-15T10:00:00Z",
|
209
|
+
"completed_at": "2025-06-15T10:05:00Z",
|
210
|
+
"created_at": "2025-06-15T10:00:00Z",
|
211
|
+
"updated_at": "2025-06-15T10:05:00Z",
|
212
|
+
"metadata": {"product_id": "12345", "campaign": "summer_sale"},
|
213
|
+
}
|
214
|
+
}
|
215
|
+
)
|
216
|
+
|
217
|
+
|
218
|
+
class PaymentCreate(PaymentBase):
|
219
|
+
"""Model for creating a new payment."""
|
220
|
+
|
221
|
+
order_id: str = Field(..., description="Associated order ID")
|
222
|
+
method: Optional[PaymentMethod] = Field(None, description="Preferred payment method")
|
223
|
+
provider: Optional[PaymentProvider] = Field(None, description="Preferred payment provider")
|
224
|
+
customer_phone: Optional[str] = Field(None, description="Customer phone number")
|
225
|
+
description: Optional[str] = Field(None, description="Payment description")
|
226
|
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional payment metadata")
|
227
|
+
|
228
|
+
@field_validator("customer_phone")
|
229
|
+
def validate_phone(cls, v: Optional[str]) -> Optional[str]:
|
230
|
+
"""Validate phone number format."""
|
231
|
+
if v is not None:
|
232
|
+
# Remove any non-digit characters except +
|
233
|
+
cleaned = "".join(c for c in v if c.isdigit() or c == "+")
|
234
|
+
if len(cleaned) < 10:
|
235
|
+
raise ValueError("Phone number must be at least 10 digits")
|
236
|
+
return cleaned
|
237
|
+
return v
|
238
|
+
|
239
|
+
model_config = ConfigDict(
|
240
|
+
json_schema_extra={
|
241
|
+
"example": {
|
242
|
+
"order_id": "ord_677e43274d7cb",
|
243
|
+
"amount": 1000,
|
244
|
+
"currency": "TZS",
|
245
|
+
"method": "USSD",
|
246
|
+
"provider": "VODACOM",
|
247
|
+
"customer_phone": "0652449389",
|
248
|
+
"description": "Payment for Order #12345",
|
249
|
+
"metadata": {"product_id": "12345", "campaign": "summer_sale"},
|
250
|
+
}
|
251
|
+
}
|
252
|
+
)
|
253
|
+
|
254
|
+
|
255
|
+
class PaymentSearch(BaseModel):
|
256
|
+
"""Model for searching payments."""
|
257
|
+
|
258
|
+
order_id: Optional[str] = Field(None, description="Filter by order ID")
|
259
|
+
status: Optional[PaymentStatus] = Field(None, description="Filter by payment status")
|
260
|
+
method: Optional[PaymentMethod] = Field(None, description="Filter by payment method")
|
261
|
+
provider: Optional[PaymentProvider] = Field(None, description="Filter by payment provider")
|
262
|
+
reference: Optional[str] = Field(None, description="Filter by payment reference")
|
263
|
+
customer_phone: Optional[str] = Field(None, description="Filter by customer phone")
|
264
|
+
date_from: Optional[Union[datetime, str]] = Field(None, description="Filter from date")
|
265
|
+
date_to: Optional[Union[datetime, str]] = Field(None, description="Filter to date")
|
266
|
+
limit: Optional[int] = Field(50, ge=1, le=100, description="Number of results to return")
|
267
|
+
|
268
|
+
@field_validator("date_from", mode="before")
|
269
|
+
def parse_date_from(cls, v: Union[str, datetime, None]) -> Union[datetime, None]:
|
270
|
+
"""Parse string dates to datetime objects."""
|
271
|
+
if isinstance(v, str):
|
272
|
+
try:
|
273
|
+
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
274
|
+
except ValueError:
|
275
|
+
try:
|
276
|
+
return datetime.strptime(v, "%Y-%m-%d")
|
277
|
+
except ValueError:
|
278
|
+
raise ValueError(f"Invalid date format: {v}")
|
279
|
+
return v
|
280
|
+
|
281
|
+
@field_validator("date_to", mode="before")
|
282
|
+
def parse_date_to(cls, v: Union[str, datetime, None]) -> Union[datetime, None]:
|
283
|
+
"""Parse string dates to datetime objects."""
|
284
|
+
if isinstance(v, str):
|
285
|
+
try:
|
286
|
+
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
287
|
+
except ValueError:
|
288
|
+
try:
|
289
|
+
return datetime.strptime(v, "%Y-%m-%d")
|
290
|
+
except ValueError:
|
291
|
+
raise ValueError(f"Invalid date format: {v}")
|
292
|
+
return v
|
293
|
+
|
294
|
+
model_config = ConfigDict(
|
295
|
+
json_schema_extra={
|
296
|
+
"example": {
|
297
|
+
"status": "COMPLETED",
|
298
|
+
"method": "USSD",
|
299
|
+
"date_from": "2025-06-01",
|
300
|
+
"date_to": "2025-06-15",
|
301
|
+
"limit": 50,
|
302
|
+
}
|
303
|
+
}
|
304
|
+
)
|
@@ -0,0 +1,122 @@
|
|
1
|
+
"""Webhook-related models for the ZenoPay SDK."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from typing import Any, Dict, Optional, Union
|
5
|
+
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
7
|
+
|
8
|
+
|
9
|
+
class WebhookPayload(BaseModel):
|
10
|
+
"""Model for webhook payload from ZenoPay."""
|
11
|
+
|
12
|
+
order_id: str = Field(..., description="Order ID")
|
13
|
+
payment_status: str = Field(..., description="Payment status")
|
14
|
+
reference: Optional[str] = Field(None, description="Payment reference number")
|
15
|
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Order metadata")
|
16
|
+
|
17
|
+
@field_validator("payment_status")
|
18
|
+
def validate_payment_status(cls, v: str) -> str:
|
19
|
+
"""Validate payment status."""
|
20
|
+
valid_statuses = ["PENDING", "COMPLETED", "FAILED", "CANCELLED"]
|
21
|
+
if v not in valid_statuses:
|
22
|
+
raise ValueError(f"Invalid payment status: {v}")
|
23
|
+
return v
|
24
|
+
|
25
|
+
@field_validator("metadata")
|
26
|
+
def parse_metadata(cls, v: Union[str, Dict[str, Any], None]) -> Optional[Dict[str, Any]]:
|
27
|
+
"""Parse metadata from JSON string if necessary."""
|
28
|
+
if isinstance(v, str):
|
29
|
+
try:
|
30
|
+
return json.loads(v)
|
31
|
+
except (json.JSONDecodeError, TypeError):
|
32
|
+
return None
|
33
|
+
return v
|
34
|
+
|
35
|
+
@property
|
36
|
+
def is_completed(self) -> bool:
|
37
|
+
"""Check if payment is completed."""
|
38
|
+
return self.payment_status == "COMPLETED"
|
39
|
+
|
40
|
+
@property
|
41
|
+
def is_failed(self) -> bool:
|
42
|
+
"""Check if payment failed."""
|
43
|
+
return self.payment_status == "FAILED"
|
44
|
+
|
45
|
+
def get_metadata_value(self, key: str, default: Any = None) -> Any:
|
46
|
+
"""Get a specific value from the metadata."""
|
47
|
+
if self.metadata:
|
48
|
+
return self.metadata.get(key, default)
|
49
|
+
return default
|
50
|
+
|
51
|
+
model_config = ConfigDict(
|
52
|
+
json_schema_extra={
|
53
|
+
"example": {
|
54
|
+
"order_id": "677e43274d7cb",
|
55
|
+
"payment_status": "COMPLETED",
|
56
|
+
"reference": "1003020496",
|
57
|
+
"metadata": {
|
58
|
+
"product_id": "12345",
|
59
|
+
"color": "blue",
|
60
|
+
"size": "L",
|
61
|
+
"custom_notes": "Please gift-wrap this item.",
|
62
|
+
},
|
63
|
+
}
|
64
|
+
}
|
65
|
+
)
|
66
|
+
|
67
|
+
|
68
|
+
class WebhookEvent(BaseModel):
|
69
|
+
"""Model for complete webhook event information."""
|
70
|
+
|
71
|
+
payload: WebhookPayload = Field(..., description="Webhook payload data")
|
72
|
+
raw_data: str = Field(..., description="Raw webhook data received")
|
73
|
+
timestamp: Optional[str] = Field(None, description="Event timestamp")
|
74
|
+
signature: Optional[str] = Field(None, description="Webhook signature for verification")
|
75
|
+
|
76
|
+
@classmethod
|
77
|
+
def from_raw_data(cls, raw_data: str) -> "WebhookEvent":
|
78
|
+
"""Create WebhookEvent from raw webhook data.
|
79
|
+
|
80
|
+
Args:
|
81
|
+
raw_data: Raw JSON string from webhook.
|
82
|
+
|
83
|
+
Returns:
|
84
|
+
WebhookEvent instance.
|
85
|
+
|
86
|
+
Raises:
|
87
|
+
ValueError: If the raw data cannot be parsed.
|
88
|
+
"""
|
89
|
+
try:
|
90
|
+
payload_data = json.loads(raw_data)
|
91
|
+
payload = WebhookPayload.model_validate(payload_data)
|
92
|
+
|
93
|
+
return cls(payload=payload, raw_data=raw_data, timestamp=None, signature=None)
|
94
|
+
except (json.JSONDecodeError, ValueError) as e:
|
95
|
+
raise ValueError(f"Invalid webhook data: {e}")
|
96
|
+
|
97
|
+
def to_dict(self) -> Dict[str, Any]:
|
98
|
+
"""Convert to dictionary for easy processing."""
|
99
|
+
return {
|
100
|
+
"order_id": self.payload.order_id,
|
101
|
+
"payment_status": self.payload.payment_status,
|
102
|
+
"reference": self.payload.reference,
|
103
|
+
"metadata": self.payload.metadata,
|
104
|
+
"raw_data": self.raw_data,
|
105
|
+
"timestamp": self.timestamp,
|
106
|
+
}
|
107
|
+
|
108
|
+
|
109
|
+
class WebhookResponse(BaseModel):
|
110
|
+
"""Model for webhook response."""
|
111
|
+
|
112
|
+
status: str = Field("success", description="Response status")
|
113
|
+
message: str = Field("Webhook received", description="Response message")
|
114
|
+
|
115
|
+
model_config = ConfigDict(
|
116
|
+
json_schema_extra={
|
117
|
+
"example": {
|
118
|
+
"status": "success",
|
119
|
+
"message": "Webhook received and processed",
|
120
|
+
}
|
121
|
+
}
|
122
|
+
)
|