connect Zarinpal as the real payment gateway
payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):
- create_order/CreateOrderView still never call Zarinpal directly —
paymentUrl points at our own GET /api/payment/orders/{id}/pay
(PaymentRedirectView), which requests the real Zarinpal authority on
first visit and redirects to StartPay. This keeps order creation from
depending on Zarinpal's uptime/latency, and means none of the existing
purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
Zarinpal redirects the user back to; it always re-verifies
server-to-server via zarinpal.verify_payment before marking an order
paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
isn't configured, matching payment/bazaar.py's existing convention —
never a fake successful payment.
Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
13
.env.example
13
.env.example
@@ -53,6 +53,19 @@ RAG_RETRIEVAL_TOP_K=4
|
||||
# RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه.
|
||||
RAG_FORCE_MOCK=
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# زرینپال (اپ payment) — درگاهِ اصلیِ پرداختِ ما
|
||||
# -----------------------------------------------------------------------
|
||||
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed میمونه (خطای صریحِ
|
||||
# ۵۰۳ بهجایِ پرداختِ ساختگیِ موفق).
|
||||
PUBLIC_BASE_URL=
|
||||
ZARINPAL_MERCHANT_ID=
|
||||
# 1 = محیطِ sandbox زرینپال (برای تست بدونِ پرداختِ واقعی)
|
||||
ZARINPAL_SANDBOX=1
|
||||
# اختیاری — بعدِ تاییدِ پرداخت، کاربر به این URL (مثلاً یک deep link اپ) با
|
||||
# ?orderId=...&status=... ریدایرکت میشه. خالی = پاسخِ JSON ساده.
|
||||
ZARINPAL_REDIRECT_URL=
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# تنظیماتِ مقیاسپذیری (همه دیفالتِ امن دارن)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -36,7 +36,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||||
| `progress` | stats, leaderboard, streaks | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` |
|
||||
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
|
||||
| `subscription` | plans | `Plan`, `Subscription` |
|
||||
| `payment` | orders/transactions (incl. Cafe Bazaar IAP) | `Order`, `Transaction` |
|
||||
| `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` |
|
||||
| `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
|
||||
| `notifications` | notifications | `Notification` |
|
||||
| `affiliate` | referral program | `AffiliateAccount`, `Conversion` |
|
||||
@@ -60,6 +60,8 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||||
|
||||
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||||
|
||||
**Payment (Zarinpal)**: `create_order`/`CreateOrderView` never call Zarinpal directly — `paymentUrl` points at our own `GET /api/payment/orders/{id}/pay` (`PaymentRedirectView`), which calls Zarinpal's PaymentRequest API on first visit (storing `Order.gateway_authority`) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to `GET /api/payment/zarinpal/callback` (`ZarinpalCallbackView`), which always re-verifies server-to-server via `payment/zarinpal.py::verify_payment` before marking an order paid — a forged `Status=OK` in the query string alone does nothing. Both `payment/zarinpal.py` and `payment/bazaar.py` fail closed (503 / invalid) when their credentials aren't configured, never a fake success.
|
||||
|
||||
## Known gaps (per README pre-deploy checklist)
|
||||
|
||||
- Still on SQLite, not Postgres.
|
||||
|
||||
@@ -278,6 +278,7 @@ REST_FRAMEWORK = {
|
||||
'user_profile_write': '60/hour',
|
||||
'affiliate_track': '10/hour',
|
||||
'payment_webhook': '60/hour',
|
||||
'payment_pay': '30/hour',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -389,6 +390,35 @@ BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
||||
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
||||
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||||
# -----------------------------------------------------------------------
|
||||
# آدرسِ عمومیِ خودِ بکاند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمیگردد
|
||||
# (PaymentRedirectView) و هم برای callback_url که به زرینپال میفرستیم لازم
|
||||
# است. بدونش این دو URL شکسته میمانند — سیگنالِ واضحی که هنوز ست نشده.
|
||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', '').rstrip('/')
|
||||
|
||||
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
||||
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
|
||||
ZARINPAL_API_BASE = os.environ.get(
|
||||
'ZARINPAL_API_BASE',
|
||||
'https://sandbox.zarinpal.com/pg/v4/payment' if ZARINPAL_SANDBOX
|
||||
else 'https://api.zarinpal.com/pg/v4/payment',
|
||||
)
|
||||
ZARINPAL_STARTPAY_BASE = os.environ.get(
|
||||
'ZARINPAL_STARTPAY_BASE',
|
||||
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
|
||||
else 'https://www.zarinpal.com/pg/StartPay',
|
||||
)
|
||||
ZARINPAL_CALLBACK_URL = os.environ.get(
|
||||
'ZARINPAL_CALLBACK_URL',
|
||||
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
|
||||
)
|
||||
# اختیاری: بعدِ verify، مرورگر/کلاینت را به این URL (مثلاً یک deep link اپ)
|
||||
# با ?orderId=...&status=... ریدایرکت میکنیم. اگه خالی باشه، همونجا یک
|
||||
# پاسخِ JSON ساده برمیگرده.
|
||||
ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '')
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
18
payment/migrations/0006_order_gateway_authority.py
Normal file
18
payment/migrations/0006_order_gateway_authority.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-06 09:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0005_remove_transaction_uniq_transaction_order_ref_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='gateway_authority',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -24,6 +24,12 @@ class Order(models.Model):
|
||||
# بازار میگیره و برای verify به بکاند میفرسته.
|
||||
bazaar_product_sku = models.CharField(max_length=100, blank=True)
|
||||
bazaar_purchase_token = models.CharField(max_length=300, blank=True)
|
||||
# فقط وقتی provider == 'gateway' (زرینپال) پر میشه: Authorityِ برگشتی از
|
||||
# PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ
|
||||
# ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرینپال
|
||||
# موقعِ callback در query string (Authority=...) برمیگردونه، پس برای
|
||||
# پیداکردنِ Order از روی callback لازمه.
|
||||
gateway_authority = models.CharField(max_length=64, blank=True, db_index=True)
|
||||
idempotency_key = models.CharField(max_length=100, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ class WebhookResponseSerializer(serializers.Serializer):
|
||||
received = serializers.BooleanField()
|
||||
|
||||
|
||||
class ZarinpalCallbackResponseSerializer(serializers.Serializer):
|
||||
"""فقط وقتی settings.ZARINPAL_REDIRECT_URL خالی باشه برگردونده میشه؛
|
||||
وگرنه کاربر به همون URL ریدایرکت میشه، نه به این JSON."""
|
||||
orderId = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
|
||||
|
||||
class TransactionSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
gateway = serializers.CharField()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
جنگوییه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
|
||||
پیادهسازی شده، نه یک HTTP call به خودمون.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.db import transaction as db_transaction
|
||||
from django.utils import timezone
|
||||
@@ -38,11 +39,15 @@ def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currenc
|
||||
|
||||
def call_payment_gateway(order: Order) -> str:
|
||||
"""
|
||||
استاب فراخوانی درگاه پرداخت واقعی؛ فعلاً یک paymentUrl ساختگی برمیگرداند
|
||||
تا کل جریان (ساخت سفارش → پرداخت → webhook) بدون کلید API واقعی قابل تست باشد.
|
||||
برای اتصال واقعی، این تابع با فراخوانی HTTP به API درگاه جایگزین شود.
|
||||
paymentUrlی که به کلاینت برمیگردد: آدرسِ داخلیِ خودمان
|
||||
(PaymentRedirectView)، نه مستقیماً StartPay زرینپال. فراخوانیِ واقعیِ
|
||||
PaymentRequest به زرینپال (یک HTTP call خارجی) عمداً همینجا انجام
|
||||
نمیشود — create_order از subscription صدا زده میشود و نباید موفقیتِ
|
||||
ساختِ سفارش به دسترسبودن/سرعتِ زرینپال گره بخورد؛ PaymentRequest اولین
|
||||
باری که کاربر واقعاً این URL را باز کند زده میشود (نگاه کنید به
|
||||
payment/zarinpal.py و PaymentRedirectView در views.py).
|
||||
"""
|
||||
return f'https://payment.gateway/pay/{order.id}'
|
||||
return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay'
|
||||
|
||||
|
||||
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
|
||||
285
payment/tests.py
285
payment/tests.py
@@ -10,10 +10,11 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from . import bazaar
|
||||
from . import bazaar, zarinpal
|
||||
from .bazaar import BazaarPurchaseResult
|
||||
from .models import Order, Transaction
|
||||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||||
from .services import call_payment_gateway
|
||||
|
||||
|
||||
class SecretDefaultsFailClosedTests(TestCase):
|
||||
@@ -364,3 +365,285 @@ class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase):
|
||||
self.assertEqual(order2.status, 'pending')
|
||||
self.assertEqual(order2.bazaar_purchase_token, '')
|
||||
self.assertFalse(Transaction.objects.filter(order=order2).exists())
|
||||
|
||||
|
||||
class ZarinpalClientTests(TestCase):
|
||||
"""
|
||||
تستهای واحدِ payment/zarinpal.py: بدونِ ZARINPAL_MERCHANT_ID باید
|
||||
fail-closed باشه (دقیقاً مثلِ bazaar.verify_purchase)، و با کلیدهای
|
||||
ستشده مسیرِ موفق/ناموفقِ درگاه باید درست پارس بشه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.order = Order.objects.create(
|
||||
user_id=1, amount=100000, currency='IRT', provider='gateway',
|
||||
idempotency_key='zp-client-order-1',
|
||||
)
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='https://api.example.com/cb')
|
||||
def test_request_payment_without_merchant_id_is_not_configured(self):
|
||||
with self.assertRaises(zarinpal.ZarinpalNotConfigured):
|
||||
zarinpal.request_payment(self.order)
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz', ZARINPAL_CALLBACK_URL='')
|
||||
def test_request_payment_without_callback_url_is_not_configured(self):
|
||||
with self.assertRaises(zarinpal.ZarinpalNotConfigured):
|
||||
zarinpal.request_payment(self.order)
|
||||
|
||||
@override_settings(
|
||||
ZARINPAL_MERCHANT_ID='merchant-xyz',
|
||||
ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
|
||||
ZARINPAL_API_BASE='https://api.zarinpal.com/pg/v4/payment',
|
||||
ZARINPAL_STARTPAY_BASE='https://www.zarinpal.com/pg/StartPay',
|
||||
)
|
||||
@patch('payment.zarinpal.requests.post')
|
||||
def test_request_payment_success_returns_authority_and_url(self, mock_post):
|
||||
mock_post.return_value.raise_for_status = lambda: None
|
||||
mock_post.return_value.json.return_value = {
|
||||
'data': {'code': 100, 'authority': 'A00000000000000000000000000123456789'},
|
||||
'errors': [],
|
||||
}
|
||||
|
||||
result = zarinpal.request_payment(self.order)
|
||||
|
||||
self.assertEqual(result.authority, 'A00000000000000000000000000123456789')
|
||||
self.assertEqual(
|
||||
result.payment_url,
|
||||
'https://www.zarinpal.com/pg/StartPay/A00000000000000000000000000123456789',
|
||||
)
|
||||
_, post_kwargs = mock_post.call_args
|
||||
self.assertEqual(post_kwargs['json']['merchant_id'], 'merchant-xyz')
|
||||
self.assertEqual(post_kwargs['json']['amount'], 100000)
|
||||
self.assertEqual(post_kwargs['json']['currency'], 'IRT')
|
||||
|
||||
@override_settings(
|
||||
ZARINPAL_MERCHANT_ID='merchant-xyz',
|
||||
ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
|
||||
)
|
||||
@patch('payment.zarinpal.requests.post')
|
||||
def test_request_payment_rejected_by_gateway_raises_api_error(self, mock_post):
|
||||
mock_post.return_value.raise_for_status = lambda: None
|
||||
mock_post.return_value.json.return_value = {'data': {'code': -9, 'authority': ''}, 'errors': []}
|
||||
|
||||
with self.assertRaises(zarinpal.ZarinpalAPIError):
|
||||
zarinpal.request_payment(self.order)
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='')
|
||||
def test_verify_payment_without_merchant_id_is_invalid(self):
|
||||
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
|
||||
self.assertFalse(result.is_valid)
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz')
|
||||
@patch('payment.zarinpal.requests.post')
|
||||
def test_verify_payment_success_returns_ref_id(self, mock_post):
|
||||
mock_post.return_value.json.return_value = {'data': {'code': 100, 'ref_id': 987654321}}
|
||||
|
||||
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
|
||||
|
||||
self.assertTrue(result.is_valid)
|
||||
self.assertEqual(result.ref_id, '987654321')
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz')
|
||||
@patch('payment.zarinpal.requests.post')
|
||||
def test_verify_payment_already_verified_is_still_valid(self, mock_post):
|
||||
# ۱۰۱ یعنی این authority قبلاً تایید شده — idempotent از سمتِ زرینپال،
|
||||
# نه یک خطا.
|
||||
mock_post.return_value.json.return_value = {'data': {'code': 101, 'ref_id': 987654321}}
|
||||
|
||||
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
|
||||
|
||||
self.assertTrue(result.is_valid)
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='merchant-xyz')
|
||||
@patch('payment.zarinpal.requests.post')
|
||||
def test_verify_payment_failed_is_invalid(self, mock_post):
|
||||
mock_post.return_value.json.return_value = {'data': {'code': -1}}
|
||||
|
||||
result = zarinpal.verify_payment(authority='A123', amount=100000, currency='IRT')
|
||||
|
||||
self.assertFalse(result.is_valid)
|
||||
|
||||
|
||||
class CallPaymentGatewayTests(TestCase):
|
||||
"""
|
||||
call_payment_gateway (که create_order موقعِ ساختِ سفارش صدا میزنه) نباید
|
||||
هیچ تماسِ خارجیای با زرینپال بزنه — فقط یک URL داخلی برمیگردونه.
|
||||
PaymentRequestِ واقعی به تعویق افتاده تا PaymentRedirectView (نگاه کنید
|
||||
به کلاسهای تست پایینتر)، دقیقاً تا ساختِ سفارش (که از subscription صدا
|
||||
زده میشه) به دسترسبودنِ زرینپال گره نخوره.
|
||||
"""
|
||||
|
||||
@override_settings(PUBLIC_BASE_URL='https://api.tadrisino.ir')
|
||||
def test_returns_internal_url_without_calling_zarinpal(self):
|
||||
order = Order.objects.create(
|
||||
user_id=1, amount=100000, currency='IRT', provider='gateway',
|
||||
idempotency_key='cpg-order-1',
|
||||
)
|
||||
|
||||
url = call_payment_gateway(order)
|
||||
|
||||
self.assertEqual(url, f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay')
|
||||
|
||||
|
||||
class PaymentRedirectViewTests(TestCase):
|
||||
"""
|
||||
GET /api/payment/orders/{id}/pay: اولین بازکردن باید authority بسازه و
|
||||
ریدایرکت کنه؛ بازکردنهای بعدی نباید دوباره PaymentRequest بزنن.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.order = Order.objects.create(
|
||||
user_id=1, amount=100000, currency='IRT', provider='gateway',
|
||||
idempotency_key='pay-view-order-1',
|
||||
)
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='')
|
||||
def test_unconfigured_gateway_returns_503(self):
|
||||
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
|
||||
self.assertEqual(response.status_code, 503)
|
||||
|
||||
@override_settings(
|
||||
ZARINPAL_MERCHANT_ID='merchant-xyz', ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
|
||||
ZARINPAL_STARTPAY_BASE='https://www.zarinpal.com/pg/StartPay',
|
||||
)
|
||||
@patch('payment.views.zarinpal.request_payment')
|
||||
def test_first_open_requests_payment_and_redirects(self, mock_request_payment):
|
||||
mock_request_payment.return_value = zarinpal.ZarinpalRequestResult(
|
||||
authority='A-first', payment_url='https://www.zarinpal.com/pg/StartPay/A-first',
|
||||
)
|
||||
|
||||
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-first')
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.gateway_authority, 'A-first')
|
||||
mock_request_payment.assert_called_once()
|
||||
|
||||
@override_settings(
|
||||
ZARINPAL_MERCHANT_ID='merchant-xyz', ZARINPAL_CALLBACK_URL='https://api.example.com/cb',
|
||||
ZARINPAL_STARTPAY_BASE='https://www.zarinpal.com/pg/StartPay',
|
||||
)
|
||||
@patch('payment.views.zarinpal.request_payment')
|
||||
def test_second_open_reuses_existing_authority(self, mock_request_payment):
|
||||
self.order.gateway_authority = 'A-existing'
|
||||
self.order.save(update_fields=['gateway_authority'])
|
||||
|
||||
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-existing')
|
||||
mock_request_payment.assert_not_called()
|
||||
|
||||
@patch('payment.views.zarinpal.request_payment')
|
||||
def test_already_resolved_order_does_not_call_gateway(self, mock_request_payment):
|
||||
self.order.status = 'paid'
|
||||
self.order.save(update_fields=['status'])
|
||||
|
||||
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay')
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
mock_request_payment.assert_not_called()
|
||||
|
||||
def test_unknown_order_is_404(self):
|
||||
response = self.client.get('/api/payment/orders/999999/pay')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_bazaar_order_is_404_on_gateway_pay_endpoint(self):
|
||||
bazaar_order = Order.objects.create(
|
||||
user_id=1, amount=100000, provider='bazaar',
|
||||
bazaar_product_sku='sku', idempotency_key='pay-view-bazaar-1',
|
||||
)
|
||||
response = self.client.get(f'/api/payment/orders/{bazaar_order.id}/pay')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class ZarinpalCallbackViewTests(TestCase):
|
||||
"""
|
||||
GET /api/payment/zarinpal/callback: امنیت از verify_payment میاد، نه از
|
||||
Status=OK توی query string — یک Status=OK جعلی بدونِ تاییدِ واقعیِ
|
||||
زرینپال نباید سفارش رو paid کنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.client = APIClient()
|
||||
self.order = Order.objects.create(
|
||||
user_id=1, amount=100000, currency='IRT', provider='gateway',
|
||||
gateway_authority='A-callback-1', idempotency_key='cb-order-1',
|
||||
)
|
||||
|
||||
def test_unknown_authority_is_404(self):
|
||||
response = self.client.get('/api/payment/zarinpal/callback?Authority=unknown&Status=OK')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@patch('payment.views.zarinpal.verify_payment')
|
||||
def test_status_nok_marks_order_failed_without_calling_verify(self, mock_verify):
|
||||
response = self.client.get(
|
||||
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=NOK'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['status'], 'failed')
|
||||
mock_verify.assert_not_called()
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.status, 'failed')
|
||||
|
||||
@patch('payment.views.zarinpal.verify_payment')
|
||||
def test_status_ok_with_failed_verification_does_not_mark_paid(self, mock_verify):
|
||||
# کاربر میتونه Status=OK رو دستی توی URL جعل کنه؛ فقط verify_payمنتِ
|
||||
# واقعی تعیینکنندهست.
|
||||
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=False)
|
||||
|
||||
response = self.client.get(
|
||||
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['status'], 'failed')
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.status, 'failed')
|
||||
|
||||
@patch('payment.views.zarinpal.verify_payment')
|
||||
def test_status_ok_with_successful_verification_marks_paid(self, mock_verify):
|
||||
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=True, ref_id='ref-123')
|
||||
|
||||
response = self.client.get(
|
||||
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['status'], 'paid')
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.status, 'paid')
|
||||
txn = Transaction.objects.get(order=self.order)
|
||||
self.assertEqual(txn.gateway, 'zarinpal')
|
||||
self.assertEqual(txn.gateway_ref_id, 'ref-123')
|
||||
|
||||
@patch('payment.views.zarinpal.verify_payment')
|
||||
def test_repeated_callback_does_not_double_process(self, mock_verify):
|
||||
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=True, ref_id='ref-once')
|
||||
|
||||
url = f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
|
||||
first = self.client.get(url)
|
||||
second = self.client.get(url)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(Transaction.objects.filter(order=self.order).count(), 1)
|
||||
|
||||
@override_settings(ZARINPAL_REDIRECT_URL='tadrisino://payment-result')
|
||||
@patch('payment.views.zarinpal.verify_payment')
|
||||
def test_redirect_url_configured_bounces_client_there(self, mock_verify):
|
||||
mock_verify.return_value = zarinpal.ZarinpalVerifyResult(is_valid=True, ref_id='ref-999')
|
||||
|
||||
response = self.client.get(
|
||||
f'/api/payment/zarinpal/callback?Authority={self.order.gateway_authority}&Status=OK'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url, f'tadrisino://payment-result?orderId={self.order.id}&status=paid',
|
||||
)
|
||||
|
||||
@@ -4,12 +4,16 @@ from .views import (
|
||||
BazaarVerifyPurchaseView,
|
||||
CreateOrderView,
|
||||
OrderDetailView,
|
||||
PaymentRedirectView,
|
||||
PaymentWebhookView,
|
||||
ZarinpalCallbackView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
||||
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'),
|
||||
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
|
||||
path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
|
||||
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
||||
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),
|
||||
]
|
||||
|
||||
132
payment/views.py
132
payment/views.py
@@ -1,13 +1,17 @@
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import bazaar
|
||||
from . import bazaar, zarinpal
|
||||
from .models import Order
|
||||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||||
from .serializers import (
|
||||
@@ -18,10 +22,27 @@ from .serializers import (
|
||||
OrderDetailResponseSerializer,
|
||||
WebhookRequestSerializer,
|
||||
WebhookResponseSerializer,
|
||||
ZarinpalCallbackResponseSerializer,
|
||||
)
|
||||
from .services import apply_order_payment_result, create_order
|
||||
|
||||
|
||||
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
|
||||
"""
|
||||
HttpResponseRedirect معمولی فقط اسکیمِ http/https/ftp رو قبول میکنه؛
|
||||
ZARINPAL_REDIRECT_URL ممکنه یک deep link با اسکیمِ سفارشیِ اپ باشه
|
||||
(مثلاً tadrisino://...). چون این URL از settings میاد (نه از ورودیِ
|
||||
کاربر)، اضافهکردنِ اسکیمش به allowed_schemes امن است.
|
||||
"""
|
||||
scheme = urlsplit(url).scheme
|
||||
redirect_cls = HttpResponseRedirect
|
||||
if scheme and scheme not in redirect_cls.allowed_schemes:
|
||||
redirect_cls = type('ConfiguredSchemeRedirect', (HttpResponseRedirect,), {
|
||||
'allowed_schemes': [*HttpResponseRedirect.allowed_schemes, scheme],
|
||||
})
|
||||
return redirect_cls(url)
|
||||
|
||||
|
||||
class GuestAwareBazaarThrottle(ScopedRateThrottle):
|
||||
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
||||
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازهی bazaar_verify
|
||||
@@ -96,13 +117,16 @@ class CreateOrderView(APIView):
|
||||
|
||||
class PaymentWebhookView(APIView):
|
||||
"""
|
||||
POST /api/payment/webhook — کالبک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت)
|
||||
POST /api/payment/webhook — وبهوکِ عمومیِ shared-secret (نه مخصوصِ زرینپال).
|
||||
|
||||
محافظتشده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی
|
||||
نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک
|
||||
فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره
|
||||
Transaction/notify تکراری نمیسازه (مثلاً وقتی درگاه callback رو دوباره
|
||||
میفرسته).
|
||||
جریانِ واقعیِ زرینپال از PaymentRedirectView/ZarinpalCallbackView رد
|
||||
میشه (زرینپال خودش webhookِ سرور-به-سرور نمیزنه، فقط مرورگرِ کاربر رو
|
||||
ریدایرکت میکنه). این endpoint برای تاییدِ دستی/تستی یا درگاههای دیگهای
|
||||
که واقعاً webhook POST میزنن نگه داشته شده؛ محافظتشده با
|
||||
HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
|
||||
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
|
||||
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
|
||||
notify تکراری نمیسازه.
|
||||
"""
|
||||
|
||||
permission_classes = [HasWebhookSecret]
|
||||
@@ -149,6 +173,98 @@ class PaymentWebhookView(APIView):
|
||||
return Response(WebhookResponseSerializer({'received': True}).data)
|
||||
|
||||
|
||||
class PaymentRedirectView(APIView):
|
||||
"""
|
||||
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
|
||||
CreateOrderResponseSerializer برای provider='gateway' برمیگرداند؛
|
||||
کلاینت (مرورگر/وبویو) این را باز میکند.
|
||||
|
||||
فراخوانیِ واقعیِ PaymentRequest به زرینپال عمداً همینجاست، نه در
|
||||
create_order: create_order از subscription صدا زده میشود و نباید
|
||||
موفقیتِ ساختِ سفارش به دسترسبودنِ زرینپال گره بخورد. اولین بار که این
|
||||
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
||||
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
||||
میکند، نه یک PaymentRequest تازه.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
throttle_scope = 'payment_pay'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
def get(self, request, order_id):
|
||||
order = get_object_or_404(Order, pk=order_id, provider='gateway')
|
||||
|
||||
if order.status != 'pending':
|
||||
# سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست.
|
||||
return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/')
|
||||
|
||||
if not order.gateway_authority:
|
||||
result = zarinpal.request_payment(order)
|
||||
# ⚠️ محافظت در برابرِ race: اگه بینِ خواندنِ بالا و اینجا یک
|
||||
# درخواستِ همزمانِ دیگر authority ساخته باشد، همان مقدارِ اول را
|
||||
# نگه میداریم، نه این result تازه را (فقط وقتی هنوز خالی است آپدیت میشود).
|
||||
Order.objects.filter(pk=order.pk, gateway_authority='').update(
|
||||
gateway_authority=result.authority,
|
||||
)
|
||||
order.refresh_from_db(fields=['gateway_authority'])
|
||||
|
||||
return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}')
|
||||
|
||||
|
||||
class ZarinpalCallbackView(APIView):
|
||||
"""
|
||||
GET /api/payment/zarinpal/callback — کالبکی که زرینپال بعدِ پرداخت،
|
||||
مرورگرِ کاربر رو بهش ریدایرکت میکنه (Authority, Status در query string).
|
||||
|
||||
برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)،
|
||||
اینجا caller خودِ مرورگرِ کاربره، نه زرینپال مستقیماً — پس هیچ
|
||||
هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک
|
||||
فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK
|
||||
رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرینپال چیزی paid نمیشه.
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
throttle_scope = 'payment_webhook'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter('Authority', OpenApiTypes.STR, required=True),
|
||||
OpenApiParameter('Status', OpenApiTypes.STR, required=True),
|
||||
],
|
||||
responses=ZarinpalCallbackResponseSerializer,
|
||||
tags=['Payment (payment)'],
|
||||
)
|
||||
def get(self, request):
|
||||
authority = request.query_params.get('Authority', '')
|
||||
status_param = request.query_params.get('Status', '')
|
||||
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
|
||||
|
||||
if status_param != 'OK':
|
||||
new_status, gateway_ref_id = 'failed', ''
|
||||
else:
|
||||
result = zarinpal.verify_payment(
|
||||
authority=authority, amount=order.amount, currency=order.currency,
|
||||
)
|
||||
new_status = 'paid' if result.is_valid else 'failed'
|
||||
gateway_ref_id = result.ref_id
|
||||
|
||||
with transaction.atomic():
|
||||
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
||||
apply_order_payment_result(
|
||||
order=locked_order, gateway='zarinpal', gateway_ref_id=gateway_ref_id,
|
||||
new_status=new_status, event='zarinpal_callback',
|
||||
)
|
||||
order.refresh_from_db(fields=['status'])
|
||||
|
||||
if settings.ZARINPAL_REDIRECT_URL:
|
||||
return _redirect_to_configured_url(
|
||||
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
|
||||
)
|
||||
data = {'orderId': str(order.id), 'status': order.status}
|
||||
return Response(ZarinpalCallbackResponseSerializer(data).data)
|
||||
|
||||
|
||||
class BazaarVerifyPurchaseView(APIView):
|
||||
"""
|
||||
POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار
|
||||
|
||||
132
payment/zarinpal.py
Normal file
132
payment/zarinpal.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
کلاینت سرور-به-سرورِ درگاهِ زرینپال (Payment API v4)، طبق داکیومنت رسمی:
|
||||
https://docs.zarinpal.com/paymentGateway/
|
||||
|
||||
جریان:
|
||||
۱) کاربر PaymentRedirectView (GET /api/payment/orders/{id}/pay) رو باز
|
||||
میکنه؛ اگه هنوز authority نداریم، همینجا request_payment صدا زده
|
||||
میشه، authority روی Order ذخیره میشه، و کاربر به paymentUrل (StartPay)
|
||||
ریدایرکت میشه. عمداً موقعِ create_order (که از subscription صدا زده
|
||||
میشه) این تماس زده نمیشه، تا موفقیتِ ساختِ سفارش به دسترسبودنِ
|
||||
زرینپال گره نخوره.
|
||||
۲) کاربر در زرینپال پرداخت میکنه؛ زرینپال مرورگر رو به
|
||||
ZARINPAL_CALLBACK_URL?Authority=...&Status=OK|NOK ریدایرکت میکنه
|
||||
(ZarinpalCallbackView در views.py).
|
||||
۳) verify_payment صدا زده میشه؛ فقط بعدِ این تاییدِ سرور-به-سرور سفارش
|
||||
paid میشه — هیچوقت فقط به Status=OK از کوئریاسترینگِ مرورگر اعتماد
|
||||
نمیشه (دقیقاً همون منطقِ bazaar.verify_purchase).
|
||||
|
||||
🔒 بدون ZARINPAL_MERCHANT_ID (کلیدِ زرینپال ست نشده)، هم request_payment و
|
||||
هم verify_payment fail-closed عمل میکنن؛ هیچوقت پرداختِ ساختگی موفق فرض
|
||||
نمیشه.
|
||||
"""
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZarinpalNotConfigured(APIException):
|
||||
"""بدونِ ZARINPAL_MERCHANT_ID/ZARINPAL_CALLBACK_URL امکانِ اتصال به درگاه نیست."""
|
||||
|
||||
status_code = 503
|
||||
default_detail = 'درگاهِ پرداخت هنوز پیکربندی نشده است.'
|
||||
default_code = 'zarinpal_not_configured'
|
||||
|
||||
|
||||
class ZarinpalAPIError(APIException):
|
||||
"""خطای موقتِ سرویسِ زرینپال (شبکه/HTTP/پاسخِ نامعتبر)."""
|
||||
|
||||
status_code = 503
|
||||
default_detail = 'در حال حاضر امکانِ اتصال به درگاهِ پرداخت وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||||
default_code = 'zarinpal_api_unavailable'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZarinpalRequestResult:
|
||||
authority: str
|
||||
payment_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZarinpalVerifyResult:
|
||||
is_valid: bool
|
||||
ref_id: str = ''
|
||||
raw: dict | None = None
|
||||
|
||||
|
||||
def request_payment(order) -> ZarinpalRequestResult:
|
||||
"""PaymentRequest — یک authority تازه برای این سفارش میگیره."""
|
||||
if not settings.ZARINPAL_MERCHANT_ID or not settings.ZARINPAL_CALLBACK_URL:
|
||||
raise ZarinpalNotConfigured()
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f'{settings.ZARINPAL_API_BASE}/request.json',
|
||||
json={
|
||||
'merchant_id': settings.ZARINPAL_MERCHANT_ID,
|
||||
'amount': order.amount,
|
||||
'currency': order.currency,
|
||||
'callback_url': settings.ZARINPAL_CALLBACK_URL,
|
||||
'description': f'سفارش #{order.id} — تدریسینو',
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
logger.exception('Zarinpal payment-request failed')
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
try:
|
||||
result_data = resp.json()['data']
|
||||
code = result_data['code']
|
||||
authority = result_data['authority']
|
||||
except (ValueError, KeyError, TypeError):
|
||||
logger.error('Zarinpal payment-request response malformed: status=%s', resp.status_code)
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
if code != 100 or not authority:
|
||||
logger.error('Zarinpal payment-request rejected: code=%s', code)
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
return ZarinpalRequestResult(
|
||||
authority=authority,
|
||||
payment_url=f'{settings.ZARINPAL_STARTPAY_BASE}/{authority}',
|
||||
)
|
||||
|
||||
|
||||
def verify_payment(*, authority: str, amount: int, currency: str) -> ZarinpalVerifyResult:
|
||||
"""PaymentVerification — تاییدِ سرور-به-سرورِ پرداخت بعدِ برگشتِ کاربر از درگاه."""
|
||||
if not settings.ZARINPAL_MERCHANT_ID:
|
||||
return ZarinpalVerifyResult(is_valid=False, raw={'error': 'zarinpal_not_configured'})
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f'{settings.ZARINPAL_API_BASE}/verify.json',
|
||||
json={
|
||||
'merchant_id': settings.ZARINPAL_MERCHANT_ID,
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'authority': authority,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
except requests.RequestException:
|
||||
logger.exception('Zarinpal payment-verify request failed')
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
try:
|
||||
result_data = resp.json()['data']
|
||||
code = result_data.get('code')
|
||||
except (ValueError, KeyError, TypeError):
|
||||
logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code)
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
# ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (idempotent از سمتِ زرینپال) — هر دو معتبرن.
|
||||
if code in (100, 101):
|
||||
return ZarinpalVerifyResult(is_valid=True, ref_id=str(result_data.get('ref_id', '')), raw=result_data)
|
||||
return ZarinpalVerifyResult(is_valid=False, raw=result_data)
|
||||
Reference in New Issue
Block a user