question packs instead of monthly/yearly plans; hearts are real
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing subscribers but are no longer on sale - credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per tutor question that reaches the API; the free daily questions are spent first; questions stopped by the server filter, cached answers and failed answers cost nothing - unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a running subscription - Message.charge records how each question was paid; GET /api/ai/quota and the ask responses report free-left-today and credits - HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,15 +3,17 @@ from django.utils import timezone
|
||||
|
||||
from statuslog.signals import status_changed
|
||||
|
||||
from .models import Subscription
|
||||
from .models import HeartsPass, QuestionCredit, Subscription
|
||||
|
||||
|
||||
def has_unlimited_hearts(user_id: int) -> bool:
|
||||
"""آیا کاربر اشتراکِ فعال داره (progress.UserHearts از این استفاده میکنه تا
|
||||
هارت کم نکنه). فعلاً هر اشتراکِ فعال، فارغ از پلن/مدت، این پرک رو میده —
|
||||
وقتی محصول پلنهای متفاوت با پرکِ متفاوت خواست، این تابع دقیقتر میشه.
|
||||
expires_at=None یعنی اشتراکِ نامحدود (grant_free_subscription با duration_days=None)."""
|
||||
"""
|
||||
قلبِ نامحدود: اشتراکِ فعال (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(
|
||||
@@ -19,6 +21,72 @@ def has_unlimited_hearts(user_id: int) -> bool:
|
||||
).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
|
||||
|
||||
@@ -35,7 +103,7 @@ def ai_daily_quota(user_id: int) -> int:
|
||||
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')]
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user