tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed

Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

View File

View File

@@ -0,0 +1,28 @@
"""
سفارش‌های pendingِ زرین‌پال را دوباره verify می‌کند.
همین کار برای هر کاربر خودکار هم انجام می‌شود (وقتی اپ وضعیتِ اشتراک یا
سفارش را می‌پرسد)؛ این فرمان برای وقتی است که بخواهید همه را یک‌جا ببینید:
python manage.py payment_reconcile --days 3
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from payment.models import Order
from payment.services import reconcile_pending_order
class Command(BaseCommand):
help = 'verifyِ دوباره‌ی سفارش‌های pendingِ زرین‌پال'
def add_arguments(self, parser):
parser.add_argument('--days', type=int, default=3)
def handle(self, *args, **opts):
since = timezone.now() - timezone.timedelta(days=opts['days'])
orders = list(Order.objects.filter(
status='pending', provider='gateway', created_at__gte=since,
).exclude(gateway_authority=''))
fixed = sum(1 for order in orders if reconcile_pending_order(order))
self.stdout.write(f'{len(orders)} سفارشِ pending بررسی شد؛ {fixed} سفارش paid شد.')

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-25 02:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0006_order_gateway_authority'),
]
operations = [
migrations.AddField(
model_name='order',
name='plan_id',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -15,6 +15,10 @@ class Order(models.Model):
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
# پلنی که *این* سفارش برایش پرداخت می‌شود. پیش‌تر پلن فقط روی Subscription
# بود و با شروعِ یک خریدِ دیگر عوض می‌شد: پرداختِ سفارشِ ماهانه بعد از
# شروعِ خریدِ سالانه، اشتراکِ سالانه می‌داد (بازتولید شد، ۲۰۲۶-۰۹-۲۵).
plan_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Plan
amount = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')

View File

@@ -1,3 +1,5 @@
import secrets
from django.conf import settings
from rest_framework.permissions import BasePermission
@@ -14,18 +16,7 @@ class HasInternalServiceKey(BasePermission):
def has_permission(self, request, view):
provided = request.headers.get('X-Internal-Key', '')
return bool(provided) and provided == settings.INTERNAL_SERVICE_KEY
class HasWebhookSecret(BasePermission):
"""
برای POST /api/payment/webhook: باید از سمت درگاه پرداخت بیاد، نه یک
کلاینت دلخواه. تا زمان اتصال درگاه واقعی (که خودش امضای HMAC داره)،
یک shared-secret ساده از طریق هدر X-Webhook-Secret بررسی می‌شه.
"""
message = 'امضای وب‌هوک نامعتبر است.'
def has_permission(self, request, view):
provided = request.headers.get('X-Webhook-Secret', '')
return bool(provided) and provided == settings.PAYMENT_WEBHOOK_SECRET
expected = settings.INTERNAL_SERVICE_KEY
# مقایسه‌ی زمان‌ثابت: == در اولین نویسه‌ی متفاوت برمی‌گردد و از زمانِ پاسخ
# می‌شود کلید را نویسه‌به‌نویسه حدس زد.
return bool(provided) and bool(expected) and secrets.compare_digest(provided, expected)

View File

@@ -37,22 +37,6 @@ class CreateOrderResponseSerializer(serializers.Serializer):
provider = serializers.CharField()
class WebhookRequestSerializer(serializers.Serializer):
# کال‌بک از سمت درگاه پرداخت میاد، نه از اپ
orderId = serializers.CharField()
gateway = serializers.CharField(required=False, default='zarinpal')
gatewayRefId = serializers.CharField(required=False, allow_blank=True)
# ⚠️ اصلاح امنیتی: قبلاً status یک CharField آزاد بود و هر مقداری
# (حتی مقادیر پشتیبانی‌نشده/اشتباه) رو مستقیم روی Order ذخیره می‌کرد.
# حالا فقط دو وضعیت نهاییِ معتبرِ کال‌بک درگاه پذیرفته می‌شه؛ 'pending'
# وضعیت اولیه‌ی داخلیه و نباید از بیرون توسط webhook قابل‌تنظیم باشه.
status = serializers.ChoiceField(choices=['paid', 'failed'])
class WebhookResponseSerializer(serializers.Serializer):
received = serializers.BooleanField()
class ZarinpalCallbackResponseSerializer(serializers.Serializer):
"""فقط وقتی settings.ZARINPAL_REDIRECT_URL خالی باشه برگردونده می‌شه؛
وگرنه کاربر به همون URL ریدایرکت می‌شه، نه به این JSON."""

View File

@@ -25,13 +25,17 @@ class IdempotencyKeyConflict(APIException):
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
subscription_id: int | None) -> None:
subscription_id: int | None, plan_id: int | None = None) -> None:
if order.user_id != user_id:
raise IdempotencyKeyConflict(
'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.'
)
if (order.amount != amount or order.currency != currency
or order.subscription_id != subscription_id):
# مبلغ فقط برای سفارشی که هنوز تخفیف نگرفته مقایسه می‌شود؛ بعد از کدِ
# تخفیف، مبلغِ سفارش عمداً کمتر از قیمتِ پلن است.
discounted = order.amount < amount and order.status == 'pending'
if ((order.amount != amount and not discounted) or order.currency != currency
or order.subscription_id != subscription_id
or (plan_id is not None and order.plan_id not in (None, plan_id))):
raise IdempotencyKeyConflict(
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
)
@@ -52,7 +56,8 @@ def call_payment_gateway(order: Order) -> str:
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
subscription_id: int | None = None, idempotency_key: str | None = None,
provider: str = 'gateway', bazaar_product_sku: str = ''):
provider: str = 'gateway', bazaar_product_sku: str = '',
plan_id: int | None = None):
"""
ساخت سفارش با پشتیبانی از Idempotency-Key: اگر سفارشی با همین کلید قبلاً
ساخته شده باشد، همان رکورد برگردانده می‌شود (نه رکورد تکراری)، فقط اگر
@@ -65,7 +70,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
if existing is not None:
_ensure_idempotent_match(
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
subscription_id=subscription_id, plan_id=plan_id,
)
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
@@ -75,6 +80,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
plan_id=plan_id,
amount=amount,
currency=currency,
status='pending',
@@ -89,7 +95,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
raise
_ensure_idempotent_match(
order, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id,
subscription_id=subscription_id, plan_id=plan_id,
)
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً
@@ -101,7 +107,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str,
new_status: str, event: str) -> bool:
"""
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای PaymentWebhookView و هم
منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای ZarinpalCallbackView و هم
BazaarVerifyPurchaseView استفاده می‌شود.
idempotent: اگر Transaction مشابه (order/status/gateway_ref_id) از قبل
@@ -167,7 +173,10 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
activate_subscription(order.subscription_id)
activate_subscription(order.subscription_id, plan_id=order.plan_id)
# پورسانتِ معرف فقط روی پرداختِ واقعی (نه ثبت‌نام، نه مهمان).
from affiliate.services import record_paid_order
record_paid_order(order)
from notifications.services import notify
notify(
@@ -178,3 +187,44 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
)
return newly_paid
def reconcile_pending_order(order: Order) -> bool:
"""
سفارشی که کاربر در درگاه پرداخت کرده ولی تأییدش نرسیده (مثلاً زرین‌پال
هنگامِ کال‌بک جواب نداد) را دوباره با verify می‌سنجد. پیش‌تر چنین سفارشی
برای همیشه pending می‌ماند: پولِ دانش‌آموز گرفته شده بود و اشتراکی در کار نبود.
فقط به سمتِ paid می‌برد، هرگز failed نمی‌کند: کاربری که هنوز در صفحه‌ی
درگاه است نباید سفارشش زیرِ پایش باطل شود. هر سفارش حداکثر دقیقه‌ای یک بار
سنجیده می‌شود. خروجی: True اگر همین فراخوانی سفارش را paid کرد.
"""
from django.core.cache import cache
from . import zarinpal
if order.provider != 'gateway' or order.status != 'pending' or not order.gateway_authority:
return False
if not cache.add(f'payment:reconcile:{order.id}', 1, 60):
return False
try:
result = zarinpal.verify_payment(
authority=order.gateway_authority, amount=order.amount, currency=order.currency,
)
except Exception: # noqa: BLE001 — درگاه در دسترس نیست؛ دفعه‌ی بعد
return False
if not result.is_valid:
return False
return apply_order_payment_result(
order=order, gateway='zarinpal', gateway_ref_id=result.ref_id,
new_status='paid', event='zarinpal_reconcile',
)
def reconcile_user_orders(user_id: int) -> None:
"""سفارش‌های pendingِ اخیرِ یک کاربر؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود."""
since = timezone.now() - timezone.timedelta(days=2)
for order in Order.objects.filter(
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
).exclude(gateway_authority='')[:3]:
reconcile_pending_order(order)

View File

@@ -13,13 +13,13 @@ from accounts.models import User
from . import bazaar, zarinpal
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .permissions import HasInternalServiceKey
from .services import call_payment_gateway
class SecretDefaultsFailClosedTests(TestCase):
"""
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY و PAYMENT_WEBHOOK_SECRET
تست‌های متمرکز روی این فیکس: INTERNAL_SERVICE_KEY
دیگه در پروداکشن (DEBUG=False) دیفالتِ قابل‌حدس‌زدن ندارن. اگه env تنظیم
نشده باشه، مقدار خالی می‌مونه و پرمیشن‌های مربوطه صراحتاً رد می‌کنن
(fail closed)، نه اینکه با یک مقدارِ شناخته‌شده قبول کنن.
@@ -35,15 +35,9 @@ class SecretDefaultsFailClosedTests(TestCase):
# settings.INTERNAL_SERVICE_KEY خودش خالیه، درخواست باید رد بشه.
self.assertFalse(HasInternalServiceKey().has_permission(request, None))
@override_settings(DEBUG=False, PAYMENT_WEBHOOK_SECRET='')
def test_production_missing_webhook_secret_rejects(self):
request = self.factory.post('/api/payment/webhook')
self.assertFalse(HasWebhookSecret().has_permission(request, None))
@override_settings(
DEBUG=False,
INTERNAL_SERVICE_KEY='prod-internal-key-xyz',
PAYMENT_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
def test_production_configured_secrets_accepts(self):
internal_request = self.factory.post(
@@ -51,11 +45,6 @@ class SecretDefaultsFailClosedTests(TestCase):
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
webhook_request = self.factory.post(
'/api/payment/webhook', HTTP_X_WEBHOOK_SECRET='prod-webhook-secret-xyz',
)
self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None))
def test_debug_mode_local_fallback_still_valid(self):
# settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
@@ -63,7 +52,7 @@ class SecretDefaultsFailClosedTests(TestCase):
from config import settings as settings_module
env_overrides = {'DJANGO_DEBUG': '1'}
env_removed = {'INTERNAL_SERVICE_KEY', 'PAYMENT_WEBHOOK_SECRET'}
env_removed = {'INTERNAL_SERVICE_KEY'}
clean_env = {
k: v for k, v in os.environ.items() if k not in env_removed
}
@@ -77,10 +66,6 @@ class SecretDefaultsFailClosedTests(TestCase):
settings_module.INTERNAL_SERVICE_KEY,
'dev-internal-service-key-change-me',
)
self.assertEqual(
settings_module.PAYMENT_WEBHOOK_SECRET,
'dev-webhook-secret-change-me',
)
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.

View File

@@ -0,0 +1,167 @@
"""
پول: ایرادهایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید کرد و حالا بسته شده‌اند.
هر تست همان سناریوی واقعی را اجرا می‌کند که پیش‌تر خراب بود.
"""
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from affiliate.models import AffiliateAccount, Conversion
from discount.models import DiscountCode
from payment.models import Order
from payment.services import apply_order_payment_result, reconcile_pending_order
from payment.zarinpal import ZarinpalVerifyResult
from subscription.models import Plan, Subscription
class _Base(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007001', is_guest=False)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.month = Plan.objects.create(title='ماهانه', price=49000, duration_days=30)
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
def tearDown(self):
cache.clear()
def buy(self, plan):
response = self.client.post('/api/subscription/purchase', {'planId': str(plan.id)}, format='json')
self.assertEqual(response.status_code, 200, response.content)
return Order.objects.get(pk=response.json()['orderId'])
def pay(self, order, ref='R'):
return apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
new_status='paid', event='test')
def subscription(self):
return Subscription.objects.get(user_id=self.user.id)
class CheckoutTests(_Base):
def test_paying_the_cheap_order_gives_the_cheap_plan(self):
cheap = self.buy(self.month)
self.buy(self.year) # شروعِ خریدِ دوم، بدونِ پرداخت
self.pay(cheap)
sub = self.subscription()
self.assertEqual(sub.plan, self.month)
days = (sub.expires_at - timezone.now()).days
self.assertIn(days, (29, 30))
def test_opening_checkout_does_not_take_away_an_active_subscription(self):
self.pay(self.buy(self.month), ref='R1')
self.buy(self.year)
self.assertEqual(self.subscription().status, 'active')
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
def test_renewing_early_keeps_the_remaining_days(self):
self.pay(self.buy(self.month), ref='R1')
first_expiry = self.subscription().expires_at
self.pay(self.buy(self.month), ref='R2')
self.assertEqual((self.subscription().expires_at - first_expiry).days, 30)
def test_an_expired_subscription_is_not_reported_active(self):
self.pay(self.buy(self.month), ref='R1')
Subscription.objects.filter(user_id=self.user.id).update(
expires_at=timezone.now() - timezone.timedelta(days=1))
self.assertFalse(self.client.get('/api/subscription/status').json()['active'])
def test_the_mark_paid_webhook_is_gone(self):
order = self.buy(self.month)
response = self.client.post('/api/payment/webhook', {'orderId': order.id, 'status': 'paid'},
format='json', HTTP_X_WEBHOOK_SECRET='anything')
self.assertEqual(response.status_code, 404)
class DiscountTests(_Base):
def setUp(self):
super().setUp()
self.half = DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
self.tenth = DiscountCode.objects.create(code='TENTH', type='percent', value=10, max_uses=10)
self.free = DiscountCode.objects.create(code='FREE', type='percent', value=100, max_uses=10)
def redeem(self, order, code):
return self.client.post('/api/discount/redeem', {'orderId': str(order.id), 'code': code}, format='json')
def test_one_code_per_order(self):
order = self.buy(self.month)
self.assertEqual(self.redeem(order, 'HALF').status_code, 200)
self.assertEqual(self.redeem(order, 'TENTH').status_code, 400)
order.refresh_from_db()
self.assertEqual(order.amount, 24500)
def test_no_discount_after_going_to_the_gateway(self):
order = self.buy(self.month)
Order.objects.filter(pk=order.pk).update(gateway_authority='A0000000000000000000000000000001')
self.assertEqual(self.redeem(order, 'HALF').status_code, 400)
order.refresh_from_db()
self.assertEqual(order.amount, 49000)
def test_a_full_discount_completes_the_purchase_without_a_gateway(self):
order = self.buy(self.month)
body = self.redeem(order, 'FREE').json()
self.assertEqual(body['orderStatus'], 'paid')
self.assertEqual(body['amount'], 0)
self.assertEqual(self.subscription().status, 'active')
class ReconcileTests(_Base):
def test_a_payment_whose_callback_was_lost_is_picked_up(self):
order = self.buy(self.month)
Order.objects.filter(pk=order.pk).update(gateway_authority='A1')
order.refresh_from_db()
with patch('payment.zarinpal.verify_payment',
return_value=ZarinpalVerifyResult(is_valid=True, ref_id='REF9')):
self.assertTrue(self.client.get('/api/subscription/status').json()['active'])
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
def test_an_unpaid_order_is_left_pending_not_failed(self):
order = self.buy(self.month)
Order.objects.filter(pk=order.pk).update(gateway_authority='A2')
order.refresh_from_db()
with patch('payment.zarinpal.verify_payment', return_value=ZarinpalVerifyResult(is_valid=False)):
self.assertFalse(reconcile_pending_order(order))
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class AffiliateTests(_Base):
def setUp(self):
super().setUp()
self.referrer = User.objects.create(phone='09120007002', is_guest=False)
self.account = AffiliateAccount.objects.create(user_id=self.referrer.id, referral_code='SARA10',
commission_rate=0.1)
def test_commission_is_recorded_on_the_first_real_payment(self):
self.assertEqual(self.client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
format='json').status_code, 200)
self.pay(self.buy(self.month), ref='R1')
conversion = Conversion.objects.get(referred_user_id=self.user.id)
self.assertEqual(conversion.status, 'paid')
self.assertEqual(conversion.commission_amount, 4900)
self.pay(self.buy(self.month), ref='R2') # فقط اولین پرداخت
self.assertEqual(Conversion.objects.get(pk=conversion.pk).commission_amount, 4900)
referrer = APIClient()
referrer.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.referrer.id)}')
me = referrer.get('/api/affiliate/me').json()
self.assertEqual((me['referredCount'], me['paidCount'], me['totalCommission']), (1, 1, 4900))
def test_guests_take_no_part(self):
guest = User.objects.create(device_id='dev-aff', is_guest=True)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(guest.id)}')
self.assertEqual(client.post('/api/affiliate/track', {'referralCode': 'SARA10'},
format='json').status_code, 403)
self.assertEqual(client.get('/api/affiliate/me').status_code, 403)
self.assertFalse(Conversion.objects.exists())

View File

@@ -5,13 +5,11 @@ from .views import (
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/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),

View File

@@ -13,18 +13,16 @@ from rest_framework.views import APIView
from . import bazaar, zarinpal
from .models import Order
from .permissions import HasInternalServiceKey, HasWebhookSecret
from .permissions import HasInternalServiceKey
from .serializers import (
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
WebhookRequestSerializer,
WebhookResponseSerializer,
ZarinpalCallbackResponseSerializer,
)
from .services import apply_order_payment_result, create_order
from .services import apply_order_payment_result, create_order, reconcile_pending_order
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
@@ -115,64 +113,6 @@ class CreateOrderView(APIView):
return Response(CreateOrderResponseSerializer(data).data)
class PaymentWebhookView(APIView):
"""
POST /api/payment/webhook — وب‌هوکِ عمومیِ shared-secret (نه مخصوصِ زرین‌پال).
جریانِ واقعیِ زرین‌پال از PaymentRedirectView/ZarinpalCallbackView رد
می‌شه (زرین‌پال خودش webhookِ سرور-به-سرور نمی‌زنه، فقط مرورگرِ کاربر رو
ریدایرکت می‌کنه). این endpoint برای تاییدِ دستی/تستی یا درگاه‌های دیگه‌ای
که واقعاً webhook POST می‌زنن نگه داشته شده؛ محافظت‌شده با
HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
notify تکراری نمی‌سازه.
"""
permission_classes = [HasWebhookSecret]
# این endpoint فقط با مقایسه‌ی هدرِ ثابتِ X-Webhook-Secret محافظت می‌شه،
# پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
# caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
# استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
throttle_scope = 'payment_webhook'
throttle_classes = [ScopedRateThrottle]
# ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI
# قابل تنظیم/تست نبود.
@extend_schema(
request=WebhookRequestSerializer,
responses=WebhookResponseSerializer,
parameters=[
OpenApiParameter(
'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).',
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = WebhookRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
gateway_ref_id = v.get('gatewayRefId', '')
with transaction.atomic():
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
# تکراری از سمت درگاه) یک Transaction جدید می‌ساخت. حالا این
# idempotency + اثرات جانبی (فعال‌سازی اشتراک/notify/statuslog)
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
apply_order_payment_result(
order=order,
gateway=v.get('gateway') or 'zarinpal',
gateway_ref_id=gateway_ref_id,
new_status=v['status'],
event='gateway_webhook',
)
return Response(WebhookResponseSerializer({'received': True}).data)
class PaymentRedirectView(APIView):
"""
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
@@ -226,8 +166,7 @@ class ZarinpalCallbackView(APIView):
GET /api/payment/zarinpal/callback — کال‌بکی که زرین‌پال بعدِ پرداخت،
مرورگرِ کاربر رو بهش ریدایرکت می‌کنه (Authority, Status در query string).
برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)،
اینجا caller خودِ مرورگرِ کاربره، نه زرین‌پال مستقیماً — پس هیچ
caller خودِ مرورگرِ کاربره، نه زرین‌پال مستقیماً — پس هیچ
هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک
فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK
رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرین‌پال چیزی paid نمی‌شه.
@@ -350,6 +289,8 @@ class OrderDetailView(APIView):
@extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)'])
def get(self, request, order_id):
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
if reconcile_pending_order(order):
order.refresh_from_db()
data = {
'orderId': str(order.id),
'status': order.status,