Files
MathApp/subscription/services.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

86 lines
4.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.
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()
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
subscription.status = 'active'
subscription.expires_at = (
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
)
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