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

@@ -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',
)