Files
MathApp/payment/zarinpal.py
Zareian 0580aec7b0 harden OTP for real SMS and close the second audit round
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:28:06 +03:30

144 lines
6.4 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:
body = resp.json()
result_data = body['data']
except (ValueError, KeyError, TypeError):
logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code)
raise ZarinpalAPIError()
# زرین‌پال روی خطا (مثلاً -51 پرداختِ ناموفق) `"data": []` و کدِ خطا را در
# `errors` برمی‌گرداند. این یک «رد»ِ صریح است، نه خرابیِ شبکه، پس مثلِ هر کدِ
# غیرِ ۱۰۰/۱۰۱ نامعتبر حساب می‌شود. پیش از ۲۰۲۶-۰۹-۲۵ اینجا `[].get` کرش
# می‌کرد و کاربر به‌جای برگشت به اپ صفحه‌ی ۵۰۰ می‌دید. با بدنه‌ی مستندِ
# زرین‌پال تست شده، نه با درگاهِ واقعی.
if not isinstance(result_data, dict):
errors = body.get('errors') if isinstance(body, dict) else None
logger.warning('Zarinpal payment-verify rejected: errors=%s', errors)
return ZarinpalVerifyResult(is_valid=False, raw=body if isinstance(body, dict) else None)
code = result_data.get('code')
# ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (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)