Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
18
subscription/migrations/0003_subscription_version.py
Normal file
18
subscription/migrations/0003_subscription_version.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user