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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user