adding
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
from django.contrib import admin
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
|
||||
|
||||
|
||||
@admin.register(BazaarSettings)
|
||||
class BazaarSettingsAdmin(admin.ModelAdmin):
|
||||
"""تکردیفی — لیست را رد میکنیم و مستقیم فرم ویرایش همان یک ردیف را نشان میدهیم."""
|
||||
|
||||
list_display = ("package_name", "updated_at")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not BazaarSettings.objects.exists()
|
||||
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
obj = BazaarSettings.get_solo()
|
||||
return redirect("admin:billing_bazaarsettings_change", obj.pk)
|
||||
|
||||
|
||||
class FeaturePlanTierInline(admin.TabularInline):
|
||||
model = FeaturePlanTier
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(FeaturePlan)
|
||||
class FeaturePlanAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "key", "is_purchasable")
|
||||
search_fields = ("name", "key")
|
||||
inlines = [FeaturePlanTierInline]
|
||||
|
||||
|
||||
@admin.register(GemPack)
|
||||
class GemPackAdmin(admin.ModelAdmin):
|
||||
list_display = ("display_name", "bazaar_sku", "gem_amount", "is_active", "sort_order")
|
||||
list_editable = ("is_active", "sort_order")
|
||||
search_fields = ("display_name", "bazaar_sku")
|
||||
|
||||
|
||||
@admin.register(FeatureActivation)
|
||||
class FeatureActivationAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "feature", "expires_at", "updated_at")
|
||||
list_filter = ("feature",)
|
||||
search_fields = ("user__phone",)
|
||||
autocomplete_fields = ["user"]
|
||||
|
||||
|
||||
@admin.register(GemTransaction)
|
||||
class GemTransactionAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "amount", "reason", "created_at")
|
||||
list_filter = ("reason",)
|
||||
search_fields = ("user__phone", "note")
|
||||
readonly_fields = ("user", "amount", "reason", "note", "created_at")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# تراکنش جم فقط از سرویسهای داخلی ساخته میشود، نه دستی از ادمین —
|
||||
# برای اصلاح دستی از GemTransaction.Reason.ADMIN_ADJUSTMENT در شل/سرویس استفاده کن.
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(BazaarPurchase)
|
||||
class BazaarPurchaseAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "gem_pack", "status", "gems_granted", "created_at", "verified_at")
|
||||
list_filter = ("status",)
|
||||
search_fields = ("user__phone", "purchase_token")
|
||||
readonly_fields = ("purchase_token", "created_at", "verified_at")
|
||||
@@ -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})
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.billing"
|
||||
verbose_name = "جم، اشتراک و پرداخت"
|
||||
@@ -0,0 +1,124 @@
|
||||
# Generated by Django 5.1.15 on 2026-08-23 12:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BazaarSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('access_token', models.CharField(blank=True, max_length=255, verbose_name='Access Token')),
|
||||
('refresh_token', models.CharField(blank=True, max_length=255, verbose_name='Refresh Token')),
|
||||
('package_name', models.CharField(blank=True, max_length=150, verbose_name='application id اپ (مثلا ir.example.app)')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تنظیمات کافه\u200cبازار',
|
||||
'verbose_name_plural': 'تنظیمات کافه\u200cبازار',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FeaturePlan',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.SlugField(unique=True, verbose_name='کلید ریزاپ')),
|
||||
('name', models.CharField(max_length=150, verbose_name='نام نمایشی')),
|
||||
('is_purchasable', models.BooleanField(default=True, verbose_name='قابل\u200cفروش')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ریزاپ قابل\u200cفعال\u200cسازی',
|
||||
'verbose_name_plural': 'ریزاپ\u200cهای قابل\u200cفعال\u200cسازی',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GemPack',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bazaar_sku', models.CharField(max_length=100, unique=True, verbose_name='SKU کافه\u200cبازار')),
|
||||
('display_name', models.CharField(max_length=150, verbose_name='نام نمایشی')),
|
||||
('gem_amount', models.PositiveIntegerField(verbose_name='تعداد جم')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='قابل\u200cخرید')),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'بسته\u200cی جم',
|
||||
'verbose_name_plural': 'بسته\u200cهای جم',
|
||||
'ordering': ['sort_order', 'gem_amount'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BazaarPurchase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('purchase_token', models.CharField(max_length=255, unique=True, verbose_name='Purchase Token')),
|
||||
('status', models.CharField(choices=[('pending', 'در انتظار تایید'), ('verified', 'تاییدشده'), ('failed', 'ناموفق')], default='pending', max_length=10)),
|
||||
('gems_granted', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('verified_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bazaar_purchases', to=settings.AUTH_USER_MODEL)),
|
||||
('gem_pack', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.gempack')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'خرید کافه\u200cبازار',
|
||||
'verbose_name_plural': 'خریدهای کافه\u200cبازار',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GemTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.IntegerField(verbose_name='مقدار (مثبت/منفی)')),
|
||||
('reason', models.CharField(choices=[('purchase', 'خرید از کافه\u200cبازار'), ('feature_activation', 'فعال\u200cسازی ریزاپ'), ('referral_bonus', 'پاداش معرفی'), ('referral_milestone', 'پاداش دستاورد معرفی'), ('admin_adjustment', 'اصلاح دستی ادمین')], max_length=30)),
|
||||
('note', models.CharField(blank=True, max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gem_transactions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تراکنش جم',
|
||||
'verbose_name_plural': 'تراکنش\u200cهای جم',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FeatureActivation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('expires_at', models.DateTimeField(verbose_name='تا تاریخ')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_activations', to=settings.AUTH_USER_MODEL)),
|
||||
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='billing.featureplan')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'فعال\u200cسازی ریزاپ',
|
||||
'verbose_name_plural': 'فعال\u200cسازی\u200cهای ریزاپ',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'feature'), name='unique_user_feature_activation')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FeaturePlanTier',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('duration_months', models.PositiveSmallIntegerField(verbose_name='مدت (ماه)')),
|
||||
('gem_price', models.PositiveIntegerField(verbose_name='قیمت (جم)')),
|
||||
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tiers', to='billing.featureplan')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'پلن قیمتی',
|
||||
'verbose_name_plural': 'پلن\u200cهای قیمتی',
|
||||
'ordering': ['feature', 'duration_months'],
|
||||
'constraints': [models.UniqueConstraint(fields=('feature', 'duration_months'), name='unique_feature_duration')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.db import migrations
|
||||
|
||||
# قیمت پایهی هرکدام + پلکان تخفیف مدت (۳ماهه ≈۱۰٪ کمتر از ۳×ماهانه،
|
||||
# ۶ماهه ≈۱۷٪، ۱۲ماهه ۲۵٪) — دقیقا مطابق مثال خودِ کارفرما برای فاکتور
|
||||
# (۱۰۰۰ / ۲۷۰۰ / ۵۰۰۰)، همان الگو برای بقیه هم تعمیم داده شده.
|
||||
FEATURES = [
|
||||
("sms_bank", "بانک اساماس", 1500),
|
||||
("invoice", "فاکتور", 1000),
|
||||
("customers", "مشتریان", 500),
|
||||
("ledger", "دفتر درآمد و هزینه", 800),
|
||||
("inventory", "انبارداری", 800),
|
||||
("booking", "نوبتدهی", 600),
|
||||
]
|
||||
|
||||
|
||||
_DURATION_MULTIPLIERS = {1: 1, 3: 2.7, 6: 5, 12: 9} # ۱۰٪/۱۷٪/۲۵٪ تخفیف نسبت به ماهانه×مدت
|
||||
|
||||
|
||||
def _tier_prices(monthly: int) -> dict:
|
||||
return {months: round(monthly * multiplier) for months, multiplier in _DURATION_MULTIPLIERS.items()}
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
|
||||
|
||||
for key, name, monthly_price in FEATURES:
|
||||
feature, _created = FeaturePlan.objects.get_or_create(key=key, defaults={"name": name})
|
||||
for months, price in _tier_prices(monthly_price).items():
|
||||
FeaturePlanTier.objects.get_or_create(
|
||||
feature=feature, duration_months=months, defaults={"gem_price": price}
|
||||
)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlan.objects.filter(key__in=[key for key, _, _ in FEATURES]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("billing", "0001_initial")]
|
||||
operations = [migrations.RunPython(seed, unseed)]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations
|
||||
|
||||
# ریزاپ کارگزاری کامل حذف شد — این migration پاکسازی میکند اگر روی یک DB
|
||||
# که قبلاً 0002 را اجرا کرده بود (و رکورد "brokerage" را ساخته بود) اعمال شود.
|
||||
# روی DB تازه (که 0002 دیگر "brokerage" را seed نمیکند) بیاثر است.
|
||||
|
||||
|
||||
def remove_brokerage(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlan.objects.filter(key="brokerage").delete()
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("billing", "0002_seed_feature_plans")]
|
||||
operations = [migrations.RunPython(remove_brokerage, noop)]
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations
|
||||
|
||||
# اسم نمایشی ابزار «sms_bank» برای کاربر عادی (نه فنی) واضحتر شد.
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlan.objects.filter(key="sms_bank").update(name="پیامک گروهی")
|
||||
|
||||
|
||||
def rename_backward(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlan.objects.filter(key="sms_bank").update(name="بانک اساماس")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("billing", "0003_remove_brokerage_feature")]
|
||||
operations = [migrations.RunPython(rename_forward, rename_backward)]
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.db import migrations
|
||||
|
||||
_DURATION_MULTIPLIERS = {1: 1, 3: 2.7, 6: 5, 12: 9}
|
||||
_MONTHLY_PRICE = 900
|
||||
|
||||
|
||||
def _tier_prices() -> dict:
|
||||
return {months: round(_MONTHLY_PRICE * multiplier) for months, multiplier in _DURATION_MULTIPLIERS.items()}
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
|
||||
|
||||
feature, _created = FeaturePlan.objects.get_or_create(key="orders", defaults={"name": "پیگیری سفارش"})
|
||||
for months, price in _tier_prices().items():
|
||||
FeaturePlanTier.objects.get_or_create(feature=feature, duration_months=months, defaults={"gem_price": price})
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
FeaturePlan = apps.get_model("billing", "FeaturePlan")
|
||||
FeaturePlan.objects.filter(key="orders").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("billing", "0004_rename_sms_bank_feature")]
|
||||
operations = [migrations.RunPython(seed, unseed)]
|
||||
@@ -0,0 +1,165 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# سیستم جم/اشتراک: هر ابزار (پیامک گروهی، فاکتور، مشتریان و...) با جم
|
||||
# برای یک بازهی زمانی فعال میشود. جم فقط از طریق خرید تاییدشدهی کافهبازار
|
||||
# (سمت سرور، بعد از تماس واقعی با API کافهبازار) یا پاداش معرفی اضافه
|
||||
# میشود — User.gem_balance هیچوقت مستقیم از کلاینت نوشته نمیشود.
|
||||
|
||||
|
||||
class BazaarSettings(models.Model):
|
||||
"""تنظیمات کافهبازار — عمداً از پنل ادمین ویرایش میشود (نه .env)، چون
|
||||
باید بدون ریدیپلوی قابلتغییر باشد. تکردیفی (singleton)."""
|
||||
|
||||
access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token"))
|
||||
refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token"))
|
||||
package_name = models.CharField(
|
||||
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("تنظیمات کافهبازار")
|
||||
verbose_name_plural = _("تنظیمات کافهبازار")
|
||||
|
||||
def __str__(self):
|
||||
return "تنظیمات کافهبازار"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls) -> "BazaarSettings":
|
||||
obj, _created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
|
||||
class GemPack(models.Model):
|
||||
"""بستهی جم قابلخرید — bazaar_sku باید دقیقا همان محصولی باشد که در
|
||||
کنسول توسعهدهندگان کافهبازار ثبت کردهای."""
|
||||
|
||||
bazaar_sku = models.CharField(max_length=100, unique=True, verbose_name=_("SKU کافهبازار"))
|
||||
display_name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
|
||||
gem_amount = models.PositiveIntegerField(verbose_name=_("تعداد جم"))
|
||||
is_active = models.BooleanField(default=True, verbose_name=_("قابلخرید"))
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("بستهی جم")
|
||||
verbose_name_plural = _("بستههای جم")
|
||||
ordering = ["sort_order", "gem_amount"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_name} ({self.gem_amount} جم)"
|
||||
|
||||
|
||||
class FeaturePlan(models.Model):
|
||||
"""یک ریزاپ قابلفعالسازی با جم — key باید با کلید همان ریزاپ در اپ
|
||||
فلاتر یکی باشد (مثلا invoice، customers، ledger، inventory، booking،
|
||||
sms_bank)."""
|
||||
|
||||
key = models.SlugField(max_length=50, unique=True, verbose_name=_("کلید ریزاپ"))
|
||||
name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
|
||||
is_purchasable = models.BooleanField(default=True, verbose_name=_("قابلفروش"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ریزاپ قابلفعالسازی")
|
||||
verbose_name_plural = _("ریزاپهای قابلفعالسازی")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class FeaturePlanTier(models.Model):
|
||||
"""قیمت هر مدتزمان برای یک ریزاپ — مثلا فاکتور: ۱ماهه=۱۰۰۰، ۳ماهه=۲۷۰۰،
|
||||
۶ماهه=۵۰۰۰ جم (تخفیف پلکانی برای خرید مدت طولانیتر)."""
|
||||
|
||||
feature = models.ForeignKey(FeaturePlan, on_delete=models.CASCADE, related_name="tiers")
|
||||
duration_months = models.PositiveSmallIntegerField(verbose_name=_("مدت (ماه)"))
|
||||
gem_price = models.PositiveIntegerField(verbose_name=_("قیمت (جم)"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("پلن قیمتی")
|
||||
verbose_name_plural = _("پلنهای قیمتی")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["feature", "duration_months"], name="unique_feature_duration")
|
||||
]
|
||||
ordering = ["feature", "duration_months"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.feature.name} — {self.duration_months} ماهه — {self.gem_price} جم"
|
||||
|
||||
|
||||
class FeatureActivation(models.Model):
|
||||
"""فعال بودن یک ریزاپ برای یک کاربر تا چه زمانی. دورهی رایگان جدا حساب
|
||||
میشود (User.is_in_trial)؛ این مدل فقط اشتراکِ خریداریشدهی بعد از
|
||||
دورهی رایگان است."""
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="feature_activations")
|
||||
feature = models.ForeignKey(FeaturePlan, on_delete=models.CASCADE, related_name="+")
|
||||
expires_at = models.DateTimeField(verbose_name=_("تا تاریخ"))
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("فعالسازی ریزاپ")
|
||||
verbose_name_plural = _("فعالسازیهای ریزاپ")
|
||||
constraints = [models.UniqueConstraint(fields=["user", "feature"], name="unique_user_feature_activation")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.phone} — {self.feature.name} تا {self.expires_at:%Y-%m-%d}"
|
||||
|
||||
|
||||
class GemTransaction(models.Model):
|
||||
"""دفتر تراکنشهای جم — تنها منبع حقیقتِ تاریخچه؛ User.gem_balance فقط
|
||||
یک مجموع cacheشده است که هر تغییرش باید از همینجا هم رد شود."""
|
||||
|
||||
class Reason(models.TextChoices):
|
||||
PURCHASE = "purchase", _("خرید از کافهبازار")
|
||||
FEATURE_ACTIVATION = "feature_activation", _("فعالسازی ریزاپ")
|
||||
REFERRAL_BONUS = "referral_bonus", _("پاداش معرفی")
|
||||
REFERRAL_MILESTONE = "referral_milestone", _("پاداش دستاورد معرفی")
|
||||
ADMIN_ADJUSTMENT = "admin_adjustment", _("اصلاح دستی ادمین")
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="gem_transactions")
|
||||
amount = models.IntegerField(verbose_name=_("مقدار (مثبت/منفی)"))
|
||||
reason = models.CharField(max_length=30, choices=Reason.choices)
|
||||
note = models.CharField(max_length=255, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("تراکنش جم")
|
||||
verbose_name_plural = _("تراکنشهای جم")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.phone} — {self.amount:+d} — {self.get_reason_display()}"
|
||||
|
||||
|
||||
class BazaarPurchase(models.Model):
|
||||
"""یک خرید بستهی جم از کافهبازار. purchase_token یکتاست — همان توکنی
|
||||
که کافهبازار به خریدار میدهد، برای جلوگیری از استفادهی دوبارهی یک
|
||||
توکن. تا وقتی status=verified نشود (با تماس واقعی سرور-به-سرور به API
|
||||
کافهبازار)، هیچ جمی اضافه نمیشود."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", _("در انتظار تایید")
|
||||
VERIFIED = "verified", _("تاییدشده")
|
||||
FAILED = "failed", _("ناموفق")
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bazaar_purchases")
|
||||
gem_pack = models.ForeignKey(GemPack, on_delete=models.PROTECT, related_name="+")
|
||||
purchase_token = models.CharField(max_length=255, unique=True, verbose_name=_("Purchase Token"))
|
||||
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
|
||||
gems_granted = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
verified_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("خرید کافهبازار")
|
||||
verbose_name_plural = _("خریدهای کافهبازار")
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.phone} — {self.gem_pack.display_name} — {self.status}"
|
||||
@@ -0,0 +1,20 @@
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class HasActiveFeature(BasePermission):
|
||||
"""اجازهی استفاده از یک ریزاپ را فقط به کاربری میدهد که آن را فعال دارد
|
||||
(دورهی آزمایشی یا خریداریشده با جم). گیت اصلی سمت اپ فلاتر (لانچر) فقط
|
||||
برای تجربهی کاربری است؛ بدون این چک سمت سرور، هرکسی با صدازدن مستقیم API
|
||||
(مثلا با curl/Postman) میتوانست کل مدل پرداختی را دور بزند. `view` باید
|
||||
`feature_key` را ست کند؛ اگر نکرد، این پرمیشن چیزی را مسدود نمیکند."""
|
||||
|
||||
message = "برای استفاده از این بخش باید آن را از فروشگاه فعال کنی."
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
feature_key = getattr(view, "feature_key", None)
|
||||
if not feature_key:
|
||||
return True
|
||||
|
||||
from apps.billing.api.services import FeatureAccessService
|
||||
|
||||
return FeatureAccessService.is_active(request.user, feature_key)
|
||||
@@ -0,0 +1,56 @@
|
||||
# apps/billing/providers/bazaar.py
|
||||
"""
|
||||
تایید خرید کافهبازار — سمت سرور، طبق مستندات عمومی devapi کافهبازار
|
||||
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که
|
||||
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این
|
||||
تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
||||
|
||||
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعهدهندهی خودت فرق دارد، فقط
|
||||
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیهی پروژه فقط
|
||||
`verify_purchase(product_id, purchase_token)` را صدا میزند):
|
||||
|
||||
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
|
||||
/inapp/{product_id}/purchases/{purchase_token}/?access_token=...
|
||||
|
||||
پاسخ موفق شامل purchaseState=0 (خریداریشده، نه refundشده) است.
|
||||
|
||||
access_token از BazaarSettings (پنل ادمین) خوانده میشود، نه .env — چون باید
|
||||
بدون ریدیپلوی قابلتعویض باشد (مثلا وقتی منقضی شد).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from apps.billing.models import BazaarSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = 15
|
||||
BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
|
||||
|
||||
|
||||
def verify_purchase(product_id: str, purchase_token: str) -> bool:
|
||||
settings_obj = BazaarSettings.get_solo()
|
||||
if not settings_obj.access_token or not settings_obj.package_name:
|
||||
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
|
||||
return False
|
||||
|
||||
url = f"{BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params={"access_token": settings_obj.access_token},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
purchase_state = body.get("purchaseState")
|
||||
is_valid = purchase_state == 0
|
||||
if not is_valid:
|
||||
logger.warning(f"❌ خرید کافهبازار نامعتبر: {body}")
|
||||
return is_valid
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error(f"❌ خطا در تایید خرید کافهبازار ({product_id}/{purchase_token}): {exc}")
|
||||
return False
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.urls import path
|
||||
|
||||
from .api.views import (
|
||||
ActivateFeatureView,
|
||||
CreatePurchaseView,
|
||||
FeatureListView,
|
||||
GemPackListView,
|
||||
VerifyPurchaseView,
|
||||
WalletView,
|
||||
)
|
||||
|
||||
app_name = "billing"
|
||||
urlpatterns = [
|
||||
path("wallet/", WalletView.as_view(), name="wallet"),
|
||||
path("features/", FeatureListView.as_view(), name="feature-list"),
|
||||
path("features/<slug:key>/activate/", ActivateFeatureView.as_view(), name="feature-activate"),
|
||||
path("gem-packs/", GemPackListView.as_view(), name="gem-pack-list"),
|
||||
path("purchases/", CreatePurchaseView.as_view(), name="purchase-create"),
|
||||
path("purchases/<int:purchase_id>/verify/", VerifyPurchaseView.as_view(), name="purchase-verify"),
|
||||
]
|
||||
Reference in New Issue
Block a user