from django.db import models, transaction from django.utils import timezone from statuslog.signals import status_changed from .models import HeartsPass, QuestionCredit, Subscription def has_unlimited_hearts(user_id: int) -> bool: """ قلبِ نامحدود: اشتراکِ فعال (expires_at=None یعنی اشتراکِ نامحدودِ هدیه)، یا کارتِ قلبِ هدیه‌ی بسته‌های سوال (HeartsPass) که هنوز تمام نشده. """ now = timezone.now() if HeartsPass.objects.filter(user_id=user_id, expires_at__gt=now).exists(): return True return Subscription.objects.filter( user_id=user_id, status='active', ).filter( models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now) ).exists() def has_active_subscription(user_id: int) -> bool: """فقط اشتراکِ زمانی (نه کارتِ قلبِ بسته‌ها)؛ برای متنِ پیامِ سقفِ روزانه.""" now = timezone.now() return Subscription.objects.filter(user_id=user_id, status='active').filter( models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)).exists() def hearts_pass_until(user_id: int): """تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده.""" hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first() return hp.expires_at if hp else None # ── اعتبارِ سوال (بسته‌ها) ──────────────────────────────────────────────── def credit_balance(user_id: int) -> int: return QuestionCredit.objects.filter(user_id=user_id).aggregate(t=models.Sum('delta'))['t'] or 0 def spend_credit(user_id: int, message_id: int) -> bool: """ یک اعتبار برای این سوال خرج می‌کند؛ False اگر موجودی صفر است. ⚠️ باید داخلِ تراکنشی صدا زده شود که ردیفِ کاربر را قفل کرده (ai_tutor.views، فازِ ۱)؛ وگرنه دو درخواستِ هم‌زمان هر دو موجودیِ ۱ را می‌بینند و هر دو خرج می‌کنند. روی SQLite همان `transaction_mode: IMMEDIATE` قفلِ نوشتن را از اولِ تراکنش می‌گیرد. """ if credit_balance(user_id) <= 0: return False QuestionCredit.objects.create(user_id=user_id, delta=-1, reason='question', message_id=message_id) return True def refund_credit(message_id: int) -> None: """سوالی که به API نرسید (کش) یا جوابش ساخته نشد، اعتبارش را پس می‌گیرد.""" QuestionCredit.objects.filter(message_id=message_id, reason='question').delete() def grant_pack(order) -> bool: """ بعد از تأییدِ پرداختِ سفارشِ یک بسته: اعتبارِ سوال و روزهای قلبِ نامحدود. idempotent: قیدِ یکتای (order_id، purchase) جلوی اعتبارِ دوباره برای تأییدِ تکراری را می‌گیرد. روزهای قلب از پایانِ کارتِ فعلی ادامه پیدا می‌کنند، نه از امروز. """ from django.db import IntegrityError from .models import Plan plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first() if plan is None: return False try: with transaction.atomic(): QuestionCredit.objects.create(user_id=order.user_id, delta=plan.question_credits, reason='purchase', order_id=order.id, note=plan.title[:100]) if plan.hearts_days: now = timezone.now() hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() start = hp.expires_at if hp and hp.expires_at > now else now until = start + timezone.timedelta(days=plan.hearts_days) HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until}) except IntegrityError: return False return True # اشتراکِ بی‌پلن (هدیه/آفر با grant_free_subscription) همان سقفِ پیش‌فرضِ پلن را می‌گیرد. DEFAULT_SUBSCRIBER_AI_QUOTA = 15 def ai_daily_quota(user_id: int) -> int: """ سقفِ سوالِ روزانه از دستیار برای این کاربر: بیشترینِ سقفِ پلن‌های اشتراکِ فعالش، یا AI_DAILY_QUOTA_FREE اگر اشتراکِ فعال ندارد. تا ۲۰۲۶-۱۰-۰۱ همه ۱۵ سوال داشتند، با اشتراک یا بی‌اشتراک — یعنی دستیار، گران‌ترین بخشِ اپ، رایگان بود. """ from django.conf import settings now = timezone.now() active = Subscription.objects.filter(user_id=user_id, status='active').filter( models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)) quotas = [s.plan.ai_daily_quota if s.plan else DEFAULT_SUBSCRIBER_AI_QUOTA for s in active.select_related('plan') if not (s.plan and s.plan.kind == 'pack')] return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE def activate_subscription(subscription_id: int, plan_id: int | None = None): """ بعد از تاییدِ پرداخت صدا زده می‌شود (کال‌بکِ زرین‌پال، تاییدِ بازار). - پلن از *سفارشِ پرداخت‌شده* می‌آید (`plan_id`)، نه از Subscription؛ پلنِ روی Subscription با شروعِ خریدِ دیگری عوض می‌شد (باگِ «ماهانه بده، سالانه بگیر»). سفارش‌های قدیمیِ بی‌plan_id همان رفتارِ قبلی را دارند. - تمدید از تاریخِ انقضای فعلی حساب می‌شود، نه از امروز؛ پیش‌تر تمدیدِ زودتر از موعد روزهای باقی‌مانده را پاک می‌کرد. """ from .models import Plan with transaction.atomic(): try: subscription = Subscription.objects.select_for_update().get(pk=subscription_id) except Subscription.DoesNotExist: return None old_status = subscription.status plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None plan = plan or subscription.plan duration_days = plan.duration_days if plan else 30 now = timezone.now() still_running = ( old_status == 'active' and subscription.expires_at is not None and subscription.expires_at > now ) start = subscription.expires_at if still_running else now subscription.status = 'active' subscription.plan = plan subscription.expires_at = start + timezone.timedelta(days=duration_days) subscription.save(update_fields=['status', 'plan', 'expires_at']) status_changed.send( sender='subscription.Subscription', object_id=subscription.id, old_status=old_status, new_status='active', event='payment_confirmed', user_id=subscription.user_id, ) return subscription def grant_free_subscription(user_id: int, *, duration_days: int | None = None, event: str = 'offer_applied'): """ فعال‌سازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان محدود/کامل). duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی trial با همان تعداد روز. مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با select_for_update قفل می‌شود تا فراخوانی‌های هم‌زمان lost update نسازند. اشتراکِ فعالی که دیرتر تمام می‌شود (یا نامحدود است) کوتاه نمی‌شود؛ آفر فقط وقتی اثر دارد که بیشتر از چیزی که کاربر دارد بدهد. پیش از ۲۰۲۶-۰۹-۲۵ کاربری که سالانه خریده بود و بعد کدِ آفرِ ۷ روزه می‌زد (یا مهمانی که خریده بود و بعد ثبت‌نام می‌کرد و آفرِ خودکارِ ثبت‌نام می‌گرفت) با ۷ روز می‌ماند. """ with transaction.atomic(): Subscription.objects.get_or_create(user_id=user_id) subscription = Subscription.objects.select_for_update().get(user_id=user_id) old_status = subscription.status offered_until = ( timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None ) still_active = old_status == 'active' and ( subscription.expires_at is None or subscription.expires_at > timezone.now() ) keeps_longer = still_active and offered_until is not None and ( subscription.expires_at is None or subscription.expires_at >= offered_until ) subscription.status = 'active' if not keeps_longer: subscription.expires_at = offered_until subscription.save(update_fields=['status', 'expires_at']) status_changed.send( sender='subscription.Subscription', object_id=subscription.id, old_status=old_status, new_status='active', event=event, user_id=user_id, ) return subscription