Files
shopship/apps/billing/models.py
T

221 lines
12 KiB
Python
Raw Normal View History

2026-08-24 15:54:08 +03:30
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
# سیستم جم/اشتراک: هر ابزار (پیامک گروهی، فاکتور، مشتریان و...) با جم
# برای یک بازه‌ی زمانی فعال می‌شود. جم فقط از طریق خرید تاییدشده‌ی کافه‌بازار
# (سمت سرور، بعد از تماس واقعی با API کافه‌بازار) یا پاداش معرفی اضافه
# می‌شود — User.gem_balance هیچ‌وقت مستقیم از کلاینت نوشته نمی‌شود.
class BazaarSettings(models.Model):
"""تنظیمات کافه‌بازار — عمداً از پنل ادمین ویرایش می‌شود (نه .env)، چون
2026-08-31 15:11:52 +03:30
باید بدون ری‌دیپلوی قابل‌تغییر باشد. تک‌ردیفی (singleton).
2026-08-24 15:54:08 +03:30
2026-08-31 15:11:52 +03:30
دو روش احراز هویت با 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)")
)
2026-08-24 15:54:08 +03:30
package_name = models.CharField(
max_length=150, blank=True, verbose_name=_("application id اپ (مثلا ir.example.app)")
)
2026-08-31 15:11:52 +03:30
dynamic_discount_secret = models.CharField(
max_length=255,
blank=True,
verbose_name=_("کلید امضای تخفیف پویا"),
help_text=_(
"از پیشخان → برنامه‌ات → پرداخت درون‌برنامه‌ای → تخفیف پویا. فقط سمت سرور استفاده "
"می‌شود (apps.billing.providers.dynamic_discount) — هیچ‌وقت نباید در اپ فلاتر ذخیره شود، "
"وگرنه کلید افشا می‌شود و کافه‌بازار مسئولیتی نمی‌پذیرد."
),
)
2026-08-24 15:54:08 +03:30
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("تنظیمات کافه‌بازار")
verbose_name_plural = _("تنظیمات کافه‌بازار")
def __str__(self):
return "تنظیمات کافه‌بازار"
def save(self, *args, **kwargs):
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls) -> "BazaarSettings":
obj, _created = cls.objects.get_or_create(pk=1)
return obj
class GemPack(models.Model):
"""بسته‌ی جم قابل‌خرید — bazaar_sku باید دقیقا همان محصولی باشد که در
کنسول توسعه‌دهندگان کافه‌بازار ثبت کرده‌ای."""
bazaar_sku = models.CharField(max_length=100, unique=True, verbose_name=_("SKU کافه‌بازار"))
display_name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
gem_amount = models.PositiveIntegerField(verbose_name=_("تعداد جم"))
is_active = models.BooleanField(default=True, verbose_name=_("قابل‌خرید"))
sort_order = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = _("بسته‌ی جم")
verbose_name_plural = _("بسته‌های جم")
ordering = ["sort_order", "gem_amount"]
def __str__(self):
return f"{self.display_name} ({self.gem_amount} جم)"
class FeaturePlan(models.Model):
"""یک ریزاپ قابل‌فعال‌سازی با جم — key باید با کلید همان ریزاپ در اپ
فلاتر یکی باشد (مثلا invoice، customers، ledger، inventory، booking،
sms_bank)."""
key = models.SlugField(max_length=50, unique=True, verbose_name=_("کلید ریزاپ"))
name = models.CharField(max_length=150, verbose_name=_("نام نمایشی"))
is_purchasable = models.BooleanField(default=True, verbose_name=_("قابل‌فروش"))
2026-09-04 13:05:54 +03:30
# ابزارِ رایگان همیشه در دسترس است و هیچ‌وقت جم نمی‌خواهد.
# قوانین انتشار کافه‌بازار: بخشی از امکاناتِ برنامه باید رایگان باشد و
# کاربر بتواند پیش از هر خریدی واقعا با اپ کار کند.
is_free = models.BooleanField(
default=False,
verbose_name=_("رایگان"),
help_text=_("اگر روشن باشد، این ابزار بدون جم و بدون دوره‌ی آزمایشی در دسترس است."),
)
2026-08-24 15:54:08 +03:30
class Meta:
verbose_name = _("ریزاپ قابل‌فعال‌سازی")
verbose_name_plural = _("ریزاپ‌های قابل‌فعال‌سازی")
def __str__(self):
return self.name
class FeaturePlanTier(models.Model):
"""قیمت هر مدت‌زمان برای یک ریزاپ — مثلا فاکتور: ۱ماهه=۱۰۰۰، ۳ماهه=۲۷۰۰،
۶ماهه=۵۰۰۰ جم (تخفیف پلکانی برای خرید مدت طولانی‌تر)."""
feature = models.ForeignKey(FeaturePlan, on_delete=models.CASCADE, related_name="tiers")
duration_months = models.PositiveSmallIntegerField(verbose_name=_("مدت (ماه)"))
gem_price = models.PositiveIntegerField(verbose_name=_("قیمت (جم)"))
class Meta:
verbose_name = _("پلن قیمتی")
verbose_name_plural = _("پلن‌های قیمتی")
constraints = [
models.UniqueConstraint(fields=["feature", "duration_months"], name="unique_feature_duration")
]
ordering = ["feature", "duration_months"]
def __str__(self):
return f"{self.feature.name} — {self.duration_months} ماهه — {self.gem_price} جم"
class FeatureActivation(models.Model):
"""فعال بودن یک ریزاپ برای یک کاربر تا چه زمانی. دوره‌ی رایگان جدا حساب
می‌شود (User.is_in_trial)؛ این مدل فقط اشتراکِ خریداری‌شده‌ی بعد از
دوره‌ی رایگان است."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="feature_activations")
feature = models.ForeignKey(FeaturePlan, on_delete=models.CASCADE, related_name="+")
expires_at = models.DateTimeField(verbose_name=_("تا تاریخ"))
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("فعال‌سازی ریزاپ")
verbose_name_plural = _("فعال‌سازی‌های ریزاپ")
constraints = [models.UniqueConstraint(fields=["user", "feature"], name="unique_user_feature_activation")]
def __str__(self):
return f"{self.user.phone} — {self.feature.name} تا {self.expires_at:%Y-%m-%d}"
class GemTransaction(models.Model):
"""دفتر تراکنش‌های جم — تنها منبع حقیقتِ تاریخچه؛ User.gem_balance فقط
یک مجموع cache‌شده است که هر تغییرش باید از همین‌جا هم رد شود."""
class Reason(models.TextChoices):
PURCHASE = "purchase", _("خرید از کافه‌بازار")
FEATURE_ACTIVATION = "feature_activation", _("فعال‌سازی ریزاپ")
REFERRAL_BONUS = "referral_bonus", _("پاداش معرفی")
REFERRAL_MILESTONE = "referral_milestone", _("پاداش دستاورد معرفی")
ADMIN_ADJUSTMENT = "admin_adjustment", _("اصلاح دستی ادمین")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="gem_transactions")
amount = models.IntegerField(verbose_name=_("مقدار (مثبت/منفی)"))
reason = models.CharField(max_length=30, choices=Reason.choices)
note = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("تراکنش جم")
verbose_name_plural = _("تراکنش‌های جم")
ordering = ["-created_at"]
def __str__(self):
return f"{self.user.phone} — {self.amount:+d} — {self.get_reason_display()}"
class BazaarPurchase(models.Model):
"""یک خرید بسته‌ی جم از کافه‌بازار. purchase_token یکتاست — همان توکنی
که کافه‌بازار به خریدار می‌دهد، برای جلوگیری از استفاده‌ی دوباره‌ی یک
توکن. تا وقتی status=verified نشود (با تماس واقعی سرور-به-سرور به API
کافه‌بازار)، هیچ جمی اضافه نمی‌شود."""
class Status(models.TextChoices):
PENDING = "pending", _("در انتظار تایید")
VERIFIED = "verified", _("تاییدشده")
FAILED = "failed", _("ناموفق")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bazaar_purchases")
gem_pack = models.ForeignKey(GemPack, on_delete=models.PROTECT, related_name="+")
purchase_token = models.CharField(max_length=255, unique=True, verbose_name=_("Purchase Token"))
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
gems_granted = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
verified_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("خرید کافه‌بازار")
verbose_name_plural = _("خریدهای کافه‌بازار")
ordering = ["-created_at"]
def __str__(self):
return f"{self.user.phone} — {self.gem_pack.display_name} — {self.status}"