|
4 | 4 |
|
5 | 5 | import hmac |
6 | 6 | import hashlib |
| 7 | +import json |
7 | 8 | from typing import ClassVar, List, Dict, Optional, Any |
| 9 | +from datetime import datetime |
8 | 10 |
|
9 | 11 | from easyswitch.adapters.base import AdaptersRegistry, BaseAdapter |
10 | | -from easyswitch.types import (Currency, PaymentResponse, |
11 | | - TransactionDetail,TransactionStatusResponse,) |
| 12 | +from easyswitch.types import (Currency, PaymentResponse, WebhookEvent, |
| 13 | + TransactionDetail,TransactionStatusResponse, |
| 14 | + CustomerInfo, TransactionStatus) |
12 | 15 | from easyswitch.exceptions import PaymentError,UnsupportedOperationError |
13 | 16 |
|
14 | 17 |
|
@@ -51,45 +54,77 @@ def get_headers(self, authorization=True, **kwargs) -> Dict[str, str]: |
51 | 54 | if authorization: |
52 | 55 | headers["Authorization"] = f"Bearer {self.config.api_key}" |
53 | 56 | return headers |
| 57 | + |
| 58 | + def get_normalize_status(self, status: str) -> TransactionStatus: |
| 59 | + """Normalize Paystack transaction status.""" |
| 60 | + mapping = { |
| 61 | + "success": TransactionStatus.SUCCESSFUL, |
| 62 | + "failed": TransactionStatus.FAILED, |
| 63 | + "abandoned": TransactionStatus.CANCELLED, |
| 64 | + "pending": TransactionStatus.PENDING, |
| 65 | + "refund": TransactionStatus.REFUNDED, |
| 66 | + } |
| 67 | + return mapping.get(status.lower(), TransactionStatus.UNKNOWN) |
54 | 68 |
|
55 | 69 | # validate_webhook expects raw_body: bytes |
56 | 70 | def validate_webhook(self, raw_body: bytes, headers: Dict[str, str]) -> bool: |
| 71 | + """Validate the authenticity of a Paystack webhook.""" |
57 | 72 | signature = headers.get("x-paystack-signature") |
58 | 73 | secret_key = getattr(self.config, "api_key", None) |
59 | 74 | if not signature or not secret_key: |
60 | 75 | return False |
61 | 76 |
|
62 | 77 | computed_sig = hmac.new(secret_key.encode("utf-8"), msg=raw_body, digestmod=hashlib.sha512).hexdigest() |
63 | 78 | return hmac.compare_digest(computed_sig, signature) |
| 79 | + |
| 80 | + def parse_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> WebhookEvent: |
| 81 | + """Parse and validate a Paystack webhook.""" |
64 | 82 |
|
| 83 | + # Convert payload to bytes for validation |
| 84 | + raw_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") |
| 85 | + |
| 86 | + if not self.validate_webhook(raw_body, headers): |
| 87 | + raise PaymentError("Invalid webhook signature", raw_response=payload) |
65 | 88 |
|
66 | | - def parse_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]: |
67 | | - """Parse Paystack webhook into a normalized format.""" |
68 | 89 | data = payload.get("data", {}) |
69 | | - event = payload.get("event") |
70 | | - |
71 | | - return { |
72 | | - "transaction_id": data.get("reference"), |
73 | | - "provider": self.provider_name(), |
74 | | - "status": self.get_normalize_status(data.get("status")), |
75 | | - "event": event, |
76 | | - "amount": data.get("amount", 0) / 100, |
77 | | - "currency": data.get("currency", "NGN"), |
| 90 | + event_type = payload.get("event", "unknown_event") |
| 91 | + transaction_id = data.get("reference") |
| 92 | + status = self.get_normalize_status(data.get("status")) |
| 93 | + amount = (data.get("amount") or 0) / 100 |
| 94 | + currency = data.get("currency", "NGN") |
| 95 | + metadata = data.get("metadata", {}) or {} |
| 96 | + context = { |
78 | 97 | "customer_email": data.get("customer", {}).get("email"), |
79 | | - "raw_data": payload, |
| 98 | + "authorization": data.get("authorization", {}), |
80 | 99 | } |
81 | | - |
82 | | - async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse: |
83 | | - """Send a payment initialization request to Paystack.""" |
84 | | - self.validate_transaction(transaction) |
85 | 100 |
|
86 | | - payload = { |
87 | | - "amount": int(transaction.amount * 100), # Paystack expects smallest unit |
| 101 | + return WebhookEvent( |
| 102 | + event_type=event_type, |
| 103 | + provider=self.provider_name(), |
| 104 | + transaction_id=transaction_id, |
| 105 | + status=status, |
| 106 | + amount=amount, |
| 107 | + currency=currency, |
| 108 | + created_at=datetime.fromtimestamp(data.get("createdAt") / 1000) if data.get("createdAt") else None, |
| 109 | + raw_data=payload, |
| 110 | + metadata=metadata, |
| 111 | + context=context, |
| 112 | + ) |
| 113 | + |
| 114 | + def format_transaction(self, transaction: TransactionDetail) -> Dict[str, Any]: |
| 115 | + """Convert standardized TransactionDetail into Paystack-specific payload.""" |
| 116 | + self.validate_transaction(transaction) |
| 117 | + return { |
| 118 | + "amount": int(transaction.amount * 100), # Paystack expects kobo |
88 | 119 | "email": transaction.customer.email, |
89 | 120 | "reference": transaction.reference, |
90 | 121 | "callback_url": transaction.callback_url or self.config.callback_url, |
91 | 122 | "metadata": transaction.metadata or {}, |
92 | 123 | } |
| 124 | + |
| 125 | + async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse: |
| 126 | + """Send a payment initialization request to Paystack.""" |
| 127 | + payload = self.format_transaction(transaction) |
93 | 128 |
|
94 | 129 | async with self.get_client() as client: |
95 | 130 | response = await client.post( |
@@ -181,23 +216,37 @@ async def refund(self, transaction_id: str, amount: Optional[float] = None) -> P |
181 | 216 | raw_response=data, |
182 | 217 | ) |
183 | 218 |
|
184 | | - async def get_transaction_detail(self, transaction_id: str) -> PaymentResponse: |
| 219 | + async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail: |
185 | 220 | """Retrieve transaction details from Paystack by transaction ID.""" |
186 | 221 | async with self.get_client() as client: |
187 | 222 | response = await client.get(f"/transaction/{transaction_id}", headers=self.get_headers()) |
188 | 223 |
|
189 | 224 | data = response.json() if hasattr(response, "json") else response.data |
190 | 225 | if response.status in range(200, 300) and data.get("status"): |
191 | 226 | tx = data.get("data", {}) |
192 | | - return PaymentResponse( |
| 227 | + |
| 228 | + customer = CustomerInfo( |
| 229 | + email=tx.get("customer", {}).get("email"), |
| 230 | + phone_number=tx.get("customer", {}).get("phone"), |
| 231 | + first_name=tx.get("customer", {}).get("first_name"), |
| 232 | + last_name=tx.get("customer", {}).get("last_name"), |
| 233 | + metadata=tx.get("customer", {}).get("metadata", {}), |
| 234 | + ) |
| 235 | + |
| 236 | + return TransactionDetail( |
193 | 237 | transaction_id=str(tx.get("id", transaction_id)), |
194 | | - reference=tx.get("reference"), |
195 | 238 | provider=self.provider_name(), |
196 | | - status=self.get_normalize_status(tx.get("status")), |
197 | | - amount=tx.get("amount", 0) / 100, |
| 239 | + amount=(tx.get("amount") or 0) / 100, |
198 | 240 | currency=tx.get("currency", "NGN"), |
| 241 | + status=self.get_normalize_status(tx.get("status")), |
| 242 | + reference=tx.get("reference"), |
| 243 | + callback_url=tx.get("callback_url"), |
| 244 | + created_at=datetime.fromtimestamp(tx.get("createdAt") / 1000) if tx.get("createdAt") else datetime.now(), |
| 245 | + updated_at=datetime.fromtimestamp(tx.get("updatedAt") / 1000) if tx.get("updatedAt") else None, |
| 246 | + completed_at=datetime.fromtimestamp(tx.get("paidAt") / 1000) if tx.get("paidAt") else None, |
| 247 | + customer=customer, |
199 | 248 | metadata=tx.get("metadata", {}), |
200 | | - raw_response=tx |
| 249 | + raw_data=tx |
201 | 250 | ) |
202 | 251 |
|
203 | 252 | raise PaymentError( |
|
0 commit comments