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