Files
MathApp/subscription/services.py
2026-08-19 17:15:55 +03:30

83 lines
4.3 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import transaction
from django.utils import timezone
from statuslog.signals import status_changed
from .models import Subscription
def activate_subscription(subscription_id: int):
"""
بعد از تایید پرداخت (payment webhook) صدا زده می‌شه؛ اشتراک رو active
می‌کنه و expires_at رو بر اساس Plan.duration_days محاسبه می‌کنه.
(قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub
بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل
Plan خونده می‌شه.)
⚛️ atomic: خودِ این تابع هم مستقل (بدون اتکا به atomic بودنِ caller)
اتمیکه، چون به‌عنوان یک سرویسِ مشترک از جاهای مختلف (payment webhook،
bazaar verify) صدا زده می‌شه؛ save روی Subscription و نوشتنِ
StatusChangeLog که از signal.send نتیجه می‌شه، باید یک واحد بمونن.
"""
with transaction.atomic():
try:
# 🔒 select_for_update اضافه شد تا این تابع هم مثل
# grant_free_subscription، صرف‌نظر از caller، در برابر
# concurrent update ایمن باشه.
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 با همون تعداد روز.
از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی
ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK
واقعی و نه HTTP call به خودمون.
⚛️ atomic: مثل activate_subscription، مستقل از caller اتمیکه — چون
get_or_create + save + signal.send (که StatusChangeLog می‌سازه) باید
یک واحد باشن، چه apply_offer از قبل توی atomic باشه چه نه.
⚛️🔒 اصلاح race condition: قبلاً بعد از get_or_create هیچ قفلی گرفته
نمی‌شد؛ دو فراخوانیِ هم‌زمان (مثلاً دو آفر که تقریباً هم‌زمان اعمال
می‌شن) می‌تونستن هر دو همون Subscription رو بخونن، هر دو expires_at
رو بر اساس همون old_status/duration محاسبه کنن و save کنن — یکی از
دو نتیجه گم می‌شد (lost update). حالا ردیفِ Subscription قبل از
خوندن/تغییرش با select_for_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