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:
2026-09-25 12:28:06 +03:30
parent 74aa9aa8ca
commit 0580aec7b0
24 changed files with 685 additions and 119 deletions

View File

@@ -120,12 +120,23 @@ def verify_payment(*, authority: str, amount: int, currency: str) -> ZarinpalVer
raise ZarinpalAPIError()
try:
result_data = resp.json()['data']
code = result_data.get('code')
body = resp.json()
result_data = body['data']
except (ValueError, KeyError, TypeError):
logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code)
raise ZarinpalAPIError()
# زرین‌پال روی خطا (مثلاً -51 پرداختِ ناموفق) `"data": []` و کدِ خطا را در
# `errors` برمی‌گرداند. این یک «رد»ِ صریح است، نه خرابیِ شبکه، پس مثلِ هر کدِ
# غیرِ ۱۰۰/۱۰۱ نامعتبر حساب می‌شود. پیش از ۲۰۲۶-۰۹-۲۵ اینجا `[].get` کرش
# می‌کرد و کاربر به‌جای برگشت به اپ صفحه‌ی ۵۰۰ می‌دید. با بدنه‌ی مستندِ
# زرین‌پال تست شده، نه با درگاهِ واقعی.
if not isinstance(result_data, dict):
errors = body.get('errors') if isinstance(body, dict) else None
logger.warning('Zarinpal payment-verify rejected: errors=%s', errors)
return ZarinpalVerifyResult(is_valid=False, raw=body if isinstance(body, dict) else None)
code = result_data.get('code')
# ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (idempotent از سمتِ زرین‌پال) — هر دو معتبرن.
if code in (100, 101):
return ZarinpalVerifyResult(is_valid=True, ref_id=str(result_data.get('ref_id', '')), raw=result_data)