Files
MathApp/subscription/services.py
Zareian 7307bfcadb Remove silent fallbacks found in the 2026-10-03 audit
- /api/ai/ask: the stub answer is reachable only under DEBUG or
  RAG_FORCE_MOCK. Without RAG keys on the server it used to return 200 with
  "this is a stub, set OPENAI_API_KEY" and spend the student's quota; now 503.
- /api/home: the `grade` alias is gone and sending it is a 400 instead of a
  silent fallback to the user's own grade. No client sent it.
- activate_subscription logs ERROR when the paid order's plan no longer exists
  (it silently became 30 days).
- resolve_grade_title logs instead of `except: pass`; curriculum_io no longer
  reads an `intro_text` key no file contains.
- CLAUDE.md: "No silent fallbacks" rules for future agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:38:42 +03:30

196 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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
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 = 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