Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-08-21 22:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('subscription', '0002_plan_duration_days'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='version',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -50,6 +50,20 @@ class Subscription(models.Model):
started_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
# 🔒 اصلاحِ رگرسیونِ concurrency در PurchaseView.post: چون Subscription
# فقط یک ردیف در ازای هر user_id داره، دو درخواستِ هم‌زمانِ خریدِ همون
# کاربر (مثلاً با همون plan) می‌تونن دقیقاً به یک وضعیتِ میانیِ یکسان
# برسن (status='pending', همون plan_id, order_id=None) — یعنی مقایسه‌ی
# مقادیرِ فیلدها (که قبلاً به‌عنوانِ CAS guard در cleanup استفاده می‌شد)
# نمی‌تونه بینِ «ردیفِ نوشته‌شده توسطِ خودِ همین درخواست» و «ردیفِ
# نوشته‌شده توسطِ یک درخواستِ هم‌زمانِ دیگه» تمایز بذاره. version یک
# شمارنده‌ی صریحه که PurchaseView.post هر بار که واقعاً روی این ردیف
# می‌نویسه (با UPDATE اتمیکِ version=F('version')+1، بدونِ نیاز به
# select_for_update/نگه‌داشتنِ قفل) افزایشش می‌ده؛ پس حتی وقتی محتوای
# semantic فیلدهای دیگه بینِ دو نویسنده‌ی هم‌زمان یکسانه، version همیشه
# بینِ نوشته‌های مجزا فرق می‌کنه و می‌تونه به‌عنوانِ CAS token قابل‌اتکا
# برای cleanup استفاده بشه.
version = models.PositiveIntegerField(default=0)
def __str__(self):
return f"sub-user{self.user_id}-{self.status}"

View File

@@ -8,22 +8,12 @@ from .models import Subscription
def activate_subscription(subscription_id: int):
"""
بعد از تایید پرداخت (payment webhook) صدا زده می‌شه؛ اشتراک رو active
می‌کنه و expires_at رو بر اساس Plan.duration_days محاسبه می‌کنه.
(قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub
بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل
Plan خونده می‌شه.)
⚛️ atomic: خودِ این تابع هم مستقل (بدون اتکا به atomic بودنِ caller)
اتمیکه، چون به‌عنوان یک سرویسِ مشترک از جاهای مختلف (payment webhook،
bazaar verify) صدا زده می‌شه؛ save روی Subscription و نوشتنِ
StatusChangeLog که از signal.send نتیجه می‌شه، باید یک واحد بمونن.
بعد از تایید پرداخت (payment webhook) صدا زده می‌شود؛ اشتراک را active
می‌کند و expires_at را بر اساس Plan.duration_days محاسبه می‌کند.
مستقل از caller اتمیک است چون از چند جا (payment webhook، bazaar verify) صدا زده می‌شود.
"""
with transaction.atomic():
try:
# 🔒 select_for_update اضافه شد تا این تابع هم مثل
# grant_free_subscription، صرف‌نظر از caller، در برابر
# concurrent update ایمن باشه.
subscription = Subscription.objects.select_for_update().get(pk=subscription_id)
except Subscription.DoesNotExist:
return None
@@ -44,24 +34,11 @@ def activate_subscription(subscription_id: int):
def grant_free_subscription(user_id: int, *, duration_days: int | None = None, event: str = 'offer_applied'):
"""
فعال‌سازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان
محدود/کامل). duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی
trial با همون تعداد روز.
فعال‌سازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان محدود/کامل).
duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی trial با همان تعداد روز.
از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی
ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK
واقعی و نه HTTP call به خودمون.
⚛️ atomic: مثل activate_subscription، مستقل از caller اتمیکه — چون
get_or_create + save + signal.send (که StatusChangeLog می‌سازه) باید
یک واحد باشن، چه apply_offer از قبل توی atomic باشه چه نه.
⚛️🔒 اصلاح race condition: قبلاً بعد از get_or_create هیچ قفلی گرفته
نمی‌شد؛ دو فراخوانیِ هم‌زمان (مثلاً دو آفر که تقریباً هم‌زمان اعمال
می‌شن) می‌تونستن هر دو همون Subscription رو بخونن، هر دو expires_at
رو بر اساس همون old_status/duration محاسبه کنن و save کنن — یکی از
دو نتیجه گم می‌شد (lost update). حالا ردیفِ Subscription قبل از
خوندن/تغییرش با select_for_update قفل می‌شه.
مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با
select_for_update قفل می‌شود تا فراخوانی‌های هم‌زمان lost update نسازند.
"""
with transaction.atomic():
Subscription.objects.get_or_create(user_id=user_id)

View File

@@ -1,3 +1,298 @@
from django.test import TestCase
from unittest.mock import patch
# Create your tests here.
from django.db.models import F
from django.test import TestCase, TransactionTestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from payment.models import Order
from payment.services import IdempotencyKeyConflict, create_order
from .models import Plan, Subscription
class PurchaseIdempotencyRetryTests(TestCase):
"""Regression test: retrying an already-paid purchase must not regress
an active subscription back to pending."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self, idempotency_key):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_retry_of_paid_purchase_does_not_regress_active_subscription_to_pending(self):
subscription = Subscription.objects.create(
user_id=self.user.id, plan=self.plan, status='active',
)
order = Order.objects.create(
user_id=self.user.id,
subscription_id=subscription.id,
amount=self.plan.price,
currency=self.plan.currency,
status='paid',
provider='gateway',
idempotency_key='retry-key-1',
)
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
response = self._purchase('retry-key-1')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['orderId'], str(order.id))
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(Order.objects.filter(idempotency_key='retry-key-1').count(), 1)
def test_genuinely_new_purchase_still_starts_pending(self):
response = self._purchase('brand-new-key-1')
self.assertEqual(response.status_code, 200)
subscription = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(subscription.status, 'pending')
self.assertEqual(str(subscription.order_id), response.data['orderId'])
class PurchaseExternalCallNotUnderLockTests(TransactionTestCase):
"""Regression test: create_order must not run inside PurchaseView's
transaction.atomic block, since it will eventually call an external
payment gateway. Uses TransactionTestCase (not TestCase) because
TestCase wraps every test in its own outer atomic block, which would
make connection.in_atomic_block always True and hide this bug."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-lock-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def test_create_order_is_called_without_an_open_db_transaction(self):
from django.db import connection
with patch('subscription.views.create_order', wraps=create_order) as mocked_create_order:
def _assert_no_open_transaction(*args, **kwargs):
# Fails here if the view still calls create_order under its own lock.
assert not connection.in_atomic_block, (
'create_order must not be called while a DB transaction from '
'PurchaseView is open'
)
return create_order(*args, **kwargs)
mocked_create_order.side_effect = _assert_no_open_transaction
response = self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
self.assertEqual(response.status_code, 200)
mocked_create_order.assert_called_once()
class PurchaseOrderCreationFailureCleanupTests(TestCase):
"""Regression test: even though create_order runs outside the view's
lock, a failed order must still clean up the subscription correctly
(delete if freshly created, restore prior state if it already existed)."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-cleanup-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_failed_order_creation_deletes_freshly_created_subscription(self):
self.assertFalse(Subscription.objects.filter(user_id=self.user.id).exists())
with patch(
'subscription.views.create_order',
side_effect=IdempotencyKeyConflict('تست شکستِ create_order'),
):
response = self._purchase()
self.assertEqual(response.status_code, 409)
self.assertFalse(Subscription.objects.filter(user_id=self.user.id).exists())
def test_failed_order_creation_restores_existing_subscription_exactly(self):
annual_plan = Plan.objects.create(
title='سالانه', price=900000, currency='IRT', duration_days=365,
)
subscription = Subscription.objects.create(
user_id=self.user.id, plan=annual_plan, status='active',
)
subscription.order_id = 4242
subscription.save(update_fields=['order_id'])
with patch(
'subscription.views.create_order',
side_effect=IdempotencyKeyConflict('تست شکستِ create_order'),
):
response = self._purchase()
self.assertEqual(response.status_code, 409)
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.plan_id, annual_plan.id)
self.assertEqual(subscription.order_id, 4242)
class PurchaseFailureCleanupConcurrencyRegressionTests(TestCase):
"""Regression test: cleanup after a failed create_order must not
erase or overwrite a valid state written by a concurrent request in
between phase 1 and this cleanup."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-race-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
self.other_plan = Plan.objects.create(
title='سالانه', price=900000, currency='IRT', duration_days=365,
)
def _purchase(self):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_failure_delete_path_does_not_erase_concurrent_success_on_new_subscription(self):
self.assertFalse(Subscription.objects.filter(user_id=self.user.id).exists())
def _fail_after_concurrent_success(*args, **kwargs):
# Simulates a concurrent request succeeding between phase 1 and cleanup.
concurrent_order = Order.objects.create(
user_id=self.user.id,
amount=self.other_plan.price,
currency=self.other_plan.currency,
status='paid',
provider='gateway',
idempotency_key='concurrent-key-1',
)
Subscription.objects.filter(user_id=self.user.id).update(
status='active', plan_id=self.other_plan.id, order_id=concurrent_order.id,
)
raise IdempotencyKeyConflict('تست شکستِ create_order بعد از موفقیتِ هم‌زمان')
with patch('subscription.views.create_order', side_effect=_fail_after_concurrent_success):
response = self._purchase()
self.assertEqual(response.status_code, 409)
subscription = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.plan_id, self.other_plan.id)
def test_failure_restore_path_does_not_overwrite_concurrent_success_on_existing_subscription(self):
original_order_id = 4242
subscription = Subscription.objects.create(
user_id=self.user.id, plan=self.other_plan, status='active',
)
subscription.order_id = original_order_id
subscription.save(update_fields=['order_id'])
def _fail_after_concurrent_success(*args, **kwargs):
# Phase 1 already set status to 'pending'; a concurrent request
# now moves the subscription to a new active state.
concurrent_order = Order.objects.create(
user_id=self.user.id,
amount=self.plan.price,
currency=self.plan.currency,
status='paid',
provider='gateway',
idempotency_key='concurrent-key-2',
)
Subscription.objects.filter(user_id=self.user.id).update(
status='active', plan_id=self.plan.id, order_id=concurrent_order.id,
)
raise IdempotencyKeyConflict('تست شکستِ create_order بعد از موفقیتِ هم‌زمان')
with patch('subscription.views.create_order', side_effect=_fail_after_concurrent_success):
response = self._purchase()
self.assertEqual(response.status_code, 409)
subscription.refresh_from_db()
self.assertEqual(subscription.status, 'active')
self.assertEqual(subscription.plan_id, self.plan.id)
self.assertNotEqual(subscription.order_id, original_order_id)
class PurchaseIdenticalIntermediateStateRaceRegressionTests(TestCase):
"""Regression test: two concurrent purchases for the same plan can
reach an identical intermediate state (pending, same plan_id,
order_id=None), so cleanup can't distinguish them by field comparison
alone. Simulates a concurrent request B writing the same phase-1
state while request A is failing."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-purchase-race-identical-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.plan = Plan.objects.create(
title='ماهانه', price=100000, currency='IRT', duration_days=30,
)
def _purchase(self, idempotency_key):
return self.client.post(
'/api/subscription/purchase',
{'planId': str(self.plan.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
def test_failed_request_does_not_delete_concurrent_requests_identical_pending_state(self):
def _simulate_concurrent_identical_purchase_then_fail(*args, **kwargs):
# Request B: concurrently repeats phase 1 (update_or_create + version
# bump) for the same user/plan, reaching the same intermediate state.
Subscription.objects.update_or_create(
user_id=self.user.id, defaults={'plan': self.plan, 'status': 'pending'},
)
b_subscription = Subscription.objects.get(user_id=self.user.id)
Subscription.objects.filter(pk=b_subscription.pk).update(version=F('version') + 1)
# Request A: its create_order fails.
raise IdempotencyKeyConflict('create_order failure for request A')
with patch(
'subscription.views.create_order',
side_effect=_simulate_concurrent_identical_purchase_then_fail,
):
response = self._purchase('key-A')
self.assertEqual(response.status_code, 409)
# Subscription now holds B's state and must survive A's failure cleanup
# even though A and B wrote identical status/plan_id/order_id.
subscription = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(subscription.status, 'pending')
self.assertEqual(subscription.plan_id, self.plan.id)
self.assertIsNone(subscription.order_id)
self.assertEqual(subscription.version, 2)

View File

@@ -1,4 +1,5 @@
from django.db import transaction
from django.db.models import F
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -6,6 +7,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from payment.models import Order
from payment.services import create_order
from .models import Plan, Subscription
@@ -18,7 +20,7 @@ from .serializers import (
class PlansListView(APIView):
"""GET /api/subscription/plans — لیست پلن‌ها"""
"""GET /api/subscription/plans — list available plans."""
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
def get(self, request):
@@ -41,18 +43,13 @@ class PlansListView(APIView):
class PurchaseView(APIView):
"""
POST /api/subscription/purchase — شروع خرید (با هدر Idempotency-Key)
یک Subscription در وضعیت pending می‌سازه/به‌روز می‌کنه، بعد مستقیماً
تابع سرویس اپ Payment رو صدا می‌زنه تا Order بسازه (همون «فراخوانی
سرور-به-سرور» سند API که در این مونولیت به شکل import مستقیم پیاده‌سازی
شده، نه یک HTTP call جدا به خودمون).
"""
"""POST /api/subscription/purchase — starts a purchase (Idempotency-Key
header supported). Creates/updates a pending Subscription, then calls
the payment app's service directly (in-process, not a separate HTTP call)
to create an Order."""
permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: مستندسازی هدر Idempotency-Key که قبلاً در Swagger UI
# قابل تنظیم/تست نبود.
@extend_schema(
request=PurchaseRequestSerializer,
responses=PurchaseResponseSerializer,
@@ -74,33 +71,86 @@ class PurchaseView(APIView):
user = request.user
plan = get_object_or_404(Plan, pk=v['planId'])
idempotency_key = request.headers.get('Idempotency-Key')
# ⚠️ اصلاح: قبلاً اگه create_order (مثلاً به‌خاطر IdempotencyKeyConflict)
# خطا می‌داد، Subscription که یک خط بالاتر update_or_create شده بود
# نیمه‌کاره (status='pending' ولی بدون order_id معتبر) در دیتابیس
# باقی می‌موند. حالا کل عملیات در یک تراکنش اتمیک انجام می‌شه.
with transaction.atomic():
subscription, _created = Subscription.objects.update_or_create(
# Three-phase flow so create_order (which may call an external payment
# gateway) never runs inside a DB transaction/lock: phase 1 creates/updates
# the pending Subscription, phase 2 calls create_order and restores the
# prior state on failure, phase 3 attaches order_id atomically.
existing_order = None
if idempotency_key:
existing_order = Order.objects.filter(idempotency_key=idempotency_key).first()
prior_subscription = Subscription.objects.filter(user_id=user.id).first()
prior_snapshot = None
if prior_subscription is not None:
prior_snapshot = {
'status': prior_subscription.status,
'plan_id': prior_subscription.plan_id,
'order_id': prior_subscription.order_id,
}
if existing_order is not None:
# Retry path: leave an existing subscription untouched, only
# create one if it's genuinely missing.
if prior_subscription is None:
# Fresh row: version starts at 1 to give it a CAS token.
subscription = Subscription.objects.create(
user_id=user.id, plan=plan, status='pending', version=1,
)
subscription_is_new = True
else:
subscription = prior_subscription
subscription_is_new = False
else:
subscription, subscription_is_new = Subscription.objects.update_or_create(
user_id=user.id,
defaults={'plan': plan, 'status': 'pending'},
)
# Two concurrent requests can reach an identical intermediate state,
# so version is bumped atomically here to give this write a unique
# CAS token the cleanup below can match against.
Subscription.objects.filter(pk=subscription.pk).update(version=F('version') + 1)
subscription.refresh_from_db(fields=['version'])
try:
order, payment_url = create_order(
user_id=user.id,
amount=plan.price,
currency=plan.currency,
subscription_id=subscription.id,
idempotency_key=request.headers.get('Idempotency-Key'),
idempotency_key=idempotency_key,
)
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only
# matches the exact row state phase 1 wrote; any concurrent writer
# (another request, a payment webhook, etc.) changes the match and
# the delete/update below becomes a no-op instead of clobbering it.
guard = {
'pk': subscription.pk,
'status': subscription.status,
'plan_id': subscription.plan_id,
'order_id': subscription.order_id,
'version': subscription.version,
}
with transaction.atomic():
if subscription_is_new:
Subscription.objects.filter(**guard).delete()
elif prior_snapshot is not None:
Subscription.objects.filter(**guard).update(**prior_snapshot)
raise
if subscription.order_id != order.id:
with transaction.atomic():
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
return Response(PurchaseResponseSerializer(data).data)
class SubscriptionStatusView(APIView):
"""GET /api/subscription/status — وضعیت فعلی اشتراک"""
"""GET /api/subscription/status — current subscription status."""
permission_classes = [IsAuthenticated]