Files
MathApp/payment/zarinpal.py
Zareian b5476e1c5d 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>
2026-09-06 13:27:13 +03:30

133 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
کلاینت سرور-به-سرورِ درگاهِ زرین‌پال (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)