This commit is contained in:
2026-08-31 15:11:52 +03:30
parent 236002a87c
commit 463b812e3e
63 changed files with 3254 additions and 1476 deletions
+92 -2
View File
@@ -1,14 +1,39 @@
from django.contrib import admin
from django.contrib import admin, messages
from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils.html import format_html
from .models import BazaarPurchase, BazaarSettings, FeatureActivation, FeaturePlan, FeaturePlanTier, GemPack, GemTransaction
from .providers.bazaar import BazaarAuthError, build_authorize_url, exchange_code_for_tokens
@admin.register(BazaarSettings)
class BazaarSettingsAdmin(admin.ModelAdmin):
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم."""
"""تک‌ردیفی — لیست را رد می‌کنیم و مستقیم فرم ویرایش همان یک ردیف را نشان می‌دهیم.
برای اتصال با روش OAuth (تمدید خودکار access_token): این سه فیلد را پر
کن — Client ID، Client Secret، application id اپ — ذخیره کن، بعد از
«وضعیت اتصال کافه‌بازار» پایین فرم روی «شروع اتصال» بزن. اگر Redirect URI
که موقع ساخت Client در پیشخان ثبت کرده‌ای با آدرسِ نشان‌داده‌شده اینجا فرق
دارد، بعد از تایید در کافه‌بازار، مقدار code= را از آدرسِ برگشتی کپی و
خودت به همان آدرس callback اضافه کن (?code=...) — نیازی به فیلد جداگانه نیست.
یا ساده‌تر: اگر همین حالا یک توکن ثابتِ «روش جدید» پیشخان داری، مستقیم در
Access Token بچسبان و Access Token Expires At را خالی بگذار — کارش تمام
است، فقط اگر روزی باطل شد باید دستی توکن تازه بگیری."""
list_display = ("package_name", "updated_at")
readonly_fields = ("oauth_status",)
fields = (
"package_name",
"access_token",
"access_token_expires_at",
"refresh_token",
"client_id",
"client_secret",
"dynamic_discount_secret",
"oauth_status",
)
def has_add_permission(self, request):
return not BazaarSettings.objects.exists()
@@ -17,6 +42,71 @@ class BazaarSettingsAdmin(admin.ModelAdmin):
obj = BazaarSettings.get_solo()
return redirect("admin:billing_bazaarsettings_change", obj.pk)
def get_urls(self):
custom = [
path(
"oauth/connect/",
self.admin_site.admin_view(self.oauth_connect_view),
name="billing_bazaarsettings_oauth_connect",
),
path(
"oauth/callback/",
self.admin_site.admin_view(self.oauth_callback_view),
name="billing_bazaarsettings_oauth_callback",
),
]
return custom + super().get_urls()
def _callback_url(self, request) -> str:
return request.build_absolute_uri(reverse("admin:billing_bazaarsettings_oauth_callback"))
def oauth_connect_view(self, request):
obj = BazaarSettings.get_solo()
try:
authorize_url = build_authorize_url(self._callback_url(request))
except BazaarAuthError as exc:
messages.error(request, str(exc))
return redirect("admin:billing_bazaarsettings_change", obj.pk)
return redirect(authorize_url)
def oauth_callback_view(self, request):
obj = BazaarSettings.get_solo()
code = request.GET.get("code")
if not code:
messages.error(request, "پارامتر code در آدرس بازگشتی پیدا نشد.")
return redirect("admin:billing_bazaarsettings_change", obj.pk)
try:
exchange_code_for_tokens(code, self._callback_url(request))
except BazaarAuthError as exc:
messages.error(request, f"اتصال به کافه‌بازار ناموفق بود: {exc}")
else:
messages.success(request, "اتصال به کافه‌بازار موفق بود — access_token از این پس خودکار تمدید می‌شود.")
return redirect("admin:billing_bazaarsettings_change", obj.pk)
@admin.display(description="وضعیت اتصال کافه‌بازار")
def oauth_status(self, obj):
if not obj or not obj.pk:
return "ابتدا ذخیره کن."
callback_hint = reverse("admin:billing_bazaarsettings_oauth_callback")
connect_url = reverse("admin:billing_bazaarsettings_oauth_connect")
if obj.access_token_expires_at:
status_line = f"متصل (OAuth) — انقضای فعلی: {obj.access_token_expires_at:%Y-%m-%d %H:%M}"
elif obj.access_token:
status_line = "متصل با توکن ثابتِ روش جدید (بدون تمدید خودکار)."
else:
status_line = "هنوز متصل نیست."
return format_html(
"<p>{}</p>"
"<p>Redirect URI برای ثبت در پیشخان (اگر می‌خواهی OAuth را وصل کنی) — این مسیر را با "
"آدرس دامنه‌ی سرورت جلو ببر، مثلا https://api.example.com{} :</p>"
"<p><code>{}</code></p>"
'<p><a class="button" href="{}">شروع اتصال به کافه‌بازار (OAuth)</a></p>',
status_line,
callback_hint,
callback_hint,
connect_url,
)
class FeaturePlanTierInline(admin.TabularInline):
model = FeaturePlanTier
View File
@@ -0,0 +1,98 @@
"""
جایگزینِ خط‌فرمانیِ پر کردنِ دستیِ فرمِ ادمین برای BazaarSettings — همان چند
مقداری که کافه‌بازار می‌خواهد (Application id، Access Token، Client ID/Secret،
کلید تخفیف پویا) را این‌جا می‌پرسد و مستقیم در دیتابیس ذخیره می‌کند.
اجرا:
python manage.py setup_bazaar_settings
هیچ مقداری در خط‌فرمان به‌عنوان آرگومان پاس داده نمی‌شود (چون در تاریخچه‌ی
شل/history ذخیره می‌شد) — همه چیز تعاملی و با getpass پرسیده می‌شود (پیست‌کردن
در ترمینال کار می‌کند، فقط روی صفحه نمایش داده نمی‌شود).
Enter خالی یعنی «مقدارِ فعلی را دست نزن» — پس اجرای دوباره‌ی این دستور فقط
برای عوض‌کردنِ یکی-دو فیلد هم امن است، بقیه‌ی فیلدها دست‌نخورده می‌مانند.
"""
import getpass
from django.core.management.base import BaseCommand
from apps.billing.models import BazaarSettings
def _mask(value: str) -> str:
if not value:
return "(خالی)"
if len(value) <= 8:
return "•" * len(value)
return f"{value[:4]}…{value[-4:]} ({len(value)} کاراکتر)"
def _prompt_secret(label: str, current: str) -> str:
hint = f" [فعلی: {_mask(current)} — Enter برای بدون‌تغییر]" if current else " [Enter برای رد کردن]"
value = getpass.getpass(f"{label}{hint}: ").strip()
return value or current
def _prompt_plain(label: str, current: str, default: str = "") -> str:
shown_current = current or default
hint = f" [فعلی: {shown_current} — Enter برای بدون‌تغییر]" if shown_current else ""
value = input(f"{label}{hint}: ").strip()
return value or shown_current
class Command(BaseCommand):
help = "پر کردنِ تعاملیِ تنظیمات کافه‌بازار (BazaarSettings) بدون نیاز به پنل ادمین."
def handle(self, *args, **options):
settings_obj = BazaarSettings.get_solo()
self.stdout.write(self.style.NOTICE("تنظیمات کافه‌بازار — Enter خالی یعنی مقدار فعلی دست‌نخورده بماند.\n"))
package_name = _prompt_plain(
"Application id اپ (مثلا ir.niqow.kangelo)", settings_obj.package_name, default="ir.niqow.kangelo"
)
access_token = _prompt_secret(
"Access Token (توکن ثابتِ «روش جدید» پیشخان، یا هر access_token دیگر)",
settings_obj.access_token,
)
client_id = _prompt_plain("Client ID (روش قدیمی/OAuth — اختیاری)", settings_obj.client_id)
client_secret = _prompt_secret("Client Secret (روش قدیمی/OAuth — اختیاری)", settings_obj.client_secret)
dynamic_discount_secret = _prompt_secret(
"کلید امضای تخفیف پویا (اختیاری، فقط اگر می‌خواهی این ویژگی را استفاده کنی)",
settings_obj.dynamic_discount_secret,
)
# عمدا دست‌نخورده می‌مانند: refresh_token و access_token_expires_at فقط
# با دکمه‌ی «اتصال به کافه‌بازار» (OAuth) در پنل ادمین پر می‌شوند —
# پر کردنِ دستی‌شان این‌جا معنی ندارد چون بدونِ چرخه‌ی واقعیِ OAuth
# (redirect + code) هیچ access_token واقعی‌ای برایشان وجود ندارد.
settings_obj.package_name = package_name
settings_obj.access_token = access_token
settings_obj.client_id = client_id
settings_obj.client_secret = client_secret
settings_obj.dynamic_discount_secret = dynamic_discount_secret
settings_obj.save()
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("ذخیره شد. خلاصه‌ی مقادیر فعلی:"))
self.stdout.write(f" Application id: {settings_obj.package_name or '(خالی)'}")
self.stdout.write(f" Access Token: {_mask(settings_obj.access_token)}")
self.stdout.write(f" Client ID: {settings_obj.client_id or '(خالی)'}")
self.stdout.write(f" Client Secret: {_mask(settings_obj.client_secret)}")
self.stdout.write(f" کلید تخفیف پویا: {_mask(settings_obj.dynamic_discount_secret)}")
self.stdout.write(f" Refresh Token: {_mask(settings_obj.refresh_token)} (دست‌نخورده)")
self.stdout.write(
f" انقضای Access Token: {settings_obj.access_token_expires_at or '(خالی — یعنی توکن ثابت، بدون تمدید خودکار)'}"
)
if settings_obj.access_token and not settings_obj.access_token_expires_at:
self.stdout.write("")
self.stdout.write(
self.style.NOTICE(
"توکن ثابت ذخیره شد — از همین الان خرید باید کار کند. برای فعال‌کردنِ تمدیدِ "
"خودکار (اختیاری)، از پنل ادمین دکمه‌ی «اتصال به کافه‌بازار» را بزن."
)
)
-124
View File
@@ -1,124 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BazaarSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_token', models.CharField(blank=True, max_length=255, verbose_name='Access Token')),
('refresh_token', models.CharField(blank=True, max_length=255, verbose_name='Refresh Token')),
('package_name', models.CharField(blank=True, max_length=150, verbose_name='application id اپ (مثلا ir.example.app)')),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'تنظیمات کافه\u200cبازار',
'verbose_name_plural': 'تنظیمات کافه\u200cبازار',
},
),
migrations.CreateModel(
name='FeaturePlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.SlugField(unique=True, verbose_name='کلید ریزاپ')),
('name', models.CharField(max_length=150, verbose_name='نام نمایشی')),
('is_purchasable', models.BooleanField(default=True, verbose_name='قابل\u200cفروش')),
],
options={
'verbose_name': 'ریزاپ قابل\u200cفعال\u200cسازی',
'verbose_name_plural': 'ریزاپ\u200cهای قابل\u200cفعال\u200cسازی',
},
),
migrations.CreateModel(
name='GemPack',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bazaar_sku', models.CharField(max_length=100, unique=True, verbose_name='SKU کافه\u200cبازار')),
('display_name', models.CharField(max_length=150, verbose_name='نام نمایشی')),
('gem_amount', models.PositiveIntegerField(verbose_name='تعداد جم')),
('is_active', models.BooleanField(default=True, verbose_name='قابل\u200cخرید')),
('sort_order', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'بسته\u200cی جم',
'verbose_name_plural': 'بسته\u200cهای جم',
'ordering': ['sort_order', 'gem_amount'],
},
),
migrations.CreateModel(
name='BazaarPurchase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('purchase_token', models.CharField(max_length=255, unique=True, verbose_name='Purchase Token')),
('status', models.CharField(choices=[('pending', 'در انتظار تایید'), ('verified', 'تاییدشده'), ('failed', 'ناموفق')], default='pending', max_length=10)),
('gems_granted', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bazaar_purchases', to=settings.AUTH_USER_MODEL)),
('gem_pack', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='billing.gempack')),
],
options={
'verbose_name': 'خرید کافه\u200cبازار',
'verbose_name_plural': 'خریدهای کافه\u200cبازار',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='GemTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.IntegerField(verbose_name='مقدار (مثبت/منفی)')),
('reason', models.CharField(choices=[('purchase', 'خرید از کافه\u200cبازار'), ('feature_activation', 'فعال\u200cسازی ریزاپ'), ('referral_bonus', 'پاداش معرفی'), ('referral_milestone', 'پاداش دستاورد معرفی'), ('admin_adjustment', 'اصلاح دستی ادمین')], max_length=30)),
('note', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gem_transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'تراکنش جم',
'verbose_name_plural': 'تراکنش\u200cهای جم',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='FeatureActivation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('expires_at', models.DateTimeField(verbose_name='تا تاریخ')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_activations', to=settings.AUTH_USER_MODEL)),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='billing.featureplan')),
],
options={
'verbose_name': 'فعال\u200cسازی ریزاپ',
'verbose_name_plural': 'فعال\u200cسازی\u200cهای ریزاپ',
'constraints': [models.UniqueConstraint(fields=('user', 'feature'), name='unique_user_feature_activation')],
},
),
migrations.CreateModel(
name='FeaturePlanTier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('duration_months', models.PositiveSmallIntegerField(verbose_name='مدت (ماه)')),
('gem_price', models.PositiveIntegerField(verbose_name='قیمت (جم)')),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tiers', to='billing.featureplan')),
],
options={
'verbose_name': 'پلن قیمتی',
'verbose_name_plural': 'پلن\u200cهای قیمتی',
'ordering': ['feature', 'duration_months'],
'constraints': [models.UniqueConstraint(fields=('feature', 'duration_months'), name='unique_feature_duration')],
},
),
]
@@ -1,42 +0,0 @@
from django.db import migrations
# قیمت پایه‌ی هرکدام + پلکان تخفیف مدت (۳ماهه ≈۱۰٪ کمتر از ۳×ماهانه،
# ۶ماهه ≈۱۷٪، ۱۲ماهه ۲۵٪) — دقیقا مطابق مثال خودِ کارفرما برای فاکتور
# (۱۰۰۰ / ۲۷۰۰ / ۵۰۰۰)، همان الگو برای بقیه هم تعمیم داده شده.
FEATURES = [
("sms_bank", "بانک اس‌ام‌اس", 1500),
("invoice", "فاکتور", 1000),
("customers", "مشتریان", 500),
("ledger", "دفتر درآمد و هزینه", 800),
("inventory", "انبارداری", 800),
("booking", "نوبت‌دهی", 600),
]
_DURATION_MULTIPLIERS = {1: 1, 3: 2.7, 6: 5, 12: 9} # ۱۰٪/۱۷٪/۲۵٪ تخفیف نسبت به ماهانه×مدت
def _tier_prices(monthly: int) -> dict:
return {months: round(monthly * multiplier) for months, multiplier in _DURATION_MULTIPLIERS.items()}
def seed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
for key, name, monthly_price in FEATURES:
feature, _created = FeaturePlan.objects.get_or_create(key=key, defaults={"name": name})
for months, price in _tier_prices(monthly_price).items():
FeaturePlanTier.objects.get_or_create(
feature=feature, duration_months=months, defaults={"gem_price": price}
)
def unseed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key__in=[key for key, _, _ in FEATURES]).delete()
class Migration(migrations.Migration):
dependencies = [("billing", "0001_initial")]
operations = [migrations.RunPython(seed, unseed)]
@@ -1,19 +0,0 @@
from django.db import migrations
# ریزاپ کارگزاری کامل حذف شد — این migration پاک‌سازی می‌کند اگر روی یک DB
# که قبلاً 0002 را اجرا کرده بود (و رکورد "brokerage" را ساخته بود) اعمال شود.
# روی DB تازه (که 0002 دیگر "brokerage" را seed نمی‌کند) بی‌اثر است.
def remove_brokerage(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="brokerage").delete()
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [("billing", "0002_seed_feature_plans")]
operations = [migrations.RunPython(remove_brokerage, noop)]
@@ -1,18 +0,0 @@
from django.db import migrations
# اسم نمایشی ابزار «sms_bank» برای کاربر عادی (نه فنی) واضح‌تر شد.
def rename_forward(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="sms_bank").update(name="پیامک گروهی")
def rename_backward(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="sms_bank").update(name="بانک اس‌ام‌اس")
class Migration(migrations.Migration):
dependencies = [("billing", "0003_remove_brokerage_feature")]
operations = [migrations.RunPython(rename_forward, rename_backward)]
@@ -1,27 +0,0 @@
from django.db import migrations
_DURATION_MULTIPLIERS = {1: 1, 3: 2.7, 6: 5, 12: 9}
_MONTHLY_PRICE = 900
def _tier_prices() -> dict:
return {months: round(_MONTHLY_PRICE * multiplier) for months, multiplier in _DURATION_MULTIPLIERS.items()}
def seed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlanTier = apps.get_model("billing", "FeaturePlanTier")
feature, _created = FeaturePlan.objects.get_or_create(key="orders", defaults={"name": "پیگیری سفارش"})
for months, price in _tier_prices().items():
FeaturePlanTier.objects.get_or_create(feature=feature, duration_months=months, defaults={"gem_price": price})
def unseed(apps, schema_editor):
FeaturePlan = apps.get_model("billing", "FeaturePlan")
FeaturePlan.objects.filter(key="orders").delete()
class Migration(migrations.Migration):
dependencies = [("billing", "0004_rename_sms_bank_feature")]
operations = [migrations.RunPython(seed, unseed)]
+50 -3
View File
@@ -10,13 +10,60 @@ from django.utils.translation import gettext_lazy as _
class BazaarSettings(models.Model):
"""تنظیمات کافه‌بازار — عمداً از پنل ادمین ویرایش می‌شود (نه .env)، چون
باید بدون ری‌دیپلوی قابل‌تغییر باشد. تک‌ردیفی (singleton)."""
باید بدون ری‌دیپلوی قابل‌تغییر باشد. تک‌ردیفی (singleton).
access_token = models.CharField(max_length=255, blank=True, verbose_name=_("Access Token"))
refresh_token = models.CharField(max_length=255, blank=True, verbose_name=_("Refresh Token"))
دو روش احراز هویت با devapi کافه‌بازار پشتیبانی می‌شود:
۱) روش جدید (ساده): توکن ثابت از پیشخان → «API پیشخان بازار» → «دریافت
توکن جدید»، مستقیم در access_token جای‌گذاری می‌شود؛ چون access_token_
expires_at خالی می‌ماند، هیچ‌وقت سعی در refresh نمی‌شود (این توکن API
رفرش ندارد؛ اگر باطل شد باید دستی از پیشخان توکن تازه گرفت و همینجا
جایگزین کرد).
۲) روش قدیمی (OAuth با رفرش خودکار): client_id/client_secret را اینجا
بگذار، از دکمه‌ی «اتصال به کافه‌بازار» در همین صفحه استفاده کن؛ از آن
به بعد access_token هر بار که نزدیک انقضا باشد، خودش با refresh_token
تمدید می‌شود — بدون نیاز به مراجعه‌ی دستی دوباره."""
access_token = models.CharField(
max_length=255,
blank=True,
verbose_name=_("Access Token"),
help_text=_(
"یا توکن ثابت «روش جدید» پیشخان را مستقیم اینجا بچسبان، یا با دکمه‌ی اتصال زیر "
"بگذار خودش (روش قدیمی/OAuth) پر و بعدا خودکار تمدید شود."
),
)
refresh_token = models.CharField(
max_length=255, blank=True, verbose_name=_("Refresh Token"),
help_text=_("فقط برای روش OAuth — با دکمه‌ی اتصال خودش پر می‌شود، دستی لازم نیست."),
)
access_token_expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("انقضای Access Token (فقط OAuth)"),
help_text=_(
"اگر خالی باشد یعنی توکن ثابتِ روش جدید است و هیچ‌وقت خودکار تمدید نمی‌شود. "
"اگر پر باشد یعنی از OAuth آمده و پیش از انقضا خودش تمدید خواهد شد."
),
)
client_id = models.CharField(
max_length=255, blank=True, verbose_name=_("Client ID (روش قدیمی/OAuth)")
)
client_secret = models.CharField(
max_length=255, blank=True, verbose_name=_("Client Secret (روش قدیمی/OAuth)")
)
package_name = models.CharField(
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
)
dynamic_discount_secret = models.CharField(
max_length=255,
blank=True,
verbose_name=_("کلید امضای تخفیف پویا"),
help_text=_(
"از پیشخان → برنامه‌ات → پرداخت درون‌برنامه‌ای → تخفیف پویا. فقط سمت سرور استفاده "
"می‌شود (apps.billing.providers.dynamic_discount) — هیچ‌وقت نباید در اپ فلاتر ذخیره شود، "
"وگرنه کلید افشا می‌شود و کافه‌بازار مسئولیتی نمی‌پذیرد."
),
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
+129 -18
View File
@@ -1,33 +1,142 @@
# apps/billing/providers/bazaar.py
"""
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات عمومی devapi کافه‌بازار
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این
تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعه‌دهنده‌ی خودت فرق دارد، فقط
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیه‌ی پروژه فقط
`verify_purchase(product_id, purchase_token)` را صدا می‌زند):
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات رسمی کافه‌بازار
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
تا این تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
/inapp/{product_id}/purchases/{purchase_token}/?access_token=...
/inapp/{product_id}/purchases/{purchase_token}/
پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است.
access_token از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید
بدون ری‌دیپلوی قابل‌تعویض باشد (مثلا وقتی منقضی شد).
دو روش احراز هویت (هر دو طبق مستندات رسمی):
«روش جدید» — توکن ثابتِ پیشخان (.../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 — چون باید بدون
ری‌دیپلوی قابل‌تعویض باشد.
"""
import logging
import requests
from django.utils import timezone
from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15
BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
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"
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید می‌کنیم تا وسط یک درخواست
# واقعی منقضی نشود (access_token های کافه‌بازار معمولا ۳۶۰۰ ثانیه عمر دارند).
REFRESH_SAFETY_MARGIN_SECONDS = 120
class BazaarAuthError(Exception):
"""احراز هویت با کافه‌بازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق)."""
def build_authorize_url(redirect_uri: str) -> str:
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یک‌بار در مرورگر باز و
تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در
پیشخان ثبت شده."""
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}"
)
def exchange_code_for_tokens(code: str, redirect_uri: str) -> None:
"""گام آخر فلوی OAuth — code گرفته‌شده از ریدایرکت (یا دستی کپی‌شده) را
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره می‌کند."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id or not settings_obj.client_secret:
raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافه‌بازار خالی است.")
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
"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,
)
if not response.ok:
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
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:
raise BazaarAuthError(
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
)
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
"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())
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 _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict:
"""توکنِ سالم برای یک درخواست devapi را برمی‌گرداند — هدر یا پارامتر URL،
بسته به این‌که توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
انقضاست، همین‌جا اول تمدیدش می‌کند)."""
is_oauth_managed = settings_obj.access_token_expires_at is not None
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}}
return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}}
def verify_purchase(product_id: str, purchase_token: str) -> bool:
@@ -36,14 +145,16 @@ def verify_purchase(product_id: str, purchase_token: str) -> bool:
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
return False
url = f"{BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
try:
response = requests.get(
url,
params={"access_token": settings_obj.access_token},
timeout=REQUEST_TIMEOUT_SECONDS,
)
auth_kwargs = _auth_kwargs_for_request(settings_obj)
except BazaarAuthError as exc:
logger.error(f"❌ احراز هویت کافه‌بازار ناموفق: {exc}")
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")
@@ -0,0 +1,57 @@
# apps/billing/providers/dynamic_discount.py
"""
تولید توکن «تخفیف پویا» کافه‌بازار
(developers.cafebazaar.ir/fa/guidelines/in-app-billing/dynamic-discount).
فقط یک تابع خام است، بدون endpoint/کمپین — چون فعلا کمپین تخفیف مشخصی در
کار نیست؛ هر وقت لازم شد (مثلا یک ویو که برای یک GemPack با قیمت پایین‌تر
توکن می‌سازد)، همین تابع را صدا بزن. کلید امضا (BazaarSettings.
dynamic_discount_secret) عمدا فقط همین‌جا (سمت سرور) استفاده می‌شود و
هیچ‌وقت نباید به کلاینت/اپ فلاتر فرستاده شود — طبق مستندات، ذخیره‌ی این کلید
در اپ یعنی افشای آن، و کافه‌بازار مسئولیتی در قبال سوءاستفاده نمی‌پذیرد.
خروجی این تابع مستقیم در فیلد dynamicPriceToken کلاس PurchaseRequest
(سمت اپ، هنگام FlutterPoolakey.purchase) استفاده می‌شود.
"""
import time
import uuid
import jwt
from apps.billing.models import BazaarSettings
class DynamicDiscountNotConfigured(Exception):
"""کلید امضای تخفیف پویا در BazaarSettings تنظیم نشده."""
def generate_dynamic_discount_token(
*,
sku: str,
price_rial: int,
ttl_seconds: int = 300,
account_id: str | None = None,
) -> str:
"""یک توکن یک‌بارمصرف طبق مشخصات مستندات کافه‌بازار می‌سازد.
price_rial باید <= قیمت ثبت‌شده‌ی همین sku در پنل پیشخان باشد، وگرنه
کافه‌بازار خودش موقع خرید خطای «قیمت بیش از حد مجاز» (کد ۱۰) برمی‌گرداند."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.dynamic_discount_secret or not settings_obj.package_name:
raise DynamicDiscountNotConfigured(
"dynamic_discount_secret یا package_name در تنظیمات کافه‌بازار خالی است."
)
now = int(time.time())
payload = {
"price": price_rial,
"package_name": settings_obj.package_name,
"sku": sku,
"exp": now + ttl_seconds,
"nonce": uuid.uuid4().hex,
}
if account_id:
payload["account_id"] = account_id
return jwt.encode(payload, settings_obj.dynamic_discount_secret, algorithm="HS256")
+301
View File
@@ -0,0 +1,301 @@
from unittest.mock import Mock, patch
import jwt
from django.test import 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.providers.bazaar import (
BazaarAuthError,
build_authorize_url,
exchange_code_for_tokens,
verify_purchase,
)
from apps.billing.providers.dynamic_discount import (
DynamicDiscountNotConfigured,
generate_dynamic_discount_token,
)
def _make_response(status_ok=True, json_body=None, status_code=200):
response = Mock()
response.ok = status_ok
response.status_code = status_code
response.json.return_value = json_body or {}
response.text = str(json_body)
response.raise_for_status = Mock()
if not status_ok:
response.raise_for_status.side_effect = Exception("http error")
return response
class BazaarStaticTokenVerifyTests(TestCase):
"""روش جدید — توکن ثابتِ پیشخان، بدون هیچ منطق تمدید."""
def setUp(self):
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.access_token = "static-pishkhan-token"
self.settings_obj.package_name = "ir.niqow.kangelo"
self.settings_obj.save()
@patch("apps.billing.providers.bazaar.requests.get")
def test_sends_static_token_via_pishkhan_header_not_query_param(self, mock_get):
mock_get.return_value = _make_response(json_body={"purchaseState": 0})
result = verify_purchase("gem_pack_100", "purchase-token-abc")
self.assertTrue(result)
_, kwargs = mock_get.call_args
self.assertEqual(kwargs["headers"], {"CAFEBAZAAR-PISHKHAN-API-SECRET": "static-pishkhan-token"})
self.assertNotIn("params", kwargs)
@patch("apps.billing.providers.bazaar.requests.get")
def test_refund_purchase_state_is_rejected(self, mock_get):
mock_get.return_value = _make_response(json_body={"purchaseState": 1})
self.assertFalse(verify_purchase("gem_pack_100", "tok"))
@patch("apps.billing.providers.bazaar.requests.get")
def test_network_error_fails_closed(self, mock_get):
import requests
mock_get.side_effect = requests.RequestException("boom")
self.assertFalse(verify_purchase("gem_pack_100", "tok"))
def test_missing_settings_fails_closed_without_network_call(self):
BazaarSettings.objects.all().delete()
with patch("apps.billing.providers.bazaar.requests.get") as mock_get:
self.assertFalse(verify_purchase("gem_pack_100", "tok"))
mock_get.assert_not_called()
class BazaarOAuthTests(TestCase):
"""روش قدیمی — OAuth با client_id/client_secret و تمدید خودکار."""
def setUp(self):
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.package_name = "ir.niqow.kangelo"
self.settings_obj.client_id = "client-123"
self.settings_obj.client_secret = "secret-456"
self.settings_obj.save()
def test_build_authorize_url_requires_client_id(self):
self.settings_obj.client_id = ""
self.settings_obj.save()
with self.assertRaises(BazaarAuthError):
build_authorize_url("https://example.com/callback/")
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)
self.assertIn("response_type=code", url)
@patch("apps.billing.providers.bazaar.requests.post")
def test_exchange_code_saves_tokens_and_expiry(self, mock_post):
mock_post.return_value = _make_response(
json_body={"access_token": "fresh-token", "refresh_token": "refresh-token", "expires_in": 3600}
)
exchange_code_for_tokens("auth-code", "https://example.com/callback/")
self.settings_obj.refresh_from_db()
self.assertEqual(self.settings_obj.access_token, "fresh-token")
self.assertEqual(self.settings_obj.refresh_token, "refresh-token")
self.assertIsNotNone(self.settings_obj.access_token_expires_at)
self.assertGreater(self.settings_obj.access_token_expires_at, timezone.now())
@patch("apps.billing.providers.bazaar.requests.post")
def test_exchange_code_failure_raises_and_does_not_save(self, mock_post):
mock_post.return_value = _make_response(status_ok=False, status_code=400)
with self.assertRaises(BazaarAuthError):
exchange_code_for_tokens("bad-code", "https://example.com/callback/")
self.settings_obj.refresh_from_db()
self.assertEqual(self.settings_obj.access_token, "")
@patch("apps.billing.providers.bazaar.requests.get")
@patch("apps.billing.providers.bazaar.requests.post")
def test_verify_purchase_refreshes_expired_token_then_uses_query_param(self, mock_post, mock_get):
self.settings_obj.access_token = "stale-token"
self.settings_obj.refresh_token = "refresh-token"
self.settings_obj.access_token_expires_at = timezone.now() - timezone.timedelta(seconds=5)
self.settings_obj.save()
mock_post.return_value = _make_response(
json_body={"access_token": "brand-new-token", "expires_in": 3600}
)
mock_get.return_value = _make_response(json_body={"purchaseState": 0})
result = verify_purchase("sku", "tok")
self.assertTrue(result)
mock_post.assert_called_once()
_, get_kwargs = mock_get.call_args
self.assertEqual(get_kwargs["params"], {"access_token": "brand-new-token"})
self.settings_obj.refresh_from_db()
self.assertEqual(self.settings_obj.access_token, "brand-new-token")
@patch("apps.billing.providers.bazaar.requests.get")
def test_verify_purchase_does_not_refresh_when_token_still_fresh(self, mock_get):
self.settings_obj.access_token = "still-good"
self.settings_obj.refresh_token = "refresh-token"
self.settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(hours=1)
self.settings_obj.save()
mock_get.return_value = _make_response(json_body={"purchaseState": 0})
with patch("apps.billing.providers.bazaar.requests.post") as mock_post:
verify_purchase("sku", "tok")
mock_post.assert_not_called()
@patch("apps.billing.providers.bazaar.requests.get")
def test_verify_purchase_fails_closed_when_refresh_credentials_missing(self, mock_get):
self.settings_obj.access_token = "stale"
self.settings_obj.refresh_token = "" # نداریم؛ تمدید ممکن نیست
self.settings_obj.access_token_expires_at = timezone.now() - timezone.timedelta(seconds=5)
self.settings_obj.save()
self.assertFalse(verify_purchase("sku", "tok"))
mock_get.assert_not_called()
class DynamicDiscountTokenTests(TestCase):
def setUp(self):
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.package_name = "ir.niqow.kangelo"
self.settings_obj.dynamic_discount_secret = "top-secret-signing-key"
self.settings_obj.save()
def test_raises_when_not_configured(self):
self.settings_obj.dynamic_discount_secret = ""
self.settings_obj.save()
with self.assertRaises(DynamicDiscountNotConfigured):
generate_dynamic_discount_token(sku="gem_pack_100", price_rial=50000)
def test_token_payload_matches_bazaar_spec(self):
token = generate_dynamic_discount_token(sku="gem_pack_100", price_rial=50000, account_id="acc-1")
payload = jwt.decode(token, "top-secret-signing-key", algorithms=["HS256"])
self.assertEqual(payload["price"], 50000)
self.assertEqual(payload["package_name"], "ir.niqow.kangelo")
self.assertEqual(payload["sku"], "gem_pack_100")
self.assertEqual(payload["account_id"], "acc-1")
self.assertIn("nonce", payload)
self.assertIn("exp", payload)
def test_two_tokens_for_same_input_have_different_nonce(self):
token_a = generate_dynamic_discount_token(sku="sku", price_rial=1000)
token_b = generate_dynamic_discount_token(sku="sku", price_rial=1000)
self.assertNotEqual(token_a, token_b)
class BazaarSettingsAdminOAuthViewTests(TestCase):
"""کانکت/کال‌بک OAuth از پنل ادمین — فقط staff، و کد از querystring هم
اگر دستی چسبانده شود (redirect_uri متفاوت) کار می‌کند."""
def setUp(self):
self.staff = User.objects.create_user(phone="+989120000000", password="pass")
self.staff.is_staff = True
self.staff.is_superuser = True
self.staff.save()
self.client.force_login(self.staff)
self.settings_obj = BazaarSettings.get_solo()
self.settings_obj.client_id = "client-123"
self.settings_obj.client_secret = "secret-456"
self.settings_obj.save()
def test_connect_view_redirects_to_cafebazaar_when_configured(self):
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_connect"))
self.assertEqual(response.status_code, 302)
self.assertIn("pardakht.cafebazaar.ir/devapi/v2/auth/authorize", response.url)
def test_connect_view_errors_gracefully_when_client_id_missing(self):
self.settings_obj.client_id = ""
self.settings_obj.save()
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_connect"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/", response.url)
def test_callback_without_code_shows_error_not_crash(self):
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_callback"))
self.assertEqual(response.status_code, 302)
@patch("apps.billing.admin.exchange_code_for_tokens")
def test_callback_with_code_exchanges_and_redirects_back(self, mock_exchange):
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_callback"), {"code": "abc"})
self.assertEqual(response.status_code, 302)
mock_exchange.assert_called_once()
self.assertEqual(mock_exchange.call_args[0][0], "abc")
def test_anonymous_cannot_reach_oauth_views(self):
self.client.logout()
response = self.client.get(reverse("admin:billing_bazaarsettings_oauth_connect"))
self.assertNotEqual(response.status_code, 200)
class PurchaseFlowIntegrationTests(TestCase):
"""مسیر کامل و واقعی: ساخت خرید → verify → جم اضافه شود، دوبار-verify
دوبار جم ندهد، خرید نامعتبر جم ندهد."""
def setUp(self):
self.user = User.objects.create_user(phone="+989121234567", password="pass")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.gem_pack = GemPack.objects.create(
bazaar_sku="gem_pack_100", display_name="۱۰۰ جم", gem_amount=100
)
settings_obj = BazaarSettings.get_solo()
settings_obj.access_token = "static-token"
settings_obj.package_name = "ir.niqow.kangelo"
settings_obj.save()
def _create_purchase(self, token="purchase-token-1"):
response = self.client.post(
reverse("billing:purchase-create"),
{"gem_pack_id": self.gem_pack.id, "purchase_token": token},
)
self.assertEqual(response.status_code, 201)
return response.data["id"]
@patch("apps.billing.api.views.verify_purchase", return_value=True)
def test_verified_purchase_grants_gems_exactly_once(self, mock_verify):
purchase_id = self._create_purchase()
response = self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], BazaarPurchase.Status.VERIFIED)
self.user.refresh_from_db()
self.assertEqual(self.user.gem_balance, 100)
self.assertEqual(
GemTransaction.objects.filter(user=self.user, reason=GemTransaction.Reason.PURCHASE).count(), 1
)
@patch("apps.billing.api.views.verify_purchase", return_value=True)
def test_double_verify_does_not_double_credit(self, mock_verify):
purchase_id = self._create_purchase()
self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.user.refresh_from_db()
self.assertEqual(self.user.gem_balance, 100)
self.assertEqual(GemTransaction.objects.filter(user=self.user).count(), 1)
@patch("apps.billing.api.views.verify_purchase", return_value=False)
def test_invalid_purchase_grants_no_gems(self, mock_verify):
purchase_id = self._create_purchase()
response = self.client.post(reverse("billing:purchase-verify", args=[purchase_id]))
self.assertEqual(response.status_code, 400)
self.user.refresh_from_db()
self.assertEqual(self.user.gem_balance, 0)
purchase = BazaarPurchase.objects.get(id=purchase_id)
self.assertEqual(purchase.status, BazaarPurchase.Status.FAILED)
def test_reusing_same_purchase_token_is_idempotent_at_create(self):
first_id = self._create_purchase(token="same-token")
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)