diff --git a/docs/PRODUCT_PAYMENTS.md b/docs/PRODUCT_PAYMENTS.md index 8d93d4a..8c45051 100644 --- a/docs/PRODUCT_PAYMENTS.md +++ b/docs/PRODUCT_PAYMENTS.md @@ -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. --- diff --git a/src/apps/payments/api_views.py b/src/apps/payments/api_views.py index 60d3a97..63f717b 100644 --- a/src/apps/payments/api_views.py +++ b/src/apps/payments/api_views.py @@ -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, @@ -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"): diff --git a/src/apps/payments/services.py b/src/apps/payments/services.py index d771d04..8663088 100644 --- a/src/apps/payments/services.py +++ b/src/apps/payments/services.py @@ -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", "") @@ -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", @@ -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. @@ -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"} @@ -111,6 +125,7 @@ async def initialise_transaction( endpoint="/transaction/initialize", method="POST", data=data, + is_test=is_test, ), ) except Exception: @@ -118,7 +133,7 @@ async def initialise_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. @@ -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"} @@ -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: @@ -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. @@ -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"} @@ -190,6 +207,7 @@ async def create_refund( endpoint="/refund", method="POST", data=data, + is_test=is_test, ), ) except Exception: @@ -197,7 +215,7 @@ async def create_refund( 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. @@ -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) @@ -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 diff --git a/src/apps/payments/views.py b/src/apps/payments/views.py index adc92f2..49bd8e4 100644 --- a/src/apps/payments/views.py +++ b/src/apps/payments/views.py @@ -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 diff --git a/src/config/settings/base.py b/src/config/settings/base.py index a9dc2a3..639d538 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -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")