|
| 1 | +""" |
| 2 | +EasySwitch - Paystack Integrator |
| 3 | +""" |
| 4 | + |
| 5 | +import hmac |
| 6 | +import hashlib |
| 7 | +import json |
| 8 | +from typing import ClassVar, List, Dict, Optional, Any |
| 9 | +from datetime import datetime |
| 10 | + |
| 11 | +from easyswitch.adapters.base import AdaptersRegistry, BaseAdapter |
| 12 | +from easyswitch.types import (Currency, PaymentResponse, WebhookEvent, |
| 13 | + TransactionDetail,TransactionStatusResponse, |
| 14 | + CustomerInfo, TransactionStatus) |
| 15 | +from easyswitch.exceptions import PaymentError,UnsupportedOperationError |
| 16 | + |
| 17 | + |
| 18 | +@AdaptersRegistry.register() |
| 19 | +class PaystackAdapter(BaseAdapter): |
| 20 | + """Paystack Adapter for EasySwitch SDK.""" |
| 21 | + |
| 22 | + SANDBOX_URL: str = "https://api.paystack.co" |
| 23 | + PRODUCTION_URL: str = "https://api.paystack.co" |
| 24 | + |
| 25 | + SUPPORTED_CURRENCIES: ClassVar[List[Currency]] = [ |
| 26 | + Currency.NGN, |
| 27 | + Currency.GHS, |
| 28 | + Currency.USD, |
| 29 | + ] |
| 30 | + |
| 31 | + MIN_AMOUNT: ClassVar[Dict[Currency, float]] = { |
| 32 | + Currency.NGN: 50.0, # 50.00 NGN (main units) |
| 33 | + Currency.GHS: 0.10, # 0.10 GHS |
| 34 | + Currency.USD: 2.0, # 2.00 USD |
| 35 | + } |
| 36 | + |
| 37 | + MAX_AMOUNT: ClassVar[Dict[Currency, float]] = { |
| 38 | + Currency.NGN: 10_000_000, |
| 39 | + Currency.GHS: 10_000_000, |
| 40 | + Currency.USD: 10_000_000, |
| 41 | + } |
| 42 | + |
| 43 | + def validate_credentials(self) -> bool: |
| 44 | + """Validate the credentials for Paystack.""" |
| 45 | + return bool(self.config.api_key) |
| 46 | + |
| 47 | + def get_credentials(self): |
| 48 | + """Return API credentials.""" |
| 49 | + return {"api_key": self.config.api_key} |
| 50 | + |
| 51 | + def get_headers(self, authorization=True, **kwargs) -> Dict[str, str]: |
| 52 | + """Return headers for Paystack requests.""" |
| 53 | + headers = {"Content-Type": "application/json"} |
| 54 | + if authorization: |
| 55 | + headers["Authorization"] = f"Bearer {self.config.api_key}" |
| 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) |
| 68 | + |
| 69 | + # validate_webhook expects raw_body: bytes |
| 70 | + def validate_webhook(self, raw_body: bytes, headers: Dict[str, str]) -> bool: |
| 71 | + """Validate the authenticity of a Paystack webhook.""" |
| 72 | + signature = headers.get("x-paystack-signature") |
| 73 | + secret_key = getattr(self.config, "api_key", None) |
| 74 | + if not signature or not secret_key: |
| 75 | + return False |
| 76 | + |
| 77 | + computed_sig = hmac.new(secret_key.encode("utf-8"), msg=raw_body, digestmod=hashlib.sha512).hexdigest() |
| 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.""" |
| 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) |
| 88 | + |
| 89 | + data = payload.get("data", {}) |
| 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 = { |
| 97 | + "customer_email": data.get("customer", {}).get("email"), |
| 98 | + "authorization": data.get("authorization", {}), |
| 99 | + } |
| 100 | + |
| 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 |
| 119 | + "email": transaction.customer.email, |
| 120 | + "reference": transaction.reference, |
| 121 | + "callback_url": transaction.callback_url or self.config.callback_url, |
| 122 | + "metadata": transaction.metadata or {}, |
| 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) |
| 128 | + |
| 129 | + async with self.get_client() as client: |
| 130 | + response = await client.post( |
| 131 | + "/transaction/initialize", |
| 132 | + json=payload, |
| 133 | + headers=self.get_headers() |
| 134 | + ) |
| 135 | + |
| 136 | + data = response.json() if hasattr(response, "json") else response.data |
| 137 | + if response.status in range(200, 300) and data.get("status"): |
| 138 | + init_data = data.get("data", {}) |
| 139 | + return PaymentResponse( |
| 140 | + transaction_id=transaction.transaction_id, |
| 141 | + reference=init_data.get("reference"), |
| 142 | + provider=self.provider_name(), |
| 143 | + status="pending", |
| 144 | + amount=transaction.amount, |
| 145 | + currency=transaction.currency, |
| 146 | + payment_link=init_data.get("authorization_url"), |
| 147 | + transaction_token=init_data.get("access_code"), |
| 148 | + metadata=init_data, |
| 149 | + raw_response=data, |
| 150 | + ) |
| 151 | + |
| 152 | + raise PaymentError( |
| 153 | + message=f"Payment request failed with status {response.status}", |
| 154 | + status_code=response.status, |
| 155 | + raw_response=data, |
| 156 | + ) |
| 157 | + |
| 158 | + async def check_status(self, reference: str) -> TransactionStatusResponse: |
| 159 | + """Check the status of a Paystack transaction by reference.""" |
| 160 | + async with self.get_client() as client: |
| 161 | + response = await client.get( |
| 162 | + f"/transaction/verify/{reference}", |
| 163 | + headers=self.get_headers() |
| 164 | + ) |
| 165 | + |
| 166 | + data = response.json() if hasattr(response, "json") else response.data |
| 167 | + if not data.get("status"): |
| 168 | + raise PaymentError( |
| 169 | + message="Failed to verify Paystack transaction", |
| 170 | + raw_response=data |
| 171 | + ) |
| 172 | + |
| 173 | + tx = data.get("data", {}) |
| 174 | + return TransactionStatusResponse( |
| 175 | + transaction_id=tx.get("id"), |
| 176 | + provider=self.provider_name(), |
| 177 | + status=self.get_normalize_status(tx.get("status")), |
| 178 | + amount=(tx.get("amount") or 0) / 100, |
| 179 | + data=tx, |
| 180 | + ) |
| 181 | + |
| 182 | + async def cancel_transaction(self, transaction_id: str) -> None: |
| 183 | + """Paystack does not support transaction cancellation. |
| 184 | + Use refund() for post-payment reversals. |
| 185 | + """ |
| 186 | + raise UnsupportedOperationError( |
| 187 | + self.provider_name(), |
| 188 | + ) |
| 189 | + |
| 190 | + async def refund(self, transaction_id: str, amount: Optional[float] = None) -> PaymentResponse: |
| 191 | + """Refund a Paystack transaction.""" |
| 192 | + async with self.get_client() as client: |
| 193 | + payload = {"transaction": transaction_id} |
| 194 | + if amount: |
| 195 | + payload["amount"] = int(amount * 100) |
| 196 | + |
| 197 | + response = await client.post("/refund", json=payload, headers=self.get_headers()) |
| 198 | + |
| 199 | + data = response.json() if hasattr(response, "json") else response.data |
| 200 | + if response.status in range(200, 300) and data.get("status"): |
| 201 | + refund_data = data.get("data", {}) |
| 202 | + return PaymentResponse( |
| 203 | + transaction_id=transaction_id, |
| 204 | + reference=refund_data.get("transaction", {}).get("reference", f"refund-{transaction_id}"), |
| 205 | + provider=self.provider_name(), |
| 206 | + status=self.get_normalize_status(refund_data.get("status")), |
| 207 | + amount=(refund_data.get("amount") or (amount or 0)) / 100, |
| 208 | + currency=refund_data.get("currency", "NGN"), |
| 209 | + metadata=refund_data, |
| 210 | + raw_response=data, |
| 211 | + ) |
| 212 | + |
| 213 | + raise PaymentError( |
| 214 | + message=f"Refund failed with status {response.status}", |
| 215 | + status_code=response.status, |
| 216 | + raw_response=data, |
| 217 | + ) |
| 218 | + |
| 219 | + async def get_transaction_detail(self, transaction_id: str) -> TransactionDetail: |
| 220 | + """Retrieve transaction details from Paystack by transaction ID.""" |
| 221 | + async with self.get_client() as client: |
| 222 | + response = await client.get(f"/transaction/{transaction_id}", headers=self.get_headers()) |
| 223 | + |
| 224 | + data = response.json() if hasattr(response, "json") else response.data |
| 225 | + if response.status in range(200, 300) and data.get("status"): |
| 226 | + tx = data.get("data", {}) |
| 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( |
| 237 | + transaction_id=str(tx.get("id", transaction_id)), |
| 238 | + provider=self.provider_name(), |
| 239 | + amount=(tx.get("amount") or 0) / 100, |
| 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, |
| 248 | + metadata=tx.get("metadata", {}), |
| 249 | + raw_data=tx |
| 250 | + ) |
| 251 | + |
| 252 | + raise PaymentError( |
| 253 | + message=f"Failed to retrieve transaction {transaction_id}", |
| 254 | + status_code=response.status, |
| 255 | + raw_response=data |
| 256 | + ) |
0 commit comments