Files
MathApp/discount/services.py

71 lines
3.9 KiB
Python

"""
منطق آفرهای رایگان (Offer/UserOffer)، جدا از منطق موجود DiscountCode
(تخفیف روی مبلغ سفارش). اینجا محل تنها جاییه که تصمیم می‌گیره «اعمال یک
آفر» دقیقاً یعنی چی (الان: فعال‌سازی اشتراک رایگان/محدود) — طبق درخواست
سینیور برای signal-based و قابل‌تغییر بودنِ این منطق، تغییر دادنِ رفتار
آفرها (مثلاً بجای اشتراک، دسترسی به یک اپ خاص) فقط همین فایل رو لمس می‌کنه،
نه apps.py/receivers.py و نه هیچ اپ دیگه‌ای.
"""
from django.db import IntegrityError, transaction
from rest_framework.exceptions import ValidationError
from .models import Offer, UserOffer
def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
"""
یک آفر رو به یک کاربر اختصاص می‌ده و اثرش رو اعمال می‌کنه (فعلاً: اشتراک
رایگان/محدود). اگه کاربر قبلاً همین آفر رو گرفته باشه (unique constraint)
None برمی‌گردونه، نه خطا — چون auto-assign هنگام ثبت‌نام نباید کرش کنه.
⚛️ atomic: ساخت UserOffer، فعال‌سازی Subscription (grant_free_subscription)
و آپدیت expires_at روی همون UserOffer، هر سه یک واحد اتمیک‌اند. بدون این،
اگه grant_free_subscription (یا هر چیزی که داخلش صدا زده می‌شه، مثل
سیگنال status_changed) وسط راه fail کنه، یک UserOffer «یتیم» توی
دیتابیس می‌مونه که هیچ اشتراکی واقعاً براش فعال نشده.
"""
if not offer.is_active:
return None
# فقط IntegrityErrorِ ناشیِ از UniqueConstraintِ خودِ UserOffer.create
# (duplicate واقعی) اینجا گرفته می‌شه، با یک savepoint مجزا که فقط
# همون create رو rollback می‌کنه — وگرنه یک IntegrityErrorِ دیگه (مثلاً
# داخلِ grant_free_subscription) قورت داده می‌شد و caller اشتباهاً فکر
# می‌کرد کاربر قبلاً این آفر رو گرفته.
with transaction.atomic():
try:
with transaction.atomic(): # savepoint مجزا فقط برای create
user_offer = UserOffer.objects.create(
user_id=user_id, offer=offer, source=source,
expires_at=None, # پایین‌تر بعد از فعال‌سازی اشتراک ست می‌شه
)
except IntegrityError:
return None
from subscription.services import grant_free_subscription
duration_days = offer.duration_days if offer.kind == 'trial' else None
subscription = grant_free_subscription(
user_id, duration_days=duration_days, event=f'offer_{offer.kind}',
)
user_offer.expires_at = subscription.expires_at
user_offer.save(update_fields=['expires_at'])
return user_offer
def redeem_offer_code(*, user_id: int, code: str) -> UserOffer:
"""POST /api/discount/offers/redeem — اعمال آفر با کد، هر زمان بعد از ثبت‌نام."""
offer = Offer.objects.filter(code=code, is_active=True).first()
if offer is None:
raise ValidationError({'code': 'این کد آفر معتبر نیست.'})
if UserOffer.objects.filter(offer=offer, user_id=user_id).exists():
raise ValidationError({'code': 'شما قبلاً این آفر را دریافت کرده‌اید.'})
user_offer = apply_offer(user_id=user_id, offer=offer, source='code')
if user_offer is None:
raise ValidationError({'code': 'این کد آفر معتبر نیست یا غیرفعال شده است.'})
return user_offer