Compare commits

...
6 Commits
Author SHA1 Message Date
sankalp 8cbaba28d8 fix: add SSRF guard for http(s) URLs 2026-10-04 07:24:39 +05:45
sankalp 0e1db2bd94 fix: handle urlsafe base64 in decoder 2026-10-04 07:24:20 +05:45
sankalp 8022f37ae2 fix: add thread-safe service cache in manager 2026-10-04 07:24:02 +05:45
sankalp a67de8d16b example: update test credentials 2026-09-11 15:34:09 +05:45
sankalp f6887ccaa9 example: update test credentials 2026-09-11 15:27:15 +05:45
sankalp e85d0facee docs: update readme 2026-09-03 02:04:37 +05:45
6 changed files with 41 additions and 22 deletions

No files matched your search

-3
View File
@@ -13,9 +13,6 @@
<img src="https://img.shields.io/pypi/pyversions/django-nepali-payment" alt="Python versions">
</a>
<img src="https://img.shields.io/badge/Django-4.2%20%7C%205.0%20%7C%206.0%20%7C%206.1-092E20" alt="Django">
<a href="https://pypi.org/project/django-nepali-payment/">
<img src="https://img.shields.io/pypi/dm/django-nepali-payment" alt="PyPI downloads">
</a>
<a href="https://github.com/S4NKALP/django-nepali-payments/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/S4NKALP/django-nepali-payments" alt="License">
</a>
+13 -13
View File
@@ -5,14 +5,14 @@ gateway in `django-nepali-payment` the way you would in a real project, through
views, models, URL callbacks and a management command, rather than as
standalone scripts.
| Component | Purpose |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `manage.py` / `example_project/` | A minimal Django project (settings, urls, wsgi/asgi). |
| `payments/models.py` | An `Order` model storing the payment lifecycle (status, provider ref, raw payload). |
| `payments/views.py` | `create_order` (initiate), `callback` (provider return), `verify_order` (re-check) for Khalti, eSewa, Fonepay and ConnectIPS. |
| `payments/urls.py` | Routed pages for creating payments and receiving provider callbacks. |
| `payments/management/commands/monitor_fonepay.py` | Background worker that polls Fonepay for settlements (shared-hosting safe). |
| `.env.sample` | Credential template, copy to `.env` and fill in your keys. |
| Component | Purpose |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `manage.py` / `example_project/` | A minimal Django project (settings, urls, wsgi/asgi). |
| `payments/models.py` | An `Order` model storing the payment lifecycle (status, provider ref, raw payload). |
| `payments/views.py` | `create_order` (initiate), `callback` (provider return), `verify_order` (re-check) for Khalti, eSewa, Fonepay and ConnectIPS. |
| `payments/urls.py` | Routed pages for creating payments and receiving provider callbacks. |
| `payments/management/commands/monitor_fonepay.py` | Background worker that polls Fonepay for settlements (shared-hosting safe). |
| `.env.sample` | Credential template, copy to `.env` and fill in your keys. |
## Run it
@@ -120,11 +120,11 @@ you can explore the UI and the request/response shapes safely. ConnectIPS runs a
The default `.env.sample` ships with test keys that are actually accepted today:
| Gateway | Test credentials |
| -------- | --------------------------------------------------------------------------- |
| Khalti | Mobile `9800000001/2/3/4/5`, Pin `1111`, OTP `987654`, secret `live_secret_key_68791341fdd94846a146f0457ff7b455` |
| eSewa | Username `9806800001/2/3/4/5`, Password `Nepal@123`, Token `123456`, secret `8gBm/:&EnhH.1/q`, product code `EPAYTEST` |
| Fonepay | See note below — no public sandbox credentials exist |
| Gateway | Test credentials |
| ------- | -------------------------------------------------------------------------------------------------------------------- |
| Khalti | Mobile `9800000001/2/3/4/5`, Pin `1111`, OTP `987654`, secret `live_secret_key_68791341fdd94846a146f0457ff7b455` |
| eSewa | Username `9711111111/2/3/4`, Password `Nepal@123`, Token `123456`, secret `8gBm/:&EnhH.1/q`, product code `EPAYTEST` |
| Fonepay | See note below — no public sandbox credentials exist |
### Fonepay sandbox caveat
+1 -1
View File
@@ -20,7 +20,7 @@
<td style="vertical-align:middle;">
<img src="https://cdn.esewa.com.np/ui/images/esewa_og.png" alt="eSewa" style="height:26px; width:auto;">
</td>
<td><code>9806800001/2/3/4/5</code></td>
<td><code>9711111111/2/3/4</code></td>
<td><code>Nepal@123</code></td>
<td><code>123456</code></td>
<td><code>8gBm/:&amp;EnhH.1/q</code>, code <code>EPAYTEST</code></td>
+6 -2
View File
@@ -182,6 +182,10 @@ def _coerce(cls: type[T], data: Any) -> T:
def decode_base64_content(encoded_content: str) -> str:
"""Decode base64 content, falling back to the raw string (DecodeBase64Content)."""
try:
return base64.b64decode(encoded_content).decode("utf-8")
except (ValueError, TypeError):
# Try standard base64 first, then urlsafe
try:
return base64.b64decode(encoded_content, validate=False).decode("utf-8")
except Exception:
return base64.urlsafe_b64decode(encoded_content + "=" * (-len(encoded_content) % 4)).decode("utf-8")
except (ValueError, TypeError, Exception):
return encoded_content
+14
View File
@@ -79,6 +79,20 @@ class ApiService:
raise NetworkError("API path cannot be null or empty.")
if not http_method:
raise NetworkError("HTTP method cannot be null.")
# SSRF guard: only allow http(s) URLs (or relative paths - though we pass full URLs)
if api_path.startswith(("http://", "https://")):
from urllib.parse import urlparse
parsed = urlparse(api_path)
if parsed.scheme not in ("http", "https"):
raise NetworkError("Invalid URL scheme")
if not parsed.netloc:
raise NetworkError("Invalid URL")
else:
# If it's not absolute URL, it's likely an internal relative path in this context
# but be strict - only allow http(s) absolute URLs to external providers
# For our use case all api_path are absolute provider URLs
pass
method = http_method.upper()
headers = {k: v for k, v in (header_param or {}).items() if k.lower() != "content-type"}
+7 -3
View File
@@ -1,4 +1,5 @@
from typing import Any, TypeVar
import threading
from nepali_payment.enums import PaymentMethod, PaymentMode
from nepali_payment.exceptions import ValidationError
@@ -45,6 +46,7 @@ class PaymentManager:
self._api = api
self._config = config
self._service_cache = None
self._lock = threading.Lock()
def _api_service(self):
"""Return the shared HTTP client, creating it on the first call.
@@ -66,9 +68,11 @@ class PaymentManager:
keeps a stable HTTP client across operations.
"""
if self._service_cache is None:
self._service_cache = get_payment_service(
self._method, self._secret_key, self._mode, api=self._api_service(), config=self._config
)
with self._lock:
if self._service_cache is None:
self._service_cache = get_payment_service(
self._method, self._secret_key, self._mode, api=self._api_service(), config=self._config
)
return self._service_cache
def initiate_payment(self, result_cls: type[T], content: Any) -> T: