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:
2026-10-02 11:41:39 +03:30
parent 2edc56fc08
commit 58c8e6d014
18 changed files with 572 additions and 55 deletions

View File

@@ -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