Files
MathApp/subscription/services.py
Zareian 39247f6d53 senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental
  settings); progress.0006 drops its tables, permissions and migration history, and
  Profile.parent_phone, the two family notification types and ?userId= on
  /api/progress/nodes go with it
- GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes,
  questions, accuracy, islands, tutor questions) for the "parent report" page
- profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it
- GET /api/payment/invoices; orders now snapshot list price and plan title
- tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11),
  refusal marker replaced with fixed text; self-harm gets a help line instead
- streamed answers now record token usage (it was always 0)
- daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota
- delete_users management command for wiping test accounts across every app
- docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results

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

119 lines
6.1 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.
from django.db import models, transaction
from django.utils import timezone
from statuslog.signals import status_changed
from .models import Subscription
def has_unlimited_hearts(user_id: int) -> bool:
"""آیا کاربر اشتراکِ فعال داره (progress.UserHearts از این استفاده می‌کنه تا
هارت کم نکنه). فعلاً هر اشتراکِ فعال، فارغ از پلن/مدت، این پرک رو می‌ده —
وقتی محصول پلن‌های متفاوت با پرکِ متفاوت خواست، این تابع دقیق‌تر می‌شه.
expires_at=None یعنی اشتراکِ نامحدود (grant_free_subscription با duration_days=None)."""
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()
# اشتراکِ بی‌پلن (هدیه/آفر با 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')]
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