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:
0
payment/management/__init__.py
Normal file
0
payment/management/__init__.py
Normal file
0
payment/management/commands/__init__.py
Normal file
0
payment/management/commands/__init__.py
Normal file
28
payment/management/commands/payment_reconcile.py
Normal file
28
payment/management/commands/payment_reconcile.py
Normal 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 شد.')
|
||||
18
payment/migrations/0007_order_plan_id.py
Normal file
18
payment/migrations/0007_order_plan_id.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
|
||||
# بقیهی تستها اثر نذاره.
|
||||
|
||||
167
payment/tests_audit_fixes.py
Normal file
167
payment/tests_audit_fixes.py
Normal 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())
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user