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:
2026-09-06 13:27:13 +03:30
parent a275eac7fd
commit b5476e1c5d
11 changed files with 630 additions and 14 deletions

View File

@@ -53,6 +53,19 @@ RAG_RETRIEVAL_TOP_K=4
# RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه. # RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه.
RAG_FORCE_MOCK= 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=
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# تنظیماتِ مقیاس‌پذیری (همه دیفالتِ امن دارن) # تنظیماتِ مقیاس‌پذیری (همه دیفالتِ امن دارن)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------

View File

@@ -36,7 +36,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
| `progress` | stats, leaderboard, streaks | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` | | `progress` | stats, leaderboard, streaks | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` |
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` | | `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
| `subscription` | plans | `Plan`, `Subscription` | | `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` | | `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
| `notifications` | notifications | `Notification` | | `notifications` | notifications | `Notification` |
| `affiliate` | referral program | `AffiliateAccount`, `Conversion` | | `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. **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) ## Known gaps (per README pre-deploy checklist)
- Still on SQLite, not Postgres. - Still on SQLite, not Postgres.

View File

@@ -278,6 +278,7 @@ REST_FRAMEWORK = {
'user_profile_write': '60/hour', 'user_profile_write': '60/hour',
'affiliate_track': '10/hour', 'affiliate_track': '10/hour',
'payment_webhook': '60/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_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') 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) # RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------

View 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),
),
]

View File

@@ -24,6 +24,12 @@ class Order(models.Model):
# بازار می‌گیره و برای verify به بک‌اند می‌فرسته. # بازار می‌گیره و برای verify به بک‌اند می‌فرسته.
bazaar_product_sku = models.CharField(max_length=100, blank=True) bazaar_product_sku = models.CharField(max_length=100, blank=True)
bazaar_purchase_token = models.CharField(max_length=300, 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) idempotency_key = models.CharField(max_length=100, unique=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -53,6 +53,13 @@ class WebhookResponseSerializer(serializers.Serializer):
received = serializers.BooleanField() received = serializers.BooleanField()
class ZarinpalCallbackResponseSerializer(serializers.Serializer):
"""فقط وقتی settings.ZARINPAL_REDIRECT_URL خالی باشه برگردونده می‌شه؛
وگرنه کاربر به همون URL ریدایرکت می‌شه، نه به این JSON."""
orderId = serializers.CharField()
status = serializers.CharField()
class TransactionSerializer(serializers.Serializer): class TransactionSerializer(serializers.Serializer):
id = serializers.CharField() id = serializers.CharField()
gateway = serializers.CharField() gateway = serializers.CharField()

View File

@@ -4,6 +4,7 @@
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون. پیاده‌سازی شده، نه یک HTTP call به خودمون.
""" """
from django.conf import settings
from django.db import IntegrityError from django.db import IntegrityError
from django.db import transaction as db_transaction from django.db import transaction as db_transaction
from django.utils import timezone 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: def call_payment_gateway(order: Order) -> str:
""" """
استاب فراخوانی درگاه پرداخت واقعی؛ فعلاً یک paymentUrl ساختگی برمی‌گرداند paymentUrlی که به کلاینت برمی‌گردد: آدرسِ داخلیِ خودمان
تا کل جریان (ساخت سفارش → پرداخت → webhook) بدون کلید API واقعی قابل تست باشد. (PaymentRedirectView)، نه مستقیماً StartPay زرین‌پال. فراخوانیِ واقعیِ
برای اتصال واقعی، این تابع با فراخوانی HTTP به API درگاه جایگزین شود. 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', def create_order(*, user_id: int, amount: int, currency: str = 'IRT',

View File

@@ -10,10 +10,11 @@ from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token from accounts.authentication import issue_access_token
from accounts.models import User from accounts.models import User
from . import bazaar from . import bazaar, zarinpal
from .bazaar import BazaarPurchaseResult from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret from .permissions import HasInternalServiceKey, HasWebhookSecret
from .services import call_payment_gateway
class SecretDefaultsFailClosedTests(TestCase): class SecretDefaultsFailClosedTests(TestCase):
@@ -364,3 +365,285 @@ class BazaarVerifyPurchaseGuestThrottleBypassTests(TestCase):
self.assertEqual(order2.status, 'pending') self.assertEqual(order2.status, 'pending')
self.assertEqual(order2.bazaar_purchase_token, '') self.assertEqual(order2.bazaar_purchase_token, '')
self.assertFalse(Transaction.objects.filter(order=order2).exists()) 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',
)

View File

@@ -4,12 +4,16 @@ from .views import (
BazaarVerifyPurchaseView, BazaarVerifyPurchaseView,
CreateOrderView, CreateOrderView,
OrderDetailView, OrderDetailView,
PaymentRedirectView,
PaymentWebhookView, PaymentWebhookView,
ZarinpalCallbackView,
) )
urlpatterns = [ urlpatterns = [
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'), path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'), 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/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>', OrderDetailView.as_view(), name='payment-order-detail'),
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),
] ]

View File

@@ -1,13 +1,17 @@
from urllib.parse import urlsplit
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema 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.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from . import bazaar from . import bazaar, zarinpal
from .models import Order from .models import Order
from .permissions import HasInternalServiceKey, HasWebhookSecret from .permissions import HasInternalServiceKey, HasWebhookSecret
from .serializers import ( from .serializers import (
@@ -18,10 +22,27 @@ from .serializers import (
OrderDetailResponseSerializer, OrderDetailResponseSerializer,
WebhookRequestSerializer, WebhookRequestSerializer,
WebhookResponseSerializer, WebhookResponseSerializer,
ZarinpalCallbackResponseSerializer,
) )
from .services import apply_order_payment_result, create_order 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): class GuestAwareBazaarThrottle(ScopedRateThrottle):
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ """همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify
@@ -96,13 +117,16 @@ class CreateOrderView(APIView):
class PaymentWebhookView(APIView): class PaymentWebhookView(APIView):
""" """
POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت) POST /api/payment/webhook — وب‌هوکِ عمومیِ shared-secret (نه مخصوصِ زرین‌پال).
محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی جریانِ واقعیِ زرین‌پال از PaymentRedirectView/ZarinpalCallbackView رد
نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک می‌شه (زرین‌پال خودش webhookِ سرور-به-سرور نمی‌زنه، فقط مرورگرِ کاربر رو
فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره ریدایرکت می‌کنه). این endpoint برای تاییدِ دستی/تستی یا درگاه‌های دیگه‌ای
Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره که واقعاً webhook POST می‌زنن نگه داشته شده؛ محافظت‌شده با
می‌فرسته). HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
notify تکراری نمی‌سازه.
""" """
permission_classes = [HasWebhookSecret] permission_classes = [HasWebhookSecret]
@@ -149,6 +173,98 @@ class PaymentWebhookView(APIView):
return Response(WebhookResponseSerializer({'received': True}).data) 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): class BazaarVerifyPurchaseView(APIView):
""" """
POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار

132
payment/zarinpal.py Normal file
View 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)