""" منطق آفرهای رایگان (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 داخل کل بلوکِ atomic (که شاملِ # grant_free_subscription هم می‌شد) به‌عنوان «duplicate UserOffer» # تفسیر می‌شد و None برمی‌گردوند — یعنی اگه یک IntegrityError واقعیِ # دیگه (مثلاً داخل grant_free_subscription) اتفاق می‌افتاد، خطا قورت # داده می‌شد و caller فکر می‌کرد «کاربر قبلاً این آفر رو گرفته»، در # حالی که اصلاً چنین چیزی نبود. حالا فقط IntegrityError ناشی از # UniqueConstraintِ خودِ UserOffer.create (duplicate واقعی) گرفته # می‌شه — با یک savepoint جداگانه (atomic تودرتو) که فقط همون create # رو rollback می‌کنه، نه کل تراکنش رو — و خطاهای دیگه (مثلاً از داخل # grant_free_subscription) عادی propagate می‌شن. atomic بودنِ کلِ # عملیات (UserOffer + Subscription + expires_at) هم دست‌نخورده باقی # مونده، دقیقاً طبق همون دلیلی که در docstring بالا توضیح داده شده. 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