Skip to content

Commit d74e861

Browse files
authored
feat: add PaystackAdapter with payment, webhook, and refund support
feat: add PaystackAdapter with payment, webhook, and refund support
2 parents 2b172cf + 1e11f77 commit d74e861

File tree

2 files changed

+380
-0
lines changed

2 files changed

+380
-0
lines changed

easyswitch/integrators/paystack.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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+
)

tests/test_paystack.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
import json
4+
import hmac
5+
import hashlib
6+
7+
from easyswitch.integrators.paystack import PaystackAdapter
8+
from easyswitch.types import TransactionDetail, Currency
9+
10+
11+
@pytest.fixture
12+
def adapter():
13+
class DummyPaystackAdapter(PaystackAdapter):
14+
def format_transaction(self, x): return x
15+
def get_normalize_status(self, status): return status
16+
17+
return DummyPaystackAdapter(
18+
config=MagicMock(api_key="test_key", callback_url="https://callback.url"),
19+
context={} # <-- provide a dict so .get() won't fail
20+
)
21+
22+
23+
# small helper that acts as an async context manager returning the provided object
24+
class _AsyncCtx:
25+
def __init__(self, obj):
26+
self._obj = obj
27+
28+
async def __aenter__(self):
29+
return self._obj
30+
31+
async def __aexit__(self, exc_type, exc, tb):
32+
return False
33+
34+
35+
@pytest.mark.asyncio
36+
async def test_validate_webhook_valid_signature(adapter):
37+
"""Should return True for valid webhook signature."""
38+
payload = {"event": "charge.success", "data": {"id": 123}}
39+
# create the exact byte representation used for signing
40+
raw_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
41+
sig = hmac.new(
42+
b"test_key",
43+
msg=raw_body,
44+
digestmod=hashlib.sha512
45+
).hexdigest()
46+
headers = {"x-paystack-signature": sig}
47+
48+
result = adapter.validate_webhook(raw_body, headers)
49+
assert result is True
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_validate_webhook_invalid_signature(adapter):
54+
"""Should return False for invalid signature."""
55+
payload = {"event": "charge.success"}
56+
raw_body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
57+
headers = {"x-paystack-signature": "invalid"}
58+
59+
result = adapter.validate_webhook(raw_body, headers)
60+
assert result is False
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_send_payment_success(adapter):
65+
"""Should return PaymentResponse on successful init."""
66+
transaction = TransactionDetail(
67+
transaction_id="tx_1",
68+
amount=500.0,
69+
currency=Currency.NGN,
70+
customer=MagicMock(email="user@example.com", phone_number="+2348012345678"),
71+
reference="ref123",
72+
callback_url="https://callback.url",
73+
provider=adapter.provider_name(),
74+
)
75+
76+
# Mock client and response
77+
mock_client = AsyncMock()
78+
mock_response = MagicMock()
79+
mock_response.status = 200
80+
mock_response.json.return_value = {
81+
"status": True,
82+
"data": {
83+
"reference": "ref123",
84+
"authorization_url": "https://paystack.com/pay/ref123",
85+
"access_code": "AC_123"
86+
}
87+
}
88+
mock_client.post.return_value = mock_response
89+
90+
# make get_client() return an async context manager that yields mock_client
91+
adapter.get_client = lambda: _AsyncCtx(mock_client)
92+
93+
response = await adapter.send_payment(transaction)
94+
95+
assert response.reference == "ref123"
96+
assert response.payment_link == "https://paystack.com/pay/ref123"
97+
assert response.status == "pending"
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_check_status_success(adapter):
102+
"""Should return TransactionStatusResponse on success."""
103+
mock_client = AsyncMock()
104+
mock_response = MagicMock()
105+
mock_response.status = 200
106+
mock_response.json.return_value = {
107+
"status": True,
108+
"data": {"id": 1, "status": "success", "amount": 10000, "reference": "ref_123"}
109+
}
110+
mock_client.get.return_value = mock_response
111+
112+
adapter.get_client = lambda: _AsyncCtx(mock_client)
113+
114+
result = await adapter.check_status("ref_123")
115+
116+
assert result.status == "success"
117+
assert result.amount == 100.0 # since /100
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_cancel_transaction_raises(adapter):
122+
"""Paystack does not support cancel; should raise."""
123+
with pytest.raises(Exception):
124+
await adapter.cancel_transaction("tx_1")

0 commit comments

Comments
 (0)