adding
This commit is contained in:
+79
-4
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
@@ -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)]
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user