Files
shopship/apps/billing/api/views.py
T
2026-09-04 13:05:54 +03:30

144 lines
6.1 KiB
Python

from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework import generics, status
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, 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):
"""فهرستِ ابزارها — عمدا AllowAny: کاربرِ مهمان (بدون حساب) هم باید
بتواند بفهمد کدام ابزار رایگان است. این پاسخ هیچ داده‌ی شخصی ندارد؛
فقط کاتالوگِ ابزارها و قیمت‌هاست."""
serializer_class = FeaturePlanSerializer
permission_classes = [AllowAny]
# ابزارهای رایگان is_purchasable=False دارند (فروختنی نیستند) ولی باید
# حتما در لیست بیایند — وگرنه اپ آن‌ها را نمی‌شناسد و قفل نشانشان می‌دهد.
queryset = (
FeaturePlan.objects.filter(Q(is_purchasable=True) | Q(is_free=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})