Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only

- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -5,19 +5,14 @@ from django.utils import timezone
from statuslog.signals import status_changed
from .models import HeartsPass, QuestionCredit, Subscription
from .models import Subscription
logger = logging.getLogger(__name__)
def has_unlimited_hearts(user_id: int) -> bool:
"""
قلبِ نامحدود: اشتراکِ فعال (expires_at=None یعنی اشتراکِ نامحدودِ هدیه)، یا کارتِ قلبِ
هدیه‌ی بسته‌های سوال (HeartsPass) که هنوز تمام نشده.
"""
"""قلبِ نامحدود: اشتراکِ فعال (expires_at=None یعنی اشتراکِ نامحدودِ هدیه)."""
now = timezone.now()
if HeartsPass.objects.filter(user_id=user_id, expires_at__gt=now).exists():
return True
return Subscription.objects.filter(
user_id=user_id, status='active',
).filter(
@@ -26,7 +21,7 @@ def has_unlimited_hearts(user_id: int) -> bool:
def has_active_subscription(user_id: int) -> bool:
"""فقط اشتراکِ زمانی (نه کارتِ قلبِ بسته‌ها)؛ برای متنِ پیامِ سقفِ روزانه."""
"""اشتراکِ زمانیِ فعال."""
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()
@@ -47,80 +42,9 @@ def has_subscription_access(user_id: int) -> bool:
return bool(provider_for('bazaar').reconcile_user(user_id)) and has_active_subscription(user_id)
def hearts_pass_until(user_id: int):
"""تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده."""
hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first()
return hp.expires_at if hp else None
# ── اعتبارِ سوال (بسته‌ها) ────────────────────────────────────────────────
def credit_balance(user_id: int) -> int:
return QuestionCredit.objects.filter(user_id=user_id).aggregate(t=models.Sum('delta'))['t'] or 0
def spend_credit(user_id: int, message_id: int) -> bool:
"""
یک اعتبار برای این سوال خرج می‌کند؛ False اگر موجودی صفر است.
⚠️ باید داخلِ تراکنشی صدا زده شود که ردیفِ کاربر را قفل کرده (ai_tutor.views، فازِ ۱)؛ وگرنه
دو درخواستِ هم‌زمان هر دو موجودیِ ۱ را می‌بینند و هر دو خرج می‌کنند. روی SQLite همان
`transaction_mode: IMMEDIATE` قفلِ نوشتن را از اولِ تراکنش می‌گیرد.
"""
if credit_balance(user_id) <= 0:
return False
QuestionCredit.objects.create(user_id=user_id, delta=-1, reason='question', message_id=message_id)
return True
def refund_credit(message_id: int) -> None:
"""سوالی که به API نرسید (کش) یا جوابش ساخته نشد، اعتبارش را پس می‌گیرد."""
QuestionCredit.objects.filter(message_id=message_id, reason='question').delete()
def grant_pack(order) -> bool:
"""
بعد از تأییدِ پرداختِ سفارشِ یک بسته: اعتبارِ سوال و روزهای قلبِ نامحدود. idempotent: قیدِ
یکتای (order_id، purchase) جلوی اعتبارِ دوباره برای تأییدِ تکراری را می‌گیرد.
روزهای قلب از پایانِ کارتِ فعلی ادامه پیدا می‌کنند، نه از امروز.
"""
from django.db import IntegrityError
from .models import Plan
plan = Plan.objects.filter(pk=order.plan_id).first()
if plan is not None and plan.kind != 'pack':
logger.error('grant_pack: پلنِ %s سفارشِ %s بسته نیست؛ چیزی داده نشد', order.plan_id, order.id)
return False
# عددها از خودِ سفارش (همان که فروخته شد)، پس پلنِ پاک‌شده جلوی دادنِ آن‌ها را نمی‌گیرد؛ تا امروز
# پرداختِ سفارشی که پلنش پاک شده بود «paid» می‌شد بی‌هیچ اعتبار و بی‌هیچ لاگی (بازتولید شد).
# سفارشِ بی‌این ستون‌ها (پیش از payment.0011) عددهای فعلیِ پلن را می‌گیرد.
if order.question_credits is None and plan is None:
logger.error('grant_pack: سفارشِ %s نه عددِ ثبت‌شده دارد نه پلنِ %s؛ پرداخت شده ولی چیزی داده نشد',
order.id, order.plan_id)
return False
credits = order.question_credits if order.question_credits is not None else plan.question_credits
hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0)
note = (order.plan_title or (plan.title if plan else ''))[:100]
try:
with transaction.atomic():
QuestionCredit.objects.create(user_id=order.user_id, delta=credits,
reason='purchase', order_id=order.id, note=note)
if hearts_days:
now = timezone.now()
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
start = hp.expires_at if hp and hp.expires_at > now else now
until = start + timezone.timedelta(days=hearts_days)
HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until})
except IntegrityError:
# اعتبارِ این سفارش از قبل داده شده؛ apply_order_payment_result نباید دو بار «تازه paid» بگوید.
logger.error('grant_pack: اعتبارِ سفارشِ %s از قبل داده شده بود؛ دوباره داده نشد', order.id)
return False
return True
# اشتراکِ بی‌پلن (هدیه/آفر با grant_free_subscription) همان سقفِ پیش‌فرضِ پلن را می‌گیرد.
DEFAULT_SUBSCRIBER_AI_QUOTA = 15
# اشتراکِ بی‌پلن (هدیه/آفر با grant_free_subscription) همان سقفِ پلن‌های فروشی را می‌گیرد (۴، subscription.0017)؛
# تا ۲۰۲۶-۱۰-۱۲ پانزده بود، یعنی اشتراکِ هدیه سه‌برابرِ اشتراکِ خریده‌شده خرجِ دستیار داشت.
DEFAULT_SUBSCRIBER_AI_QUOTA = 4
def ai_daily_quota(user_id: int) -> int:
@@ -135,7 +59,7 @@ def ai_daily_quota(user_id: int) -> int:
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') if not (s.plan and s.plan.kind == 'pack')]
for s in active.select_related('plan')]
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE