Files
MathApp/subscription/services.py
Zareian 0580aec7b0 harden OTP for real SMS and close the second audit round
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

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

99 lines
5.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()
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