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
+66
View File
@@ -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")
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})
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.billing"
verbose_name = "جم، اشتراک و پرداخت"
+124
View File
@@ -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)]
View File
+165
View File
@@ -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}"
+20
View File
@@ -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)
View File
+56
View File
@@ -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
+20
View File
@@ -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"),
]