Skip to content

Commit 1e11f77

Browse files
committed
refactor: update webhook parsing, transaction detail, and status normalization
1 parent 4f53b24 commit 1e11f77

1 file changed

Lines changed: 75 additions & 26 deletions

File tree

easyswitch/integrators/paystack.py

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
import hmac
66
import hashlib
7+
import json
78
from typing import ClassVar, List, Dict, Optional, Any
9+
from datetime import datetime
810

911
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)
1215
from easyswitch.exceptions import PaymentError,UnsupportedOperationError
1316

1417

@@ -51,45 +54,77 @@ def get_headers(self, authorization=True, **kwargs) -> Dict[str, str]:
5154
if authorization:
5255
headers["Authorization"] = f"Bearer {self.config.api_key}"
5356
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)
5468

5569
# validate_webhook expects raw_body: bytes
5670
def validate_webhook(self, raw_body: bytes, headers: Dict[str, str]) -> bool:
71+
"""Validate the authenticity of a Paystack webhook."""
5772
signature = headers.get("x-paystack-signature")
5873
secret_key = getattr(self.config, "api_key", None)
5974
if not signature or not secret_key:
6075
return False
6176

6277
computed_sig = hmac.new(secret_key.encode("utf-8"), msg=raw_body, digestmod=hashlib.sha512).hexdigest()
6378
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."""
6482

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)
6588

66-
def parse_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]:
67-
"""Parse Paystack webhook into a normalized format."""
6889
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 = {
7897
"customer_email": data.get("customer", {}).get("email"),
79-
"raw_data": payload,
98+
"authorization": data.get("authorization", {}),
8099
}
81-
82-
async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse:
83-
"""Send a payment initialization request to Paystack."""
84-
self.validate_transaction(transaction)
85100

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
88119
"email": transaction.customer.email,
89120
"reference": transaction.reference,
90121
"callback_url": transaction.callback_url or self.config.callback_url,
91122
"metadata": transaction.metadata or {},
92123
}
124+
125+
async def send_payment(self, transaction: TransactionDetail) -> PaymentResponse:
126+
"""Send a payment initialization request to Paystack."""
127+
payload = self.format_transaction(transaction)
93128

94129
async with self.get_client() as client:
95130
response = await client.post(
@@ -181,23 +216,37 @@ async def refund(self, transaction_id: str, amount: Optional[float] = None) -> P
181216
raw_response=data,
182217
)
183218

184-
async def get_transaction_detail(self, transaction_id: str) -> PaymentResponse:
219+
async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail:
185220
"""Retrieve transaction details from Paystack by transaction ID."""
186221
async with self.get_client() as client:
187222
response = await client.get(f"/transaction/{transaction_id}", headers=self.get_headers())
188223

189224
data = response.json() if hasattr(response, "json") else response.data
190225
if response.status in range(200, 300) and data.get("status"):
191226
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(
193237
transaction_id=str(tx.get("id", transaction_id)),
194-
reference=tx.get("reference"),
195238
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,
198240
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,
199248
metadata=tx.get("metadata", {}),
200-
raw_response=tx
249+
raw_data=tx
201250
)
202251

203252
raise PaymentError(

0 commit comments

Comments
 (0)