There was no way to take an order back: staff could only edit the status by hand, and turning 'paid' into 'failed' left the credits, heart days or subscription days the order had given. Two OrderAdmin actions now do it: - cancel a pending order (a payment that does arrive later still counts); - refund a paid order, after a confirmation page: takes back the pack's unspent credits (never below zero), its heart days, or the plan's subscription days, and undoes the referrer's commission. The money itself is returned in the Zarinpal / Bazaar panel; the action says so. Status is read-only in the order form. Steps in docs/operations.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
6.6 KiB
Python
108 lines
6.6 KiB
Python
from django.db import models
|
||
|
||
|
||
class Order(models.Model):
|
||
"""
|
||
تنها اپی که واقعاً پول جابهجا میکنه. عمداً از Subscription جدا شده
|
||
چون منطق و ریسکش فرق داره (درگاه، retry، idempotency).
|
||
"""
|
||
# «لغوشده» و «بازپرداختشده» فقط از پنلِ ادمین (actionهای OrderAdmin) گذاشته میشوند، از ۲۰۲۶-۱۰-۰۲.
|
||
STATUS_CHOICES = [
|
||
('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق'),
|
||
('canceled', 'لغوشده'), ('refunded', 'بازپرداختشده'),
|
||
]
|
||
|
||
# 'gateway' و 'bazaar' منطق و صحتسنجیِ کاملاً متفاوتی دارن (webhook در
|
||
# برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست
|
||
# از Transaction.gateway (که فقط برای گزارشگیریِ درگاهِ واقعیه).
|
||
PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درونبرنامهای بازار')]
|
||
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
subscription_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Subscription
|
||
# پلنی که *این* سفارش برایش پرداخت میشود. پیشتر پلن فقط روی Subscription
|
||
# بود و با شروعِ یک خریدِ دیگر عوض میشد: پرداختِ سفارشِ ماهانه بعد از
|
||
# شروعِ خریدِ سالانه، اشتراکِ سالانه میداد (بازتولید شد، ۲۰۲۶-۰۹-۲۵).
|
||
plan_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Plan
|
||
# مبلغی که باید پرداخت شود؛ کدِ تخفیف همین را کم میکند (discount/views.py).
|
||
amount = models.PositiveIntegerField()
|
||
# عکسِ لحظهی خرید برای فاکتور: قیمتِ پایه پیش از تخفیف و نامِ پلن. بدونِ این،
|
||
# فاکتورِ ماهِ پیش با عوض شدنِ قیمت یا نامِ پلن در ادمین عوض میشد. سفارشهای
|
||
# پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی).
|
||
list_price = models.PositiveIntegerField(null=True, blank=True)
|
||
plan_title = models.CharField(max_length=50, blank=True)
|
||
currency = models.CharField(max_length=10, default='IRT')
|
||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
|
||
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
|
||
# فقط وقتی provider == 'bazaar' پر میشن: SKU محصول درونبرنامهای تعریفشده
|
||
# در پنل بازار، و purchase tokenای که کلاینت اندروید بعد از خرید از SDK
|
||
# بازار میگیره و برای verify به بکاند میفرسته.
|
||
bazaar_product_sku = models.CharField(max_length=100, blank=True)
|
||
bazaar_purchase_token = models.CharField(max_length=300, blank=True)
|
||
# فقط وقتی provider == 'gateway' (زرینپال) پر میشه: Authorityِ برگشتی از
|
||
# PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ
|
||
# ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرینپال
|
||
# موقعِ callback در query string (Authority=...) برمیگردونه، پس برای
|
||
# پیداکردنِ Order از روی callback لازمه.
|
||
gateway_authority = models.CharField(max_length=64, blank=True, db_index=True)
|
||
idempotency_key = models.CharField(max_length=100, unique=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f"order-{self.pk}-user{self.user_id}"
|
||
|
||
|
||
class Transaction(models.Model):
|
||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions')
|
||
gateway = models.CharField(max_length=30, default='zarinpal')
|
||
gateway_ref_id = models.CharField(max_length=100, blank=True)
|
||
status = models.CharField(max_length=30)
|
||
paid_at = models.DateTimeField(null=True, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ['created_at']
|
||
constraints = [
|
||
# یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای غیرخالی:
|
||
# یک ref مشخص (مثلاً Authorityِ زرینپال) فقط میتونه متعلقِ یک
|
||
# Order باشه — هم جلوی duplicate رو میگیره هم جلوی reuse/replay
|
||
# برای Orderِ دیگه (نگاه کنید به apply_order_payment_result).
|
||
# gateway_ref_id__gt='' یعنی refِ خالی مشمول نیست.
|
||
models.UniqueConstraint(
|
||
fields=['gateway', 'gateway_ref_id'],
|
||
condition=models.Q(gateway_ref_id__gt=''),
|
||
name='uniq_transaction_gateway_ref_global',
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"txn-{self.pk}-order{self.order_id}"
|
||
|
||
|
||
class BazaarCredential(models.Model):
|
||
"""
|
||
refresh tokenِ API بازار، که با اولین «اجازه دادن» در پنل بازار ساخته
|
||
میشود. در دیتابیس است نه `.env` تا گرفتنش کسی را مجبور به SSH نکند:
|
||
بازار کاربر را با `?code=` به همان `/api/payment/bazaar/verify`
|
||
برمیگرداند (Redirect URIای که در پنل ثبت شده) و ویو خودش code را عوض
|
||
میکند. یک ردیف بیشتر ندارد (pk=1).
|
||
|
||
`pending_state`/`pending_since` پنجرهی اتصال است: GETِ بیپنجره هیچ
|
||
کاری نمیکند، تا کسی نتواند با codeِ حسابِ بازارِ خودش توکنِ ما را
|
||
عوض کند. `BAZAAR_REFRESH_TOKEN` در `.env`، اگر باشد، بر این ردیف مقدم است.
|
||
"""
|
||
|
||
refresh_token = models.TextField(blank=True)
|
||
authorized_at = models.DateTimeField(null=True, blank=True)
|
||
pending_state = models.CharField(max_length=64, blank=True)
|
||
pending_since = models.DateTimeField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = 'اتصال به API بازار'
|
||
verbose_name_plural = 'اتصال به API بازار'
|
||
|
||
def __str__(self):
|
||
return 'متصل' if self.refresh_token else 'متصل نیست'
|