import logging from django.db import models, transaction from django.utils import timezone from statuslog.signals import status_changed from .models import HeartsPass, QuestionCredit, Subscription logger = logging.getLogger(__name__) 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).first() if plan is not None and plan.kind != 'pack': logger.error('grant_pack: پلنِ %s سفارشِ %s بسته نیست؛ چیزی داده نشد', order.plan_id, order.id) return False # عددها از خودِ سفارش (همان که فروخته شد)، پس پلنِ پاک‌شده جلوی دادنِ آن‌ها را نمی‌گیرد؛ تا امروز # پرداختِ سفارشی که پلنش پاک شده بود «paid» می‌شد بی‌هیچ اعتبار و بی‌هیچ لاگی (بازتولید شد). # سفارشِ بی‌این ستون‌ها (پیش از payment.0011) عددهای فعلیِ پلن را می‌گیرد. if order.question_credits is None and plan is None: logger.error('grant_pack: سفارشِ %s نه عددِ ثبت‌شده دارد نه پلنِ %s؛ پرداخت شده ولی چیزی داده نشد', order.id, order.plan_id) return False credits = order.question_credits if order.question_credits is not None else plan.question_credits hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0) note = (order.plan_title or (plan.title if plan else ''))[:100] try: with transaction.atomic(): QuestionCredit.objects.create(user_id=order.user_id, delta=credits, reason='purchase', order_id=order.id, note=note) if 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=hearts_days) HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until}) except IntegrityError: # اعتبارِ این سفارش از قبل داده شده؛ apply_order_payment_result نباید دو بار «تازه paid» بگوید. logger.error('grant_pack: اعتبارِ سفارشِ %s از قبل داده شده بود؛ دوباره داده نشد', order.id) 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, duration_days: 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 if plan_id and plan is None: # پلنِ سفارشِ پرداخت‌شده پاک شده؛ جایگزین (پلنِ Subscription یا ۳۰ روز) # ممکن است کمتر از آن‌چه خریده شده باشد. کاربر بی‌اشتراک نمی‌ماند، ولی # باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید. logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id) plan = plan or subscription.plan # duration_days از سفارش (عددِ لحظه‌ی خرید)؛ None برای سفارشِ پیش از آن ستون. if duration_days is None: 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