next_refill_at was set on UserHearts but never read or recalculated, so a heart lost to zero never came back. UserHearts.apply_regen() now catches up missed 24h intervals on read/deduct, and lose_heart() starts the timer only when it isn't already running. Any user with an active (non-expired) subscription now skips heart deduction entirely, regardless of which plan they're on — good enough for now until per-plan perks are needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
3.4 KiB
Python
73 lines
3.4 KiB
Python
from django.db import models, transaction
|
|
from django.utils import timezone
|
|
|
|
from statuslog.signals import status_changed
|
|
|
|
from .models import Subscription
|
|
|
|
|
|
def has_unlimited_hearts(user_id: int) -> bool:
|
|
"""آیا کاربر اشتراکِ فعال داره (progress.UserHearts از این استفاده میکنه تا
|
|
هارت کم نکنه). فعلاً هر اشتراکِ فعال، فارغ از پلن/مدت، این پرک رو میده —
|
|
وقتی محصول پلنهای متفاوت با پرکِ متفاوت خواست، این تابع دقیقتر میشه.
|
|
expires_at=None یعنی اشتراکِ نامحدود (grant_free_subscription با duration_days=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 activate_subscription(subscription_id: int):
|
|
"""
|
|
بعد از تایید پرداخت (payment webhook) صدا زده میشود؛ اشتراک را active
|
|
میکند و expires_at را بر اساس Plan.duration_days محاسبه میکند.
|
|
مستقل از caller اتمیک است چون از چند جا (payment webhook، bazaar verify) صدا زده میشود.
|
|
"""
|
|
with transaction.atomic():
|
|
try:
|
|
subscription = Subscription.objects.select_for_update().get(pk=subscription_id)
|
|
except Subscription.DoesNotExist:
|
|
return None
|
|
|
|
old_status = subscription.status
|
|
duration_days = subscription.plan.duration_days if subscription.plan else 30
|
|
subscription.status = 'active'
|
|
subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days)
|
|
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='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
|
|
|
|
subscription.status = 'active'
|
|
subscription.expires_at = (
|
|
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
|
|
)
|
|
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
|