2026-08-24 15:54:08 +03:30
|
|
|
import logging
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
from django.db import transaction
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
|
|
|
|
|
|
|
|
|
from apps.account.models import User
|
|
|
|
|
from apps.billing.models import FeatureActivation, FeaturePlan, GemTransaction
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# پاداش هر معرفیِ موفق (اولین verify واقعی کاربر جدید) + پاداش پلکانی سر
|
|
|
|
|
# رسیدن به نقاط عطف — دقیقا همان الگوی «گیمیفیکیشن» که خواسته شده بود.
|
|
|
|
|
REFERRAL_FLAT_BONUS = 200
|
|
|
|
|
REFERRAL_MILESTONES = {3: 500, 5: 1000, 10: 2500, 25: 7000, 50: 20000}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GemWalletService:
|
|
|
|
|
@staticmethod
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def add_gems(user: User, amount: int, reason: str, note: str = "") -> User:
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
raise ValueError("amount باید مثبت باشد")
|
|
|
|
|
user = User.objects.select_for_update().get(pk=user.pk)
|
|
|
|
|
user.gem_balance += amount
|
|
|
|
|
user.save(update_fields=["gem_balance"])
|
|
|
|
|
GemTransaction.objects.create(user=user, amount=amount, reason=reason, note=note)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def spend_gems(user: User, amount: int, reason: str, note: str = "") -> User:
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
raise ValueError("amount باید مثبت باشد")
|
|
|
|
|
user = User.objects.select_for_update().get(pk=user.pk)
|
|
|
|
|
if user.gem_balance < amount:
|
|
|
|
|
raise DRFValidationError({"detail": "موجودی جم کافی نیست."})
|
|
|
|
|
user.gem_balance -= amount
|
|
|
|
|
user.save(update_fields=["gem_balance"])
|
|
|
|
|
GemTransaction.objects.create(user=user, amount=-amount, reason=reason, note=note)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReferralService:
|
|
|
|
|
@staticmethod
|
|
|
|
|
def link_referrer(new_user: User, referral_code: str | None) -> None:
|
|
|
|
|
"""موقع ساخت کاربر (send_login_code) صدا زده میشود — فقط لینک میکند،
|
|
|
|
|
پاداش نمیدهد؛ پاداش فقط سر verify واقعی داده میشود."""
|
|
|
|
|
if not referral_code or new_user.referred_by_id:
|
|
|
|
|
return
|
|
|
|
|
referrer = User.objects.filter(referral_code=referral_code.strip().upper()).exclude(pk=new_user.pk).first()
|
|
|
|
|
if referrer:
|
|
|
|
|
new_user.referred_by = referrer
|
|
|
|
|
new_user.save(update_fields=["referred_by"])
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def grant_bonus_if_needed(user: User) -> None:
|
|
|
|
|
"""بعد از اولین verify موفق کاربرِ معرفیشده صدا زده میشود."""
|
|
|
|
|
if user.referral_bonus_granted or not user.referred_by_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
from apps.notifications.services import NotificationService
|
|
|
|
|
|
|
|
|
|
referrer = user.referred_by
|
|
|
|
|
GemWalletService.add_gems(
|
|
|
|
|
referrer, REFERRAL_FLAT_BONUS, GemTransaction.Reason.REFERRAL_BONUS, note=f"معرفی {user.phone}"
|
|
|
|
|
)
|
|
|
|
|
user.referral_bonus_granted = True
|
|
|
|
|
user.save(update_fields=["referral_bonus_granted"])
|
|
|
|
|
NotificationService.notify(
|
|
|
|
|
referrer,
|
|
|
|
|
title="🎉 یکی با کد معرفت پیوست!",
|
|
|
|
|
body=(
|
|
|
|
|
f"یک نفر با کد معرف تو توی کاسبیار ثبتنام کرد و {REFERRAL_FLAT_BONUS} جم جایزه گرفتی. "
|
|
|
|
|
"همینطوری ادامه بده تا جمهای بیشتری جمع کنی!"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
verified_referrals_count = User.objects.filter(referred_by=referrer, referral_bonus_granted=True).count()
|
|
|
|
|
milestone_bonus = REFERRAL_MILESTONES.get(verified_referrals_count)
|
|
|
|
|
if milestone_bonus:
|
|
|
|
|
GemWalletService.add_gems(
|
|
|
|
|
referrer,
|
|
|
|
|
milestone_bonus,
|
|
|
|
|
GemTransaction.Reason.REFERRAL_MILESTONE,
|
|
|
|
|
note=f"دستاورد {verified_referrals_count} معرفی موفق",
|
|
|
|
|
)
|
|
|
|
|
NotificationService.notify(
|
|
|
|
|
referrer,
|
|
|
|
|
title=f"🏆 به {verified_referrals_count} معرفی موفق رسیدی!",
|
|
|
|
|
body=f"دستاورد ویژه باز شد — {milestone_bonus} جم پاداش اضافه به کیف پولت اضافه شد.",
|
|
|
|
|
)
|
|
|
|
|
logger.info(f"Referral bonus granted: {referrer.phone} <- {user.phone}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeatureAccessService:
|
|
|
|
|
@staticmethod
|
|
|
|
|
def is_active(user: User, feature_key: str) -> bool:
|
2026-09-04 13:05:54 +03:30
|
|
|
# ابزارهای رایگان همیشه باز — پیش از دورهی آزمایشی و بعد از آن.
|
|
|
|
|
if FeaturePlan.objects.filter(key=feature_key, is_free=True).exists():
|
|
|
|
|
return True
|
2026-08-24 15:54:08 +03:30
|
|
|
if user.is_in_trial:
|
|
|
|
|
return True
|
|
|
|
|
return FeatureActivation.objects.filter(
|
|
|
|
|
user=user, feature__key=feature_key, expires_at__gt=timezone.now()
|
|
|
|
|
).exists()
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def expires_at(user: User, feature_key: str):
|
|
|
|
|
activation = FeatureActivation.objects.filter(user=user, feature__key=feature_key).first()
|
|
|
|
|
return activation.expires_at if activation else None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
@transaction.atomic
|
|
|
|
|
def activate(user: User, feature_key: str, duration_months: int) -> FeatureActivation:
|
|
|
|
|
try:
|
|
|
|
|
feature = FeaturePlan.objects.get(key=feature_key, is_purchasable=True)
|
|
|
|
|
except FeaturePlan.DoesNotExist:
|
|
|
|
|
raise DRFValidationError({"detail": "ریزاپ یافت نشد."})
|
|
|
|
|
|
|
|
|
|
tier = feature.tiers.filter(duration_months=duration_months).first()
|
|
|
|
|
if not tier:
|
|
|
|
|
raise DRFValidationError({"detail": "این مدت برای این ریزاپ تعریف نشده."})
|
|
|
|
|
|
|
|
|
|
GemWalletService.spend_gems(
|
|
|
|
|
user, tier.gem_price, GemTransaction.Reason.FEATURE_ACTIVATION, note=f"{feature.name} — {duration_months} ماه"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
activation, _created = FeatureActivation.objects.get_or_create(
|
|
|
|
|
user=user, feature=feature, defaults={"expires_at": timezone.now()}
|
|
|
|
|
)
|
|
|
|
|
# اگر از قبل فعال بود، از تاریخ انقضای فعلی جمع میزند (نه از الان)؛
|
|
|
|
|
# اگر منقضی شده بود، از الان شروع میشود.
|
|
|
|
|
base = activation.expires_at if activation.expires_at > timezone.now() else timezone.now()
|
|
|
|
|
activation.expires_at = base + timedelta(days=30 * duration_months)
|
|
|
|
|
activation.save(update_fields=["expires_at"])
|
|
|
|
|
return activation
|