adding
This commit is contained in:
+92
-2
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"توکن ثابت ذخیره شد — از همین الان خرید باید کار کند. برای فعالکردنِ تمدیدِ "
|
||||
"خودکار (اختیاری)، از پنل ادمین دکمهی «اتصال به کافهبازار» را بزن."
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user