adding
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.billing.api.services import REFERRAL_MILESTONES, FeatureAccessService
|
||||
from apps.billing.models import FeaturePlan, FeaturePlanTier, GemPack
|
||||
|
||||
|
||||
class WalletSerializer(serializers.Serializer):
|
||||
gem_balance = serializers.IntegerField()
|
||||
is_in_trial = serializers.BooleanField()
|
||||
trial_expires_at = serializers.DateTimeField()
|
||||
referral_code = serializers.CharField()
|
||||
referral_count = serializers.IntegerField()
|
||||
next_referral_milestone = serializers.SerializerMethodField()
|
||||
|
||||
def get_next_referral_milestone(self, obj):
|
||||
count = obj["referral_count"]
|
||||
for milestone, bonus in sorted(REFERRAL_MILESTONES.items()):
|
||||
if count < milestone:
|
||||
return {"referrals_needed": milestone, "referrals_remaining": milestone - count, "bonus_gems": bonus}
|
||||
return None
|
||||
|
||||
|
||||
class FeaturePlanTierSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FeaturePlanTier
|
||||
fields = ["duration_months", "gem_price"]
|
||||
|
||||
|
||||
class FeaturePlanSerializer(serializers.ModelSerializer):
|
||||
tiers = FeaturePlanTierSerializer(many=True, read_only=True)
|
||||
is_active = serializers.SerializerMethodField()
|
||||
expires_at = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = FeaturePlan
|
||||
fields = ["key", "name", "tiers", "is_active", "expires_at"]
|
||||
|
||||
def get_is_active(self, obj: FeaturePlan) -> bool:
|
||||
return FeatureAccessService.is_active(self.context["request"].user, obj.key)
|
||||
|
||||
def get_expires_at(self, obj: FeaturePlan):
|
||||
return FeatureAccessService.expires_at(self.context["request"].user, obj.key)
|
||||
|
||||
|
||||
class GemPackSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = GemPack
|
||||
fields = ["id", "bazaar_sku", "display_name", "gem_amount"]
|
||||
|
||||
|
||||
class ActivateFeatureSerializer(serializers.Serializer):
|
||||
duration_months = serializers.IntegerField(min_value=1)
|
||||
|
||||
|
||||
class CreatePurchaseSerializer(serializers.Serializer):
|
||||
gem_pack_id = serializers.IntegerField()
|
||||
purchase_token = serializers.CharField(max_length=255)
|
||||
@@ -0,0 +1,135 @@
|
||||
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:
|
||||
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
|
||||
@@ -0,0 +1,133 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.account.models import User
|
||||
from apps.billing.models import BazaarPurchase, FeaturePlan, GemPack, GemTransaction
|
||||
from apps.billing.providers.bazaar import verify_purchase
|
||||
|
||||
from .serializers import (
|
||||
ActivateFeatureSerializer,
|
||||
CreatePurchaseSerializer,
|
||||
FeaturePlanSerializer,
|
||||
GemPackSerializer,
|
||||
WalletSerializer,
|
||||
)
|
||||
from .services import FeatureAccessService, GemWalletService
|
||||
|
||||
|
||||
def _wallet_data(user: User) -> dict:
|
||||
return {
|
||||
"gem_balance": user.gem_balance,
|
||||
"is_in_trial": user.is_in_trial,
|
||||
"trial_expires_at": user.trial_expires_at,
|
||||
"referral_code": user.referral_code,
|
||||
"referral_count": User.objects.filter(referred_by=user, referral_bonus_granted=True).count(),
|
||||
}
|
||||
|
||||
|
||||
class WalletView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(WalletSerializer(_wallet_data(request.user)).data)
|
||||
|
||||
|
||||
class FeatureListView(generics.ListAPIView):
|
||||
serializer_class = FeaturePlanSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = FeaturePlan.objects.filter(is_purchasable=True).prefetch_related("tiers")
|
||||
|
||||
|
||||
class ActivateFeatureView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = "billing_write"
|
||||
|
||||
def post(self, request, key):
|
||||
serializer = ActivateFeatureSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
FeatureAccessService.activate(request.user, key, serializer.validated_data["duration_months"])
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"feature": FeaturePlanSerializer(
|
||||
FeaturePlan.objects.get(key=key), context={"request": request}
|
||||
).data,
|
||||
"wallet": WalletSerializer(_wallet_data(request.user)).data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GemPackListView(generics.ListAPIView):
|
||||
serializer_class = GemPackSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = GemPack.objects.filter(is_active=True)
|
||||
|
||||
|
||||
class CreatePurchaseView(APIView):
|
||||
"""کلاینت بعد از باز کردن لینک خرید کافهبازار و برگشتن، اینجا فقط
|
||||
میگوید «این خرید را شروع کردم»؛ جمی اضافه نمیشود تا verify تایید کند."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = "billing_write"
|
||||
|
||||
def post(self, request):
|
||||
serializer = CreatePurchaseSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
gem_pack = GemPack.objects.get(id=serializer.validated_data["gem_pack_id"], is_active=True)
|
||||
except GemPack.DoesNotExist:
|
||||
raise NotFound("بستهی جم یافت نشد.")
|
||||
|
||||
purchase, _created = BazaarPurchase.objects.get_or_create(
|
||||
purchase_token=serializer.validated_data["purchase_token"],
|
||||
defaults={"user": request.user, "gem_pack": gem_pack},
|
||||
)
|
||||
return Response({"id": purchase.id, "status": purchase.status}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class VerifyPurchaseView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = "billing_write"
|
||||
|
||||
def post(self, request, purchase_id):
|
||||
# قفل ردیفِ همین خرید تا پایان تراکنش — بدون این، دو درخواست verify
|
||||
# همزمان (دابلتپ یا retry شبکه) هر دو میتوانند از چک status=pending
|
||||
# رد شوند و جم را دوبار اضافه کنند. با این قفل، دومی تا کامیت شدن
|
||||
# اولی معطل میماند و بعد status=verified را میبیند و کاری نمیکند.
|
||||
with transaction.atomic():
|
||||
try:
|
||||
purchase = BazaarPurchase.objects.select_for_update().get(id=purchase_id, user=request.user)
|
||||
except BazaarPurchase.DoesNotExist:
|
||||
raise NotFound("خرید یافت نشد.")
|
||||
|
||||
if purchase.status == BazaarPurchase.Status.VERIFIED:
|
||||
return Response(
|
||||
{"status": purchase.status, "wallet": WalletSerializer(_wallet_data(request.user)).data}
|
||||
)
|
||||
|
||||
is_valid = verify_purchase(purchase.gem_pack.bazaar_sku, purchase.purchase_token)
|
||||
if not is_valid:
|
||||
purchase.status = BazaarPurchase.Status.FAILED
|
||||
purchase.save(update_fields=["status"])
|
||||
return Response(
|
||||
{"status": purchase.status, "detail": "تایید خرید ناموفق بود."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
GemWalletService.add_gems(
|
||||
request.user,
|
||||
purchase.gem_pack.gem_amount,
|
||||
GemTransaction.Reason.PURCHASE,
|
||||
note=f"خرید {purchase.gem_pack.display_name}",
|
||||
)
|
||||
purchase.status = BazaarPurchase.Status.VERIFIED
|
||||
purchase.gems_granted = purchase.gem_pack.gem_amount
|
||||
purchase.verified_at = timezone.now()
|
||||
purchase.save(update_fields=["status", "gems_granted", "verified_at"])
|
||||
|
||||
return Response({"status": purchase.status, "wallet": WalletSerializer(_wallet_data(request.user)).data})
|
||||
Reference in New Issue
Block a user