This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+79 -4
View File
@@ -1,8 +1,13 @@
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.db import transaction as db_transaction
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils.html import format_html
from apps.account.models import User
from .api.services import GemWalletService
from .forms import GemGiftForm
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
@@ -115,7 +120,9 @@ class FeaturePlanTierInline(admin.TabularInline):
@admin.register(FeaturePlan)
class FeaturePlanAdmin(admin.ModelAdmin):
list_display = ("name", "key", "is_purchasable")
list_display = ("name", "key", "is_free", "is_purchasable")
list_editable = ("is_free",)
list_filter = ("is_free", "is_purchasable")
search_fields = ("name", "key")
inlines = [FeaturePlanTierInline]
@@ -137,16 +144,84 @@ class FeatureActivationAdmin(admin.ModelAdmin):
@admin.register(GemTransaction)
class GemTransactionAdmin(admin.ModelAdmin):
"""تاریخچه‌ی جم — فقط‌خواندنی، به‌علاوه‌ی ابزارِ «هدیه‌ی جم».
هدیه از دکمه‌ی بالای همین صفحه انجام می‌شود، نه با ساختنِ دستیِ ردیفِ
تراکنش: اگر ادمین ردیف را دستی بسازد، User.gem_balance به‌روز نمی‌شود و
موجودی با تاریخچه واگرا می‌شود. مسیرِ درست از GemWalletService رد
می‌شود که هر دو را با هم و داخلِ یک تراکنش تغییر می‌دهد.
"""
list_display = ("user", "amount", "reason", "created_at")
list_filter = ("reason",)
search_fields = ("user__phone", "note")
readonly_fields = ("user", "amount", "reason", "note", "created_at")
change_list_template = "admin/billing/gemtransaction/change_list.html"
def has_add_permission(self, request):
# تراکنش جم فقط از سرویس‌های داخلی ساخته می‌شود، نه دستی از ادمین —
# برای اصلاح دستی از GemTransaction.Reason.ADMIN_ADJUSTMENT در شل/سرویس استفاده کن.
return False
def get_urls(self):
custom = [
path(
"gift/",
self.admin_site.admin_view(self.gift_view),
name="billing_gemtransaction_gift",
),
]
return custom + super().get_urls()
def gift_view(self, request):
if request.method == "POST":
form = GemGiftForm(request.POST)
if form.is_valid():
return self._apply_gift(request, form)
else:
form = GemGiftForm()
return render(
request,
"admin/billing/gem_gift.html",
{
**self.admin_site.each_context(request),
"title": "هدیه‌ی جم",
"form": form,
"opts": self.model._meta,
},
)
@db_transaction.atomic
def _apply_gift(self, request, form: GemGiftForm):
amount = form.cleaned_data["amount"]
note = form.cleaned_data["note"] or f"هدیه‌ی ادمین ({request.user})"
users, missing = form.existing_and_missing()
if missing and form.cleaned_data["create_missing"]:
created = [User.objects.create(phone=phone) for phone in missing]
users += created
missing = []
for user in users:
GemWalletService.add_gems(
user,
amount,
GemTransaction.Reason.ADMIN_ADJUSTMENT,
note=note,
)
if users:
messages.success(
request,
f"{amount} جم به {len(users)} شماره اضافه شد.",
)
if missing:
# هشدار و نه خطا: بقیه‌ی شماره‌ها شارژ شده‌اند و ادمین باید
# بداند دقیقا کدام‌ها جا مانده‌اند.
messages.warning(
request,
"این شماره‌ها کاربر ندارند و رد شدند: " + "، ".join(missing),
)
return redirect("admin:billing_gemtransaction_changelist")
@admin.register(BazaarPurchase)
class BazaarPurchaseAdmin(admin.ModelAdmin):
+11 -3
View File
@@ -33,13 +33,21 @@ class FeaturePlanSerializer(serializers.ModelSerializer):
class Meta:
model = FeaturePlan
fields = ["key", "name", "tiers", "is_active", "expires_at"]
fields = ["key", "name", "tiers", "is_active", "is_free", "expires_at"]
def get_is_active(self, obj: FeaturePlan) -> bool:
return FeatureAccessService.is_active(self.context["request"].user, obj.key)
# مهمان (بدون حساب): فقط ابزارهای رایگان باز است — بدونِ این شاخه،
# AnonymousUser به is_active می‌رسید و خطا می‌داد.
user = self.context["request"].user
if not user.is_authenticated:
return obj.is_free
return FeatureAccessService.is_active(user, obj.key)
def get_expires_at(self, obj: FeaturePlan):
return FeatureAccessService.expires_at(self.context["request"].user, obj.key)
user = self.context["request"].user
if not user.is_authenticated:
return None
return FeatureAccessService.expires_at(user, obj.key)
class GemPackSerializer(serializers.ModelSerializer):
+3
View File
@@ -97,6 +97,9 @@ class ReferralService:
class FeatureAccessService:
@staticmethod
def is_active(user: User, feature_key: str) -> bool:
# ابزارهای رایگان همیشه باز — پیش از دوره‌ی آزمایشی و بعد از آن.
if FeaturePlan.objects.filter(key=feature_key, is_free=True).exists():
return True
if user.is_in_trial:
return True
return FeatureActivation.objects.filter(
+13 -3
View File
@@ -1,8 +1,9 @@
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 IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -38,9 +39,18 @@ class WalletView(APIView):
class FeatureListView(generics.ListAPIView):
"""فهرستِ ابزارها — عمدا AllowAny: کاربرِ مهمان (بدون حساب) هم باید
بتواند بفهمد کدام ابزار رایگان است. این پاسخ هیچ داده‌ی شخصی ندارد؛
فقط کاتالوگِ ابزارها و قیمت‌هاست."""
serializer_class = FeaturePlanSerializer
permission_classes = [IsAuthenticated]
queryset = FeaturePlan.objects.filter(is_purchasable=True).prefetch_related("tiers")
permission_classes = [AllowAny]
# ابزارهای رایگان is_purchasable=False دارند (فروختنی نیستند) ولی باید
# حتما در لیست بیایند — وگرنه اپ آن‌ها را نمی‌شناسد و قفل نشانشان می‌دهد.
queryset = (
FeaturePlan.objects.filter(Q(is_purchasable=True) | Q(is_free=True))
.prefetch_related("tiers")
)
class ActivateFeatureView(APIView):
+92
View File
@@ -0,0 +1,92 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.account.models import User
def normalize_iran_phone(raw: str) -> str:
"""۰۹۱۲…، +۹۸۹۱۲…، ۰۰۹۸۹۱۲… و ارقام فارسی را به شکلِ یکّه‌ی 09xxxxxxxxx می‌برد.
ادمین شماره را از هرجایی کپی می‌کند (پیامک، اکسل، پیام‌رسان)؛ اگر شکلِ
ورودی را نرمال نکنیم، «کاربر پیدا نشد» می‌گیرد در حالی که کاربر هست.
"""
digits = {
"۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4",
"۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9",
"٠": "0", "١": "1", "٢": "2", "٣": "3", "٤": "4",
"٥": "5", "٦": "6", "٧": "7", "٨": "8", "٩": "9",
}
value = "".join(digits.get(ch, ch) for ch in (raw or "").strip())
value = "".join(ch for ch in value if ch.isdigit() or ch == "+")
value = value.lstrip("+")
if value.startswith("0098"):
value = value[4:]
elif value.startswith("98") and len(value) == 12:
value = value[2:]
if value.startswith("9") and len(value) == 10:
value = "0" + value
return value
class GemGiftForm(forms.Form):
"""هدیه‌دادنِ جم به یک یا چند شماره — از پنل ادمین.
عمداً روی *شماره* کار می‌کند نه روی انتخابِ کاربر از لیست: ادمین معمولا
شماره را دارد (از پشتیبانی، از یک کمپین)، نه شناسه‌ی کاربر را.
"""
phones = forms.CharField(
label=_("شماره‌ها"),
widget=forms.Textarea(attrs={"rows": 5, "dir": "ltr"}),
help_text=_(
"هر شماره در یک خط (یا با کاما جدا کن). "
"شکل‌های 09123456789 و +989123456789 هر دو قبول است."
),
)
amount = forms.IntegerField(
label=_("تعداد جم"),
min_value=1,
help_text=_("به هر شماره همین مقدار جم اضافه می‌شود."),
)
note = forms.CharField(
label=_("علت / یادداشت"),
max_length=255,
required=False,
help_text=_("در تاریخچه‌ی تراکنش‌های جم ذخیره می‌شود — مثلا «جبران خطای پرداخت»."),
)
create_missing = forms.BooleanField(
label=_("اگر کاربر وجود ندارد، ساخته شود"),
required=False,
help_text=_(
"برای هدیه‌ی پیش از ثبت‌نام. کاربر با همان شماره ساخته می‌شود و "
"جم موقع اولین ورودش آماده است."
),
)
def clean_phones(self) -> list[str]:
raw = self.cleaned_data["phones"].replace(",", "\n")
numbers, seen = [], set()
invalid = []
for line in raw.splitlines():
if not line.strip():
continue
phone = normalize_iran_phone(line)
if len(phone) != 11 or not phone.startswith("09"):
invalid.append(line.strip())
continue
if phone not in seen:
seen.add(phone)
numbers.append(phone)
if invalid:
raise forms.ValidationError(
_("این شماره‌ها معتبر نیستند: %(bad)s") % {"bad": "، ".join(invalid)}
)
if not numbers:
raise forms.ValidationError(_("حداقل یک شماره وارد کن."))
return numbers
def existing_and_missing(self) -> tuple[list[User], list[str]]:
phones = self.cleaned_data["phones"]
users = list(User.objects.filter(phone__in=phones))
found = {u.phone for u in users}
return users, [p for p in phones if p not in found]
+128
View File
@@ -0,0 +1,128 @@
# Generated by Django 5.1.15 on 2026-09-02 00:19
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, help_text='یا توکن ثابت «روش جدید» پیشخان را مستقیم اینجا بچسبان، یا با دکمه\u200cی اتصال زیر بگذار خودش (روش قدیمی/OAuth) پر و بعدا خودکار تمدید شود.', max_length=255, verbose_name='Access Token')),
('refresh_token', models.CharField(blank=True, help_text='فقط برای روش OAuth — با دکمه\u200cی اتصال خودش پر می\u200cشود، دستی لازم نیست.', max_length=255, verbose_name='Refresh Token')),
('access_token_expires_at', models.DateTimeField(blank=True, help_text='اگر خالی باشد یعنی توکن ثابتِ روش جدید است و هیچ\u200cوقت خودکار تمدید نمی\u200cشود. اگر پر باشد یعنی از OAuth آمده و پیش از انقضا خودش تمدید خواهد شد.', null=True, verbose_name='انقضای Access Token (فقط OAuth)')),
('client_id', models.CharField(blank=True, max_length=255, verbose_name='Client ID (روش قدیمی/OAuth)')),
('client_secret', models.CharField(blank=True, max_length=255, verbose_name='Client Secret (روش قدیمی/OAuth)')),
('package_name', models.CharField(blank=True, max_length=150, verbose_name='application id اپ (مثلا ir.example.app)')),
('dynamic_discount_secret', models.CharField(blank=True, help_text='از پیشخان → برنامه\u200cات → پرداخت درون\u200cبرنامه\u200cای → تخفیف پویا. فقط سمت سرور استفاده می\u200cشود (apps.billing.providers.dynamic_discount) — هیچ\u200cوقت نباید در اپ فلاتر ذخیره شود، وگرنه کلید افشا می\u200cشود و کافه\u200cبازار مسئولیتی نمی\u200cپذیرد.', max_length=255, verbose_name='کلید امضای تخفیف پویا')),
('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,18 @@
# Generated by Django 5.1.15 on 2026-09-02 11:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='featureplan',
name='is_free',
field=models.BooleanField(default=False, help_text='اگر روشن باشد، این ابزار بدون جم و بدون دوره\u200cی آزمایشی در دسترس است.', verbose_name='رایگان'),
),
]
@@ -0,0 +1,71 @@
from django.db import migrations
# داده‌ی پایه‌ی فروشگاه — ابزارها، پلن‌های قیمتی، و بسته‌های جم.
#
# این seed قبلا در یک مهاجرتِ جداگانه بود که همراهِ بقیه‌ی فایل‌های مهاجرت
# از دست رفت. نبودش دقیقا همان باگی است که کافه‌بازار گزارش کرد: وقتی هیچ
# FeaturePlan ای در دیتابیس نباشد، اندپوینت features/ لیستِ خالی می‌دهد،
# اپ برای هر ابزار isFeatureActive=false می‌گیرد و پیغام «این ابزار فعال
# نیست» را نشان می‌دهد — حتی در دوره‌ی آزمایشی و حتی بعد از خرید جم.
#
# دو ابزار عمدا رایگان‌اند (is_free=True): «مشتریان» و «دفتر درآمد و هزینه».
# طبق قوانین انتشار کافه‌بازار باید بخشی از امکانات پیش از هر خریدی
# در دسترس باشد؛ این دو، هسته‌ی دفترداریِ اپ‌اند و بدون آن‌ها بقیه‌ی
# ابزارها هم معنی نمی‌دهند، پس بهترین گزینه برای رایگان‌بودن‌اند.
# هر وقت خواستی می‌توانی از پنل ادمین (ریزاپ‌های قابل‌فعال‌سازی → ستون
# «رایگان») این ترکیب را عوض کنی، بدون نیاز به مهاجرت تازه.
FEATURES = [
# (key, name, is_free, [(months, gem_price), ...])
("customers", "مشتریان", True, []),
("ledger", "دفتر درآمد و هزینه", True, []),
("invoice", "فاکتور", False, [(1, 1000), (3, 2700), (6, 5000)]),
("sms_bank", "پیام گروهی", False, [(1, 1200), (3, 3200), (6, 6000)]),
("inventory", "انبارداری", False, [(1, 800), (3, 2200), (6, 4000)]),
("orders", "پیگیری سفارش", False, [(1, 900), (3, 2400), (6, 4500)]),
]
GEM_PACKS = [
# (sku, name, gems, sort)
("gem_pack_500", "۵۰۰ جم", 500, 1),
("gem_pack_1200", "۱۲۰۰ جم", 1200, 2),
("gem_pack_3000", "۳۰۰۰ جم", 3000, 3),
]
def seed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
GemPack = apps.get_model("billing", "GemPack")
for key, name, is_free, tiers in FEATURES:
feature, _ = FeaturePlan.objects.update_or_create(
key=key,
defaults={"name": name, "is_free": is_free, "is_purchasable": not is_free},
)
for months, price in tiers:
FeaturePlanTier.objects.update_or_create(
feature=feature, duration_months=months, defaults={"gem_price": price}
)
for sku, name, gems, sort_order in GEM_PACKS:
GemPack.objects.update_or_create(
bazaar_sku=sku,
defaults={"display_name": name, "gem_amount": gems, "sort_order": sort_order, "is_active": True},
)
def unseed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
GemPack = apps.get_model("billing", "GemPack")
FeaturePlan.objects.filter(key__in=[f[0] for f in FEATURES]).delete()
GemPack.objects.filter(bazaar_sku__in=[g[0] for g in GEM_PACKS]).delete()
class Migration(migrations.Migration):
dependencies = [
("billing", "0002_featureplan_is_free"),
]
operations = [migrations.RunPython(seed, unseed)]
@@ -0,0 +1,53 @@
from django.db import migrations
# تغییرِ ترکیبِ رایگان/پولی طبق تصمیمِ محصول:
# رایگان (بدون اکانت): مشتریان، پیام گروهی، فاکتور
# پولی (فقط با جم): دفتر درآمد و هزینه، انبارداری، پیگیری سفارش
#
# سه ابزارِ رایگان عمدا همان‌هایی‌اند که بدونِ سرور هم معنی دارند (روی خودِ
# گوشی کار می‌کنند) تا واقعا بدونِ حساب کاربری قابل‌استفاده باشند.
FREE_KEYS = ["customers", "sms_bank", "invoice"]
PAID_KEYS = ["ledger", "inventory", "orders"]
# قیمتِ ابزارهایی که تازه پولی شده‌اند (اگر tier نداشته باشند).
PAID_TIERS = {
"ledger": [(1, 900), (3, 2400), (6, 4500)],
"inventory": [(1, 800), (3, 2200), (6, 4000)],
"orders": [(1, 900), (3, 2400), (6, 4500)],
}
def apply_split(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
FeaturePlan.objects.filter(key__in=FREE_KEYS).update(is_free=True, is_purchasable=False)
# ابزارِ رایگان نباید قیمت داشته باشد.
FeaturePlanTier.objects.filter(feature__key__in=FREE_KEYS).delete()
FeaturePlan.objects.filter(key__in=PAID_KEYS).update(is_free=False, is_purchasable=True)
for key, tiers in PAID_TIERS.items():
feature = FeaturePlan.objects.filter(key=key).first()
if feature is None:
continue
for months, price in tiers:
FeaturePlanTier.objects.update_or_create(
feature=feature, duration_months=months, defaults={"gem_price": price}
)
def revert_split(apps, schema_editor):
# برگشت به ترکیبِ قبلی (customers/ledger رایگان بودند).
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key__in=["customers", "ledger"]).update(is_free=True, is_purchasable=False)
FeaturePlan.objects.filter(key__in=["invoice", "sms_bank", "inventory", "orders"]).update(
is_free=False, is_purchasable=True
)
class Migration(migrations.Migration):
dependencies = [("billing", "0003_seed_feature_plans")]
operations = [migrations.RunPython(apply_split, revert_split)]
+8
View File
@@ -110,6 +110,14 @@ class FeaturePlan(models.Model):
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=_("قابل‌فروش"))
# ابزارِ رایگان همیشه در دسترس است و هیچ‌وقت جم نمی‌خواهد.
# قوانین انتشار کافه‌بازار: بخشی از امکاناتِ برنامه باید رایگان باشد و
# کاربر بتواند پیش از هر خریدی واقعا با اپ کار کند.
is_free = models.BooleanField(
default=False,
verbose_name=_("رایگان"),
help_text=_("اگر روشن باشد، این ابزار بدون جم و بدون دوره‌ی آزمایشی در دسترس است."),
)
class Meta:
verbose_name = _("ریزاپ قابل‌فعال‌سازی")
+179 -106
View File
@@ -1,35 +1,28 @@
# apps/billing/providers/bazaar.py
"""
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات رسمی کافه‌بازار
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
تا این تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
اتصال به API «پرداخت درون‌برنامه‌ای» کافه‌بازار (devapi v2) — گرفتن/تمدید
access_token و تایید خریدها.
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
/inapp/{product_id}/purchases/{purchase_token}/
دو روشِ کاملا متفاوت برای احراز هویت با همین API وجود دارد — قاطی‌کردنشان
دقیقا همان چیزی است که باعث خطا می‌شود، پس اینجا صریح از هم جدا شده‌اند:
پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است.
۱) «روش جدید» (پیشخان → اپ → «API پیشخان بازار» → «دریافت توکن جدید»):
یک توکن ثابت (JWT، عملا بدون انقضا) می‌گیری و مستقیم در access_token
می‌چسبانی؛ access_token_expires_at را خالی می‌گذاری. این توکن باید در
هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود — نه
به‌صورت access_token در query string. هیچ OAuth/client_id لازم نیست.
دو روش احراز هویت (هر دو طبق مستندات رسمی):
۲) «روش قدیمی» (OAuth با client_id/client_secret، تمدیدشونده): با
build_authorize_url → exchange_code_for_tokens یک access_token
موقت (و refresh_token) می‌گیری؛ access_token_expires_at پر می‌شود.
این access_token باید به‌صورت query param (?access_token=...) فرستاده
شود. verify_purchase خودش پیش از انقضا با refresh_token تمدیدش می‌کند.
«روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر:
CAFEBAZAAR-PISHKHAN-API-SECRET: <token>
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
?access_token=<token>
با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا
همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده.
تشخیص این‌که کدام روش فعال است صرفا از روی BazaarSettings.access_token_
expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با
رفرش خودکار پیش از انقضا).
همه‌چیز از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید بدون
ری‌دیپلوی قابل‌تعویض باشد.
مستندات رسمی: https://developers.cafebazaar.ir/fa/guidelines/in-app-billing
"""
import logging
from datetime import timedelta
from urllib.parse import urlencode
import requests
from django.utils import timezone
@@ -38,130 +31,210 @@ from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15
DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth"
PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET"
AUTHORIZE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/authorize/"
TOKEN_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/token/"
VALIDATE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}/inapp/{sku}/purchases/{token}/"
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید می‌کنیم تا وسط یک درخواست
# واقعی منقضی نشود (access_token های کافه‌بازار معمولا ۳۶۰۰ ثانیه عمر دارند).
REFRESH_SAFETY_MARGIN_SECONDS = 120
REQUEST_TIMEOUT = 15 # ثانیه
# اگر کمتر از این مدت تا انقضای access_token مانده باشد، پیش از استفاده
# با refresh_token تمدید می‌شود (به‌جای اینکه صبر کنیم واقعا 401 بگیریم).
REFRESH_MARGIN = timedelta(minutes=5)
# purchaseState طبق مستندات کافه‌بازار (هم‌شکل با Google Play):
# 0 = خریداری‌شده، 1 = لغوشده، 2 = در انتظار (pending)
PURCHASE_STATE_PURCHASED = 0
class BazaarAuthError(Exception):
"""احراز هویت با کافه‌بازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق)."""
"""خطای قابل‌نمایش مستقیم به ادمین (پیام‌اش با messages.error در admin.py نشان داده می‌شود)."""
def build_authorize_url(redirect_uri: str) -> str:
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یک‌بار در مرورگر باز و
تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در
پیشخان ثبت شده."""
def _force_https(url: str) -> str:
"""redirect_uri باید دقیقا با چیزی که موقع ساخت Client در پیشخان ثبت
شده یکی باشد — که تقریبا همیشه https است. اگر جنگو پشت ریورس‌پراکسی
باشد و SECURE_PROXY_SSL_HEADER تنظیم نشده باشد، request.build_absolute_uri
می‌تواند اشتباها http:// بسازد؛ این تابع همیشه https را تحمیل می‌کند تا
این باگِ رایج، دلیلِ خطای redirect_uri mismatch نشود.
اگر واقعا در محیط توسعه‌ی محلی روی http کار می‌کنی و Client هم با
http ثبت شده، این تابع را همین‌جا موقتا حذف کن — برای پروداکشن همیشه
باید https باشد."""
if url.startswith("http://"):
forced = "https://" + url[len("http://"):]
logger.warning("Bazaar OAuth callback_url ارسالی http بود، به https تبدیل شد: %s -> %s", url, forced)
return forced
return url
def build_authorize_url(callback_url: str) -> str:
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id:
raise BazaarAuthError("Client ID در تنظیمات کافه‌بازار خالی است.")
return (
f"{OAUTH_BASE_URL}/authorize/"
f"?response_type=code&access_type=offline"
f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}"
)
raise BazaarAuthError("ابتدا Client ID را در تنظیمات کافه‌بازار پر و ذخیره کن.")
params = {
"response_type": "code",
"access_type": "offline",
"redirect_uri": _force_https(callback_url),
"client_id": settings_obj.client_id,
}
return f"{AUTHORIZE_URL}?{urlencode(params)}"
def exchange_code_for_tokens(code: str, redirect_uri: str) -> None:
"""گام آخر فلوی OAuth — code گرفته‌شده از ریدایرکت (یا دستی کپی‌شده) را
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره می‌کند."""
def exchange_code_for_tokens(code: str, callback_url: str) -> None:
"""بعد از برگشت کاربر با ?code=... از پیشخان صدا زده می‌شود (oauth_callback_view)."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id or not settings_obj.client_secret:
raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافه‌بازار خالی است.")
raise BazaarAuthError("Client ID و Client Secret باید پیش از اتصال پر و ذخیره شده باشند.")
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
response = _post_token_request(
{
"grant_type": "authorization_code",
"code": code,
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"redirect_uri": redirect_uri,
},
timeout=REQUEST_TIMEOUT_SECONDS,
"redirect_uri": _force_https(callback_url),
}
)
if not response.ok:
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
_save_token_response(settings_obj, response)
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token:
if not settings_obj.refresh_token:
raise BazaarAuthError(
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
"access_token منقضی شده و refresh_token موجود نیست — از پنل ادمین دوباره «شروع اتصال به کافه‌بازار» را بزن."
)
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
response = _post_token_request(
{
"grant_type": "refresh_token",
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"refresh_token": settings_obj.refresh_token,
},
timeout=REQUEST_TIMEOUT_SECONDS,
}
)
if not response.ok:
raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
# پاسخِ refresh_token معمولا خودِ refresh_token جدید را برنمی‌گرداند —
# یعنی همان قبلی همچنان معتبر می‌ماند؛ _save_token_response این را رعایت می‌کند.
_save_token_response(settings_obj, response)
def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None:
settings_obj.access_token = body["access_token"]
# رفرش‌گرنت گاهی refresh_token جدید برنمی‌گرداند — قبلی را نگه می‌داریم.
if body.get("refresh_token"):
settings_obj.refresh_token = body["refresh_token"]
expires_in = body.get("expires_in", 3600)
settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in)
settings_obj.save(
update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"]
)
def _post_token_request(data: dict) -> requests.Response:
try:
return requests.post(TOKEN_URL, data=data, timeout=REQUEST_TIMEOUT)
except requests.RequestException as exc:
logger.exception("درخواست توکن کافه‌بازار با خطای شبکه مواجه شد")
raise BazaarAuthError(f"اتصال به سرور کافه‌بازار برقرار نشد: {exc}") from exc
def _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict:
"""توکنِ سالم برای یک درخواست devapi را برمی‌گرداند — هدر یا پارامتر URL،
بسته به این‌که توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
انقضاست، همین‌جا اول تمدیدش می‌کند)."""
is_oauth_managed = settings_obj.access_token_expires_at is not None
def _save_token_response(settings_obj: BazaarSettings, response: requests.Response) -> None:
if response.status_code >= 400:
try:
detail = response.json().get("error", response.text)
except ValueError:
detail = response.text
logger.warning("درخواست توکن کافه‌بازار رد شد: %s %s", response.status_code, detail)
# رایج‌ترین دلیلِ همین ارور: عدم تطابق redirect_uri با چیزی که در
# پیشخان ثبت شده (از نظر scheme/http-https یا اسلش انتهایی)، یا
# Client ID/Secret اشتباه.
raise BazaarAuthError(
f"کافه‌بازار درخواست را رد کرد ({response.status_code}): {detail}. "
"معمولا یعنی redirect_uri ثبت‌شده در پیشخان با آدرسی که سرور می‌فرستد یکی نیست "
"(https/http یا اسلش انتهایی را چک کن)، یا Client ID/Secret اشتباه است."
)
if is_oauth_managed:
margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS)
if timezone.now() >= settings_obj.access_token_expires_at - margin:
_refresh_access_token(settings_obj)
return {"params": {"access_token": settings_obj.access_token}}
try:
data = response.json()
except ValueError as exc:
raise BazaarAuthError("پاسخ کافه‌بازار قابل‌خواندن (JSON) نبود.") from exc
return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}}
settings_obj.access_token = data["access_token"]
settings_obj.access_token_expires_at = timezone.now() + timedelta(seconds=data.get("expires_in", 3600))
if data.get("refresh_token"):
settings_obj.refresh_token = data["refresh_token"]
settings_obj.save(update_fields=["access_token", "access_token_expires_at", "refresh_token"])
def verify_purchase(product_id: str, purchase_token: str) -> bool:
def _get_valid_settings() -> BazaarSettings:
"""BazaarSettings را با access_token معتبر برمی‌گرداند. اگر روش OAuth
باشد (access_token_expires_at پر است) و توکن نزدیک انقضا/منقضی باشد،
خودش با refresh_token تمدید می‌کند. اگر «روش جدید» (پیشخان، بدون
انقضا) باشد دست‌نخورده برمی‌گردد."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.access_token or not settings_obj.package_name:
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
if not settings_obj.package_name:
raise BazaarAuthError("Application id اپ در تنظیمات کافه‌بازار خالی است.")
if not settings_obj.access_token:
raise BazaarAuthError("هنوز هیچ access_token ای در تنظیمات کافه‌بازار ثبت نشده.")
if settings_obj.access_token_expires_at and timezone.now() >= (
settings_obj.access_token_expires_at - REFRESH_MARGIN
):
_refresh_access_token(settings_obj)
settings_obj.refresh_from_db()
return settings_obj
def _auth_kwargs(settings_obj: BazaarSettings) -> dict:
"""دو روش کاملا متفاوتِ ارسال توکن به API کافه‌بازار وجود دارد و
قاطی‌کردنشان دقیقا همان چیزی است که باعث خطا می‌شود:
- «روش جدید» (توکن ثابت پیشخان → «API پیشخان بازار» → «دریافت توکن
جدید»؛ در مدل با access_token_expires_at خالی مشخص می‌شود): باید در
هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود.
- «روش قدیمی» (OAuth با client_id/client_secret؛ access_token_expires_at
پر است): access_token به‌صورت query param فرستاده می‌شود.
فرستادنِ توکنِ روش جدید به‌صورت query param (یا برعکس) باعث رد شدن
درخواست توسط کافه‌بازار می‌شود، حتی اگر خودِ توکن کاملا معتبر باشد."""
if settings_obj.access_token_expires_at:
return {"params": {"access_token": settings_obj.access_token}}
return {"headers": {"CAFEBAZAAR-PISHKHAN-API-SECRET": settings_obj.access_token}}
def verify_purchase(sku: str, purchase_token: str) -> bool:
"""فقط وقتی True برمی‌گرداند که کافه‌بازار خرید را «معتبر و پرداخت‌شده»
تایید کند (purchaseState == 0). هر خطا یا وضعیت نامشخص → False (یعنی
VerifyPurchaseView در views.py آن را ناموفق حساب می‌کند و جمی اضافه
نمی‌شود — امن‌ترین رفتار پیش‌فرض)."""
try:
settings_obj = _get_valid_settings()
except BazaarAuthError:
logger.exception("verify_purchase: تنظیمات/توکن کافه‌بازار آماده نیست")
return False
url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
url = VALIDATE_URL.format(package_name=settings_obj.package_name, sku=sku, token=purchase_token)
def _call(s: BazaarSettings) -> requests.Response:
return requests.get(url, timeout=REQUEST_TIMEOUT, **_auth_kwargs(s))
try:
auth_kwargs = _auth_kwargs_for_request(settings_obj)
except BazaarAuthError as exc:
logger.error(f"❌ احراز هویت کافه‌بازار ناموفق: {exc}")
response = _call(settings_obj)
except requests.RequestException:
logger.exception("تایید خرید کافه‌بازار با خطای شبکه مواجه شد")
return False
if response.status_code == 401:
# توکن بین گرفتنش و همین لحظه باطل شده — این فقط برای روش OAuth
# (که refresh_token دارد) قابل‌جبران است؛ توکن ثابت روش جدید اگر رد
# شود یعنی از پیشخان باطل/عوض شده و باید دستی جایگزین شود.
if not settings_obj.refresh_token:
logger.warning("کافه‌بازار توکن را رد کرد (401) — اگر توکن «روش جدید» است، از پیشخان توکن تازه بگیر و جایگزین کن")
return False
try:
_refresh_access_token(settings_obj)
settings_obj.refresh_from_db()
response = _call(settings_obj)
except BazaarAuthError:
logger.exception("تمدید access_token بعد از 401 هم ناموفق بود")
return False
if response.status_code != 200:
logger.warning("تایید خرید کافه‌بازار پاسخ غیرمنتظره داد: %s %s", response.status_code, response.text)
return False
try:
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
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}")
data = response.json()
except ValueError:
logger.warning("پاسخ تایید خرید کافه‌بازار JSON نبود: %s", response.text)
return False
return data.get("purchaseState") == PURCHASE_STATE_PURCHASED
@@ -0,0 +1,17 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div style="max-width:720px">
<p style="margin-bottom:1rem">
به هر شماره‌ای که این‌جا وارد کنی، همان تعداد جم اضافه می‌شود و یک ردیف در
«تراکنش‌های جم» با علتِ «اصلاح دستی ادمین» ثبت می‌گردد.
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="submit-row">
<input type="submit" value="هدیه بده" class="default">
<a href="{% url 'admin:billing_gemtransaction_changelist' %}" class="button cancel-link">انصراف</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:billing_gemtransaction_gift' %}" class="addlink">هدیه‌ی جم به شماره‌ها</a>
</li>
{{ block.super }}
{% endblock %}
+192 -3
View File
@@ -1,13 +1,20 @@
from unittest.mock import Mock, patch
import jwt
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.account.models import User
from apps.billing.models import BazaarPurchase, BazaarSettings, GemPack, GemTransaction
from apps.billing.api.services import FeatureAccessService
from apps.billing.models import (
BazaarPurchase,
BazaarSettings,
FeaturePlan,
GemPack,
GemTransaction,
)
from apps.billing.providers.bazaar import (
BazaarAuthError,
build_authorize_url,
@@ -90,7 +97,9 @@ class BazaarOAuthTests(TestCase):
def test_build_authorize_url_shape(self):
url = build_authorize_url("https://example.com/callback/")
self.assertIn("client_id=client-123", url)
self.assertIn("redirect_uri=https://example.com/callback/", url)
# urlencode مقدار را percent-encode می‌کند — همین درست است، چون
# redirect_uri باید به‌شکلِ امنِ query-string فرستاده شود.
self.assertIn("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback%2F", url)
self.assertIn("response_type=code", url)
@patch("apps.billing.providers.bazaar.requests.post")
@@ -299,3 +308,183 @@ class PurchaseFlowIntegrationTests(TestCase):
second_id = self._create_purchase(token="same-token")
self.assertEqual(first_id, second_id)
self.assertEqual(BazaarPurchase.objects.filter(purchase_token="same-token").count(), 1)
class FreeFeatureAccessTests(TestCase):
"""قوانین انتشار کافه‌بازار: بخشی از امکانات باید بدون خرید در دسترس
باشد. این تست‌ها تضمین می‌کنند ابزارهای رایگان همیشه باز بمانند — حتی
بعد از پایانِ دوره‌ی آزمایشی."""
def setUp(self):
self.user = User.objects.create_user(phone="+989127777777", password="pass")
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_seed_creates_features_and_gem_packs(self):
# ریشه‌ی باگی که کافه‌بازار گزارش کرد: نبودِ داده‌ی پایه یعنی هر
# ابزار «فعال نیست» نشان داده می‌شود.
self.assertGreater(FeaturePlan.objects.count(), 0)
self.assertGreater(GemPack.objects.filter(is_active=True).count(), 0)
def test_at_least_one_feature_is_free(self):
self.assertTrue(
FeaturePlan.objects.filter(is_free=True).exists(),
"حداقل یک ابزار باید رایگان باشد وگرنه انتشار رد می‌شود.",
)
def test_free_feature_is_active_without_trial_or_gems(self):
self.user.trial_expires_at = timezone.now() - timezone.timedelta(days=1)
self.user.gem_balance = 0
self.user.save()
self.assertFalse(self.user.is_in_trial)
for feature in FeaturePlan.objects.filter(is_free=True):
self.assertTrue(
FeatureAccessService.is_active(self.user, feature.key),
f"ابزار رایگان {feature.key} باید باز باشد.",
)
def test_paid_feature_is_locked_after_trial_ends(self):
self.user.trial_expires_at = timezone.now() - timezone.timedelta(days=1)
self.user.save()
paid = FeaturePlan.objects.filter(is_free=False).first()
self.assertIsNotNone(paid)
self.assertFalse(FeatureAccessService.is_active(self.user, paid.key))
def test_paid_feature_is_open_during_trial(self):
self.user.trial_expires_at = timezone.now() + timezone.timedelta(days=3)
self.user.save()
paid = FeaturePlan.objects.filter(is_free=False).first()
self.assertTrue(FeatureAccessService.is_active(self.user, paid.key))
def test_features_endpoint_reports_free_flag(self):
response = self.client.get(reverse("billing:feature-list"))
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 0)
free = [f for f in response.data if f["is_free"]]
self.assertGreater(len(free), 0)
for f in free:
self.assertTrue(f["is_active"])
def test_free_features_have_no_price_tiers(self):
# ابزارِ رایگان نباید در فروشگاه قیمت نشان دهد.
for feature in FeaturePlan.objects.filter(is_free=True):
self.assertEqual(feature.tiers.count(), 0)
def test_expected_free_and_paid_split(self):
"""ترکیبِ تصمیم‌گرفته‌شده‌ی محصول — اگر کسی سهوا عوضش کند، همین‌جا
گیر می‌افتد (چون رایگان‌بودنِ این سه، شرطِ انتشار در کافه‌بازار است)."""
free = set(FeaturePlan.objects.filter(is_free=True).values_list("key", flat=True))
paid = set(FeaturePlan.objects.filter(is_free=False).values_list("key", flat=True))
self.assertEqual(free, {"customers", "sms_bank", "invoice"})
self.assertEqual(paid, {"ledger", "inventory", "orders"})
def test_paid_features_have_price_tiers(self):
for feature in FeaturePlan.objects.filter(is_free=False, is_purchasable=True):
self.assertGreater(
feature.tiers.count(), 0, f"{feature.key} قیمت ندارد."
)
class GuestFeatureCatalogTests(TestCase):
"""کاربرِ مهمان (بدون حساب) باید بتواند کاتالوگ ابزارها را ببیند و
بفهمد کدام رایگان است — وگرنه پیش از ورود همه‌چیز قفل به‌نظر می‌رسد."""
def setUp(self):
self.client = APIClient() # عمدا بدون احراز هویت
def test_anonymous_can_list_features(self):
response = self.client.get(reverse("billing:feature-list"))
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 0)
def test_free_features_report_active_for_anonymous(self):
response = self.client.get(reverse("billing:feature-list"))
by_key = {f["key"]: f for f in response.data}
for key in ["customers", "sms_bank", "invoice"]:
self.assertTrue(by_key[key]["is_free"], key)
self.assertTrue(by_key[key]["is_active"], key)
def test_paid_features_are_inactive_for_anonymous(self):
response = self.client.get(reverse("billing:feature-list"))
by_key = {f["key"]: f for f in response.data}
for key in ["ledger", "inventory", "orders"]:
self.assertFalse(by_key[key]["is_active"], key)
def test_anonymous_gets_no_expiry(self):
response = self.client.get(reverse("billing:feature-list"))
for f in response.data:
self.assertIsNone(f["expires_at"])
def test_wallet_still_requires_auth(self):
# کاتالوگ عمومی شد، ولی داده‌ی شخصی نه.
response = self.client.get(reverse("billing:wallet"))
self.assertIn(response.status_code, (401, 403))
class GemGiftAdminTests(TestCase):
"""هدیه‌ی جم از پنل ادمین — موجودی و تاریخچه باید با هم جلو بروند."""
def setUp(self):
self.staff = User.objects.create_superuser(phone="09120000001", password="x")
self.client = Client()
self.client.force_login(self.staff)
self.url = reverse("admin:billing_gemtransaction_gift")
def test_phone_normalization_accepts_the_shapes_admins_actually_paste(self):
from apps.billing.forms import normalize_iran_phone
for raw in ["09121234567", "+989121234567", "00989121234567",
"9121234567", "۰۹۱۲۱۲۳۴۵۶۷", "0912 123 4567"]:
self.assertEqual(normalize_iran_phone(raw), "09121234567", raw)
def test_gift_adds_gems_and_records_history(self):
target = User.objects.create(phone="09121112233")
response = self.client.post(
self.url,
{"phones": "09121112233", "amount": 250, "note": "جبران خطا"},
)
self.assertEqual(response.status_code, 302)
target.refresh_from_db()
self.assertEqual(target.gem_balance, 250)
tx = GemTransaction.objects.get(user=target)
self.assertEqual(tx.amount, 250)
self.assertEqual(tx.reason, GemTransaction.Reason.ADMIN_ADJUSTMENT)
self.assertEqual(tx.note, "جبران خطا")
def test_gift_handles_several_numbers_in_one_go(self):
a = User.objects.create(phone="09121112233")
b = User.objects.create(phone="09124445566")
self.client.post(
self.url,
{"phones": "09121112233\n+989124445566", "amount": 100},
)
a.refresh_from_db()
b.refresh_from_db()
self.assertEqual((a.gem_balance, b.gem_balance), (100, 100))
def test_unknown_number_is_skipped_but_the_rest_still_get_gems(self):
known = User.objects.create(phone="09121112233")
self.client.post(
self.url,
{"phones": "09121112233\n09129998877", "amount": 50},
)
known.refresh_from_db()
self.assertEqual(known.gem_balance, 50)
self.assertFalse(User.objects.filter(phone="09129998877").exists())
def test_create_missing_makes_the_user_so_the_gift_waits_for_them(self):
self.client.post(
self.url,
{"phones": "09129998877", "amount": 75, "create_missing": "on"},
)
created = User.objects.get(phone="09129998877")
self.assertEqual(created.gem_balance, 75)
def test_invalid_number_rejects_the_whole_form_rather_than_half_applying(self):
User.objects.create(phone="09121112233")
response = self.client.post(
self.url, {"phones": "09121112233\n12345", "amount": 10}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(GemTransaction.objects.count(), 0)