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>
144 lines
6.4 KiB
Python
144 lines
6.4 KiB
Python
"""
|
||
کلاینت سرور-به-سرورِ درگاهِ زرینپال (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)
|