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>
This commit is contained in:
@@ -65,16 +65,29 @@ def grant_free_subscription(user_id: int, *, duration_days: int | None = None, e
|
||||
|
||||
مستقل از 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 = (
|
||||
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(
|
||||
|
||||
@@ -112,8 +112,9 @@ class PurchaseView(APIView):
|
||||
plan = get_object_or_404(Plan, pk=v['planId'])
|
||||
idempotency_key = request.headers.get('Idempotency-Key')
|
||||
|
||||
# Three-phase flow so create_order (which may call an external payment
|
||||
# gateway) never runs inside a DB transaction/lock: phase 1 creates/updates
|
||||
# Three-phase flow so create_order never runs inside a DB transaction/lock.
|
||||
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
|
||||
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
|
||||
# the pending Subscription, phase 2 calls create_order and restores the
|
||||
# prior state on failure, phase 3 attaches order_id atomically.
|
||||
existing_order = None
|
||||
|
||||
Reference in New Issue
Block a user