diff --git a/.env.example b/.env.example index 7f65ff0..438ecf5 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,19 @@ RAG_RETRIEVAL_TOP_K=4 # RAG_FORCE_MOCK=1 یعنی حتی با وجودِ کلیدها هم مسیرِ RAG خاموش بمونه. RAG_FORCE_MOCK= +# ----------------------------------------------------------------------- +# زرین‌پال (اپ payment) — درگاهِ اصلیِ پرداختِ ما +# ----------------------------------------------------------------------- +# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed می‌مونه (خطای صریحِ +# ۵۰۳ به‌جایِ پرداختِ ساختگیِ موفق). +PUBLIC_BASE_URL= +ZARINPAL_MERCHANT_ID= +# 1 = محیطِ sandbox زرین‌پال (برای تست بدونِ پرداختِ واقعی) +ZARINPAL_SANDBOX=1 +# اختیاری — بعدِ تاییدِ پرداخت، کاربر به این URL (مثلاً یک deep link اپ) با +# ?orderId=...&status=... ریدایرکت می‌شه. خالی = پاسخِ JSON ساده. +ZARINPAL_REDIRECT_URL= + # ----------------------------------------------------------------------- # تنظیماتِ مقیاس‌پذیری (همه دیفالتِ امن دارن) # ----------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 0a56834..69f66e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`. | `progress` | stats, leaderboard, streaks | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` | | `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` | | `subscription` | plans | `Plan`, `Subscription` | -| `payment` | orders/transactions (incl. Cafe Bazaar IAP) | `Order`, `Transaction` | +| `payment` | orders/transactions (Zarinpal gateway + Cafe Bazaar IAP) | `Order`, `Transaction` | | `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` | | `notifications` | notifications | `Notification` | | `affiliate` | referral program | `AffiliateAccount`, `Conversion` | @@ -60,6 +60,8 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`. **SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success. +**Payment (Zarinpal)**: `create_order`/`CreateOrderView` never call Zarinpal directly — `paymentUrl` points at our own `GET /api/payment/orders/{id}/pay` (`PaymentRedirectView`), which calls Zarinpal's PaymentRequest API on first visit (storing `Order.gateway_authority`) and redirects to StartPay; this keeps order creation from depending on Zarinpal's uptime. Zarinpal redirects the user back to `GET /api/payment/zarinpal/callback` (`ZarinpalCallbackView`), which always re-verifies server-to-server via `payment/zarinpal.py::verify_payment` before marking an order paid — a forged `Status=OK` in the query string alone does nothing. Both `payment/zarinpal.py` and `payment/bazaar.py` fail closed (503 / invalid) when their credentials aren't configured, never a fake success. + ## Known gaps (per README pre-deploy checklist) - Still on SQLite, not Postgres. diff --git a/config/settings.py b/config/settings.py index 7183732..bc19740 100644 --- a/config/settings.py +++ b/config/settings.py @@ -278,6 +278,7 @@ REST_FRAMEWORK = { 'user_profile_write': '60/hour', 'affiliate_track': '10/hour', 'payment_webhook': '60/hour', + 'payment_pay': '30/hour', }, } @@ -389,6 +390,35 @@ BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '') BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '') BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') +# ----------------------------------------------------------------------- +# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4) +# ----------------------------------------------------------------------- +# آدرسِ عمومیِ خودِ بک‌اند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمی‌گردد +# (PaymentRedirectView) و هم برای callback_url که به زرین‌پال می‌فرستیم لازم +# است. بدونش این دو URL شکسته می‌مانند — سیگنالِ واضحی که هنوز ست نشده. +PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', '').rstrip('/') + +ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '') +ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1' +ZARINPAL_API_BASE = os.environ.get( + 'ZARINPAL_API_BASE', + 'https://sandbox.zarinpal.com/pg/v4/payment' if ZARINPAL_SANDBOX + else 'https://api.zarinpal.com/pg/v4/payment', +) +ZARINPAL_STARTPAY_BASE = os.environ.get( + 'ZARINPAL_STARTPAY_BASE', + 'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX + else 'https://www.zarinpal.com/pg/StartPay', +) +ZARINPAL_CALLBACK_URL = os.environ.get( + 'ZARINPAL_CALLBACK_URL', + f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '', +) +# اختیاری: بعدِ verify، مرورگر/کلاینت را به این URL (مثلاً یک deep link اپ) +# با ?orderId=...&status=... ریدایرکت می‌کنیم. اگه خالی باشه، همون‌جا یک +# پاسخِ JSON ساده برمی‌گرده. +ZARINPAL_REDIRECT_URL = os.environ.get('ZARINPAL_REDIRECT_URL', '') + # ----------------------------------------------------------------------- # RAG / AI provider (اپ rag + ai_tutor.generate_tutor_answer) # ----------------------------------------------------------------------- diff --git a/payment/migrations/0006_order_gateway_authority.py b/payment/migrations/0006_order_gateway_authority.py new file mode 100644 index 0000000..e90e954 --- /dev/null +++ b/payment/migrations/0006_order_gateway_authority.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-09-06 09:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0005_remove_transaction_uniq_transaction_order_ref_status'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='gateway_authority', + field=models.CharField(blank=True, db_index=True, max_length=64), + ), + ] diff --git a/payment/models.py b/payment/models.py index b0195dc..5476c3e 100644 --- a/payment/models.py +++ b/payment/models.py @@ -24,6 +24,12 @@ class Order(models.Model): # بازار می‌گیره و برای verify به بک‌اند می‌فرسته. bazaar_product_sku = models.CharField(max_length=100, blank=True) bazaar_purchase_token = models.CharField(max_length=300, blank=True) + # فقط وقتی provider == 'gateway' (زرین‌پال) پر می‌شه: Authorityِ برگشتی از + # PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ + # ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرین‌پال + # موقعِ callback در query string (Authority=...) برمی‌گردونه، پس برای + # پیداکردنِ Order از روی callback لازمه. + gateway_authority = models.CharField(max_length=64, blank=True, db_index=True) idempotency_key = models.CharField(max_length=100, unique=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/payment/serializers.py b/payment/serializers.py index 5b95e54..38bc640 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -53,6 +53,13 @@ class WebhookResponseSerializer(serializers.Serializer): received = serializers.BooleanField() +class ZarinpalCallbackResponseSerializer(serializers.Serializer): + """فقط وقتی settings.ZARINPAL_REDIRECT_URL خالی باشه برگردونده می‌شه؛ + وگرنه کاربر به همون URL ریدایرکت می‌شه، نه به این JSON.""" + orderId = serializers.CharField() + status = serializers.CharField() + + class TransactionSerializer(serializers.Serializer): id = serializers.CharField() gateway = serializers.CharField() diff --git a/payment/services.py b/payment/services.py index 82b5ca1..0be0901 100644 --- a/payment/services.py +++ b/payment/services.py @@ -4,6 +4,7 @@ جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم پیاده‌سازی شده، نه یک HTTP call به خودمون. """ +from django.conf import settings from django.db import IntegrityError from django.db import transaction as db_transaction from django.utils import timezone @@ -38,11 +39,15 @@ def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currenc def call_payment_gateway(order: Order) -> str: """ - استاب فراخوانی درگاه پرداخت واقعی؛ فعلاً یک paymentUrl ساختگی برمی‌گرداند - تا کل جریان (ساخت سفارش → پرداخت → webhook) بدون کلید API واقعی قابل تست باشد. - برای اتصال واقعی، این تابع با فراخوانی HTTP به API درگاه جایگزین شود. + paymentUrlی که به کلاینت برمی‌گردد: آدرسِ داخلیِ خودمان + (PaymentRedirectView)، نه مستقیماً StartPay زرین‌پال. فراخوانیِ واقعیِ + PaymentRequest به زرین‌پال (یک HTTP call خارجی) عمداً همین‌جا انجام + نمی‌شود — create_order از subscription صدا زده می‌شود و نباید موفقیتِ + ساختِ سفارش به دسترس‌بودن/سرعتِ زرین‌پال گره بخورد؛ PaymentRequest اولین + باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به + payment/zarinpal.py و PaymentRedirectView در views.py). """ - return f'https://payment.gateway/pay/{order.id}' + return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay' def create_order(*, user_id: int, amount: int, currency: str = 'IRT', diff --git a/payment/tests.py b/payment/tests.py index 28bccc0..fb8c834 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -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', + ) diff --git a/payment/urls.py b/payment/urls.py index 4523121..3d242d6 100644 --- a/payment/urls.py +++ b/payment/urls.py @@ -4,12 +4,16 @@ from .views import ( BazaarVerifyPurchaseView, CreateOrderView, OrderDetailView, + PaymentRedirectView, PaymentWebhookView, + ZarinpalCallbackView, ) urlpatterns = [ path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'), path('payment/webhook', PaymentWebhookView.as_view(), name='payment-webhook'), + path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'), path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'), path('payment/orders/', OrderDetailView.as_view(), name='payment-order-detail'), + path('payment/orders//pay', PaymentRedirectView.as_view(), name='payment-order-pay'), ] diff --git a/payment/views.py b/payment/views.py index bb8cba2..55a89e1 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,13 +1,17 @@ +from urllib.parse import urlsplit + +from django.conf import settings from django.db import transaction +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView -from . import bazaar +from . import bazaar, zarinpal from .models import Order from .permissions import HasInternalServiceKey, HasWebhookSecret from .serializers import ( @@ -18,10 +22,27 @@ from .serializers import ( OrderDetailResponseSerializer, WebhookRequestSerializer, WebhookResponseSerializer, + ZarinpalCallbackResponseSerializer, ) from .services import apply_order_payment_result, create_order +def _redirect_to_configured_url(url: str) -> HttpResponseRedirect: + """ + HttpResponseRedirect معمولی فقط اسکیمِ http/https/ftp رو قبول می‌کنه؛ + ZARINPAL_REDIRECT_URL ممکنه یک deep link با اسکیمِ سفارشیِ اپ باشه + (مثلاً tadrisino://...). چون این URL از settings میاد (نه از ورودیِ + کاربر)، اضافه‌کردنِ اسکیمش به allowed_schemes امن است. + """ + scheme = urlsplit(url).scheme + redirect_cls = HttpResponseRedirect + if scheme and scheme not in redirect_cls.allowed_schemes: + redirect_cls = type('ConfiguredSchemeRedirect', (HttpResponseRedirect,), { + 'allowed_schemes': [*HttpResponseRedirect.allowed_schemes, scheme], + }) + return redirect_cls(url) + + class GuestAwareBazaarThrottle(ScopedRateThrottle): """همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify @@ -96,13 +117,16 @@ class CreateOrderView(APIView): class PaymentWebhookView(APIView): """ - POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت) + POST /api/payment/webhook — وب‌هوکِ عمومیِ shared-secret (نه مخصوصِ زرین‌پال). - محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی - نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک - فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره - Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره - می‌فرسته). + جریانِ واقعیِ زرین‌پال از PaymentRedirectView/ZarinpalCallbackView رد + می‌شه (زرین‌پال خودش webhookِ سرور-به-سرور نمی‌زنه، فقط مرورگرِ کاربر رو + ریدایرکت می‌کنه). این endpoint برای تاییدِ دستی/تستی یا درگاه‌های دیگه‌ای + که واقعاً webhook POST می‌زنن نگه داشته شده؛ محافظت‌شده با + HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک + orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه. + همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/ + notify تکراری نمی‌سازه. """ permission_classes = [HasWebhookSecret] @@ -149,6 +173,98 @@ class PaymentWebhookView(APIView): return Response(WebhookResponseSerializer({'received': True}).data) +class PaymentRedirectView(APIView): + """ + GET /api/payment/orders/{id}/pay — همان paymentUrlی که + CreateOrderResponseSerializer برای provider='gateway' برمی‌گرداند؛ + کلاینت (مرورگر/وب‌ویو) این را باز می‌کند. + + فراخوانیِ واقعیِ PaymentRequest به زرین‌پال عمداً همین‌جاست، نه در + create_order: create_order از subscription صدا زده می‌شود و نباید + موفقیتِ ساختِ سفارش به دسترس‌بودنِ زرین‌پال گره بخورد. اولین بار که این + URL باز شود authority ساخته و روی سفارش ذخیره می‌شود؛ بازکردنِ دوباره + (مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده + می‌کند، نه یک PaymentRequest تازه. + """ + + permission_classes = [AllowAny] + throttle_scope = 'payment_pay' + throttle_classes = [ScopedRateThrottle] + + def get(self, request, order_id): + order = get_object_or_404(Order, pk=order_id, provider='gateway') + + if order.status != 'pending': + # سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست. + return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/') + + if not order.gateway_authority: + result = zarinpal.request_payment(order) + # ⚠️ محافظت در برابرِ race: اگه بینِ خواندنِ بالا و اینجا یک + # درخواستِ هم‌زمانِ دیگر authority ساخته باشد، همان مقدارِ اول را + # نگه می‌داریم، نه این result تازه را (فقط وقتی هنوز خالی است آپدیت می‌شود). + Order.objects.filter(pk=order.pk, gateway_authority='').update( + gateway_authority=result.authority, + ) + order.refresh_from_db(fields=['gateway_authority']) + + return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}') + + +class ZarinpalCallbackView(APIView): + """ + GET /api/payment/zarinpal/callback — کال‌بکی که زرین‌پال بعدِ پرداخت، + مرورگرِ کاربر رو بهش ریدایرکت می‌کنه (Authority, Status در query string). + + برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)، + اینجا caller خودِ مرورگرِ کاربره، نه زرین‌پال مستقیماً — پس هیچ + هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک + فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK + رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرین‌پال چیزی paid نمی‌شه. + """ + + permission_classes = [AllowAny] + throttle_scope = 'payment_webhook' + throttle_classes = [ScopedRateThrottle] + + @extend_schema( + parameters=[ + OpenApiParameter('Authority', OpenApiTypes.STR, required=True), + OpenApiParameter('Status', OpenApiTypes.STR, required=True), + ], + responses=ZarinpalCallbackResponseSerializer, + tags=['Payment (payment)'], + ) + def get(self, request): + authority = request.query_params.get('Authority', '') + status_param = request.query_params.get('Status', '') + order = get_object_or_404(Order, gateway_authority=authority, provider='gateway') + + if status_param != 'OK': + new_status, gateway_ref_id = 'failed', '' + else: + result = zarinpal.verify_payment( + authority=authority, amount=order.amount, currency=order.currency, + ) + new_status = 'paid' if result.is_valid else 'failed' + gateway_ref_id = result.ref_id + + with transaction.atomic(): + locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk) + apply_order_payment_result( + order=locked_order, gateway='zarinpal', gateway_ref_id=gateway_ref_id, + new_status=new_status, event='zarinpal_callback', + ) + order.refresh_from_db(fields=['status']) + + if settings.ZARINPAL_REDIRECT_URL: + return _redirect_to_configured_url( + f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}' + ) + data = {'orderId': str(order.id), 'status': order.status} + return Response(ZarinpalCallbackResponseSerializer(data).data) + + class BazaarVerifyPurchaseView(APIView): """ POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار diff --git a/payment/zarinpal.py b/payment/zarinpal.py new file mode 100644 index 0000000..b406b92 --- /dev/null +++ b/payment/zarinpal.py @@ -0,0 +1,132 @@ +""" +کلاینت سرور-به-سرورِ درگاهِ زرین‌پال (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)