Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/PRODUCT_PAYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The admin also configures:
- **Default Callback URL** — where users redirect after payment (can be overridden per request)
- **Allowed Currencies** — optional restriction (empty = all supported)
- **Allowed IPs** — optional API call restriction (empty = all)
- **Test Mode** (`is_test`) — when enabled, all payments for this service use Paystack **test credentials** (`PAYSTACK_TEST_SECRET_KEY` / `PAYSTACK_TEST_PUBLIC_KEY`). Live services always use live credentials. The correct credential set is selected automatically on every Paystack API call and webhook signature validation.

---

Expand Down
2 changes: 2 additions & 0 deletions src/apps/payments/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async def post(self, request: HttpRequest) -> JsonResponse:
reference=reference,
currency="KES",
callback_url=paystack_callback,
is_test=request.service.is_test,
metadata={
"payment_id": payment.pk,
"service": request.service.slug,
Expand Down Expand Up @@ -310,6 +311,7 @@ async def post(self, request: HttpRequest, reference: str) -> JsonResponse:
transaction_reference=payment.reference,
amount_kobo=amount_kobo,
reason=reason,
is_test=payment.service.is_test if payment.service else False,
)

if result.get("status"):
Expand Down
72 changes: 49 additions & 23 deletions src/apps/payments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,25 @@
PAYSTACK_API_URL = "https://api.paystack.co"


def get_paystack_secret_key() -> str:
"""Return the Paystack secret key from settings."""
def get_paystack_secret_key(*, is_test: bool = False) -> str:
"""Return the Paystack secret key from settings.

Uses the test key when ``is_test=True`` (for test service products),
otherwise returns the live key.
"""
if is_test:
return getattr(settings, "PAYSTACK_TEST_SECRET_KEY", "")
return getattr(settings, "PAYSTACK_SECRET_KEY", "")


def get_paystack_public_key() -> str:
"""Return the Paystack public key from settings."""
def get_paystack_public_key(*, is_test: bool = False) -> str:
"""Return the Paystack public key from settings.

Uses the test key when ``is_test=True`` (for test service products),
otherwise returns the live key.
"""
if is_test:
return getattr(settings, "PAYSTACK_TEST_PUBLIC_KEY", "")
return getattr(settings, "PAYSTACK_PUBLIC_KEY", "")


Expand All @@ -33,12 +45,13 @@ def _make_paystack_request(
endpoint: str,
method: str = "GET",
data: bytes | None = None,
is_test: bool = False,
) -> dict:
"""Synchronous Paystack API request (for use in executors)."""
import json
from urllib.request import Request, urlopen

secret_key = get_paystack_secret_key()
secret_key = get_paystack_secret_key(is_test=is_test)
headers = {
"Authorization": f"Bearer {secret_key}",
"Content-Type": "application/json",
Expand Down Expand Up @@ -66,6 +79,7 @@ async def initialise_transaction(
currency: str = "KES",
callback_url: str = "",
metadata: dict | None = None,
is_test: bool = False,
) -> dict:
"""
Initialise a Paystack transaction.
Expand All @@ -85,7 +99,7 @@ async def initialise_transaction(
import asyncio
import json

secret_key = get_paystack_secret_key()
secret_key = get_paystack_secret_key(is_test=is_test)
if not secret_key:
logger.warning("Paystack secret key not configured")
return {"status": False, "message": "Payment not configured"}
Expand All @@ -111,14 +125,15 @@ async def initialise_transaction(
endpoint="/transaction/initialize",
method="POST",
data=data,
is_test=is_test,
),
)
except Exception:
logger.exception("Failed to initialise Paystack transaction")
return {"status": False, "message": "Payment initiation failed"}


async def verify_transaction(reference: str) -> dict:
async def verify_transaction(reference: str, *, is_test: bool = False) -> dict:
"""
Verify a Paystack transaction by reference.

Expand All @@ -128,7 +143,7 @@ async def verify_transaction(reference: str) -> dict:
"""
import asyncio

secret_key = get_paystack_secret_key()
secret_key = get_paystack_secret_key(is_test=is_test)
if not secret_key:
return {"status": False, "message": "Payment not configured"}

Expand All @@ -138,6 +153,7 @@ async def verify_transaction(reference: str) -> dict:
None,
lambda: _make_paystack_request(
endpoint=f"/transaction/verify/{reference}",
is_test=is_test,
),
)
except Exception:
Expand All @@ -151,6 +167,7 @@ async def create_refund(
amount_kobo: int | None = None,
reason: str = "",
merchant_note: str = "",
is_test: bool = False,
) -> dict:
"""
Create a refund on Paystack.
Expand All @@ -168,7 +185,7 @@ async def create_refund(
import asyncio
import json

secret_key = get_paystack_secret_key()
secret_key = get_paystack_secret_key(is_test=is_test)
if not secret_key:
return {"status": False, "message": "Payment not configured"}

Expand All @@ -190,14 +207,15 @@ async def create_refund(
endpoint="/refund",
method="POST",
data=data,
is_test=is_test,
),
)
except Exception:
logger.exception("Failed to create refund for: %s", transaction_reference)
return {"status": False, "message": "Refund request failed"}


async def fetch_transaction_details(transaction_id: str) -> dict:
async def fetch_transaction_details(transaction_id: str, *, is_test: bool = False) -> dict:
"""
Fetch full transaction details from Paystack by transaction ID.

Expand All @@ -211,7 +229,7 @@ async def fetch_transaction_details(transaction_id: str) -> dict:
try:
return await loop.run_in_executor(
None,
lambda: _make_paystack_request(endpoint=f"/transaction/{transaction_id}"),
lambda: _make_paystack_request(endpoint=f"/transaction/{transaction_id}", is_test=is_test),
)
except Exception:
logger.exception("Failed to fetch transaction details: %s", transaction_id)
Expand All @@ -222,22 +240,30 @@ def validate_webhook_signature(payload: bytes, signature: str) -> bool:
"""
Validate the Paystack webhook signature.

Paystack signs live webhooks with the live secret key and test webhooks
with the test secret key. Because we cannot know which key applies before
looking up the payment, we try both keys and accept if either matches.

Args:
payload: Raw request body bytes.
signature: x-paystack-signature header value.

Returns:
True if signature is valid.
True if signature is valid against at least one configured key.

"""
secret_key = get_paystack_secret_key()
if not secret_key:
return False

expected = hmac.new(
secret_key.encode(),
payload,
hashlib.sha512,
).hexdigest()

return hmac.compare_digest(expected, signature)
keys = [
get_paystack_secret_key(is_test=False),
get_paystack_secret_key(is_test=True),
]
for secret_key in keys:
if not secret_key:
continue
expected = hmac.new(
secret_key.encode(),
payload,
hashlib.sha512,
).hexdigest()
if hmac.compare_digest(expected, signature):
return True
return False
3 changes: 2 additions & 1 deletion src/apps/payments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ async def get(self, request: HttpRequest) -> HttpResponse:
messages.error(request, "Payment not found.")
return redirect("payments:pay")

result = await services.verify_transaction(reference)
is_test = payment.service.is_test if payment.service else False
result = await services.verify_transaction(reference, is_test=is_test)

if result.get("status") and result.get("data", {}).get("status") == "success":
payment.status = Payment.Status.SUCCESS
Expand Down
5 changes: 4 additions & 1 deletion src/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@
# Site URL for links in emails
SITE_URL = env("SITE_URL", default="https://acoruss.com")

# Paystack
# Paystack — live credentials (used for live service products)
PAYSTACK_SECRET_KEY = env("PAYSTACK_SECRET_KEY", default="")
PAYSTACK_PUBLIC_KEY = env("PAYSTACK_PUBLIC_KEY", default="")
# Paystack — test credentials (used for service products with is_test=True)
PAYSTACK_TEST_SECRET_KEY = env("PAYSTACK_TEST_SECRET_KEY", default="")
PAYSTACK_TEST_PUBLIC_KEY = env("PAYSTACK_TEST_PUBLIC_KEY", default="")

# Google Analytics
GOOGLE_ANALYTICS_ID = env("GOOGLE_ANALYTICS_ID", default="G-BSXKG26LSP")
Expand Down
Loading