- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
12 KiB
Python
224 lines
12 KiB
Python
import logging
|
||
|
||
from django.db import models, transaction
|
||
from django.utils import timezone
|
||
|
||
from statuslog.signals import status_changed
|
||
|
||
from .models import HeartsPass, QuestionCredit, Subscription
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def has_unlimited_hearts(user_id: int) -> bool:
|
||
"""
|
||
قلبِ نامحدود: اشتراکِ فعال (expires_at=None یعنی اشتراکِ نامحدودِ هدیه)، یا کارتِ قلبِ
|
||
هدیهی بستههای سوال (HeartsPass) که هنوز تمام نشده.
|
||
"""
|
||
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(
|
||
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.services import sync_bazaar_renewals
|
||
return bool(sync_bazaar_renewals(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
|
||
|
||
|
||
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') if not (s.plan and s.plan.kind == 'pack')]
|
||
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
|