- 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>
150 lines
8.2 KiB
Python
150 lines
8.2 KiB
Python
import logging
|
||
|
||
from django.db import models, transaction
|
||
from django.utils import timezone
|
||
|
||
from statuslog.signals import status_changed
|
||
|
||
from .models import Subscription
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def has_unlimited_hearts(user_id: int) -> bool:
|
||
"""قلبِ نامحدود: اشتراکِ فعال (expires_at=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 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()
|
||
|
||
|
||
def has_subscription_access(user_id: int) -> bool:
|
||
"""
|
||
دیوارِ پرداخت (نقشه و فصلها). مثلِ has_active_subscription، با این فرق که اگر اشتراک تمام شده، اول
|
||
تمدیدِ خودکارِ بازار را میپرسد (payment.services.sync_bazaar_renewals): بازار خبر نمیدهد، و بیاین
|
||
مشترکی که بازار تمدیدش کرده تا باز کردنِ فروشگاه پشتِ دیوار میماند. برای کسی که سفارشِ اشتراکِ بازار
|
||
ندارد فقط یک کوئری است؛ فراخوانیِ بازار هر توکن ساعتی یک بار.
|
||
"""
|
||
if has_active_subscription(user_id):
|
||
return True
|
||
# فقط بازار، نه همهی روشها: بازسنجیِ زرینپال در این مسیرِ پرترافیک برای هر بیاشتراکی با سفارشِ نیمهکاره
|
||
# یک فراخوانیِ شبکه میشد؛ آن را صفحهی وضعیت و کالبک میکنند.
|
||
from payment.providers import provider_for
|
||
return bool(provider_for('bazaar').reconcile_user(user_id)) and has_active_subscription(user_id)
|
||
|
||
|
||
# اشتراکِ بیپلن (هدیه/آفر با grant_free_subscription) همان سقفِ پلنهای فروشی را میگیرد (۴، subscription.0017)؛
|
||
# تا ۲۰۲۶-۱۰-۱۲ پانزده بود، یعنی اشتراکِ هدیه سهبرابرِ اشتراکِ خریدهشده خرجِ دستیار داشت.
|
||
DEFAULT_SUBSCRIBER_AI_QUOTA = 4
|
||
|
||
|
||
def ai_daily_quota(user_id: int) -> int:
|
||
"""
|
||
سقفِ سوالِ روزانه از دستیار برای این کاربر: بیشترینِ سقفِ پلنهای اشتراکِ فعالش،
|
||
یا AI_DAILY_QUOTA_FREE اگر اشتراکِ فعال ندارد. تا ۲۰۲۶-۱۰-۰۱ همه ۱۵ سوال داشتند،
|
||
با اشتراک یا بیاشتراک — یعنی دستیار، گرانترین بخشِ اپ، رایگان بود.
|
||
"""
|
||
from django.conf import settings
|
||
|
||
now = timezone.now()
|
||
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')]
|
||
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE
|
||
|
||
|
||
def activate_subscription(subscription_id: int, plan_id: int | None = None, duration_days: 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
|
||
if plan_id and plan is None:
|
||
# پلنِ سفارشِ پرداختشده پاک شده؛ جایگزین (پلنِ Subscription یا ۳۰ روز)
|
||
# ممکن است کمتر از آنچه خریده شده باشد. کاربر بیاشتراک نمیماند، ولی
|
||
# باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید.
|
||
logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id)
|
||
plan = plan or subscription.plan
|
||
# duration_days از سفارش (عددِ لحظهی خرید)؛ None برای سفارشِ پیش از آن ستون.
|
||
if duration_days is None:
|
||
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
|