Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cbaba28d8 | ||
|
|
0e1db2bd94 | ||
|
|
8022f37ae2 | ||
|
|
a67de8d16b | ||
|
|
f6887ccaa9 | ||
|
|
e85d0facee |
No files matched your search
@@ -13,9 +13,6 @@
|
|||||||
<img src="https://img.shields.io/pypi/pyversions/django-nepali-payment" alt="Python versions">
|
<img src="https://img.shields.io/pypi/pyversions/django-nepali-payment" alt="Python versions">
|
||||||
</a>
|
</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">
|
<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">
|
<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">
|
<img src="https://img.shields.io/github/license/S4NKALP/django-nepali-payments" alt="License">
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+13
-13
@@ -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
|
views, models, URL callbacks and a management command, rather than as
|
||||||
standalone scripts.
|
standalone scripts.
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `manage.py` / `example_project/` | A minimal Django project (settings, urls, wsgi/asgi). |
|
| `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/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/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/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). |
|
| `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. |
|
| `.env.sample` | Credential template, copy to `.env` and fill in your keys. |
|
||||||
|
|
||||||
## Run it
|
## 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:
|
The default `.env.sample` ships with test keys that are actually accepted today:
|
||||||
|
|
||||||
| Gateway | Test credentials |
|
| Gateway | Test credentials |
|
||||||
| -------- | --------------------------------------------------------------------------- |
|
| ------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Khalti | Mobile `9800000001/2/3/4/5`, Pin `1111`, OTP `987654`, secret `live_secret_key_68791341fdd94846a146f0457ff7b455` |
|
| 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` |
|
| 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 | See note below — no public sandbox credentials exist |
|
||||||
|
|
||||||
### Fonepay sandbox caveat
|
### Fonepay sandbox caveat
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<td style="vertical-align:middle;">
|
<td style="vertical-align:middle;">
|
||||||
<img src="https://cdn.esewa.com.np/ui/images/esewa_og.png" alt="eSewa" style="height:26px; width:auto;">
|
<img src="https://cdn.esewa.com.np/ui/images/esewa_og.png" alt="eSewa" style="height:26px; width:auto;">
|
||||||
</td>
|
</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>Nepal@123</code></td>
|
||||||
<td><code>123456</code></td>
|
<td><code>123456</code></td>
|
||||||
<td><code>8gBm/:&EnhH.1/q</code>, code <code>EPAYTEST</code></td>
|
<td><code>8gBm/:&EnhH.1/q</code>, code <code>EPAYTEST</code></td>
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ def _coerce(cls: type[T], data: Any) -> T:
|
|||||||
def decode_base64_content(encoded_content: str) -> str:
|
def decode_base64_content(encoded_content: str) -> str:
|
||||||
"""Decode base64 content, falling back to the raw string (DecodeBase64Content)."""
|
"""Decode base64 content, falling back to the raw string (DecodeBase64Content)."""
|
||||||
try:
|
try:
|
||||||
return base64.b64decode(encoded_content).decode("utf-8")
|
# Try standard base64 first, then urlsafe
|
||||||
except (ValueError, TypeError):
|
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
|
return encoded_content
|
||||||
@@ -79,6 +79,20 @@ class ApiService:
|
|||||||
raise NetworkError("API path cannot be null or empty.")
|
raise NetworkError("API path cannot be null or empty.")
|
||||||
if not http_method:
|
if not http_method:
|
||||||
raise NetworkError("HTTP method cannot be null.")
|
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()
|
method = http_method.upper()
|
||||||
headers = {k: v for k, v in (header_param or {}).items() if k.lower() != "content-type"}
|
headers = {k: v for k, v in (header_param or {}).items() if k.lower() != "content-type"}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
import threading
|
||||||
|
|
||||||
from nepali_payment.enums import PaymentMethod, PaymentMode
|
from nepali_payment.enums import PaymentMethod, PaymentMode
|
||||||
from nepali_payment.exceptions import ValidationError
|
from nepali_payment.exceptions import ValidationError
|
||||||
@@ -45,6 +46,7 @@ class PaymentManager:
|
|||||||
self._api = api
|
self._api = api
|
||||||
self._config = config
|
self._config = config
|
||||||
self._service_cache = None
|
self._service_cache = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def _api_service(self):
|
def _api_service(self):
|
||||||
"""Return the shared HTTP client, creating it on the first call.
|
"""Return the shared HTTP client, creating it on the first call.
|
||||||
@@ -66,9 +68,11 @@ class PaymentManager:
|
|||||||
keeps a stable HTTP client across operations.
|
keeps a stable HTTP client across operations.
|
||||||
"""
|
"""
|
||||||
if self._service_cache is None:
|
if self._service_cache is None:
|
||||||
self._service_cache = get_payment_service(
|
with self._lock:
|
||||||
self._method, self._secret_key, self._mode, api=self._api_service(), config=self._config
|
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
|
return self._service_cache
|
||||||
|
|
||||||
def initiate_payment(self, result_cls: type[T], content: Any) -> T:
|
def initiate_payment(self, result_cls: type[T], content: Any) -> T:
|
||||||
|
|||||||
Reference in new issue
Block a user