This commit is contained in:
2026-08-24 15:54:08 +03:30
commit 236002a87c
133 changed files with 5946 additions and 0 deletions
View File
+57
View File
@@ -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)
+135
View File
@@ -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
+133
View File
@@ -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})