Files
MathApp/payment/models.py
Zareian 947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:38 +03:30

118 lines
7.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
# مدتِ اشتراکی که این سفارش فروخته، در لحظه‌ی ساخت (create_order). پرداخت و بازپرداخت همین را می‌دهند و
# پس می‌گیرند، نه مدتِ فعلیِ پلن. null یعنی سفارشِ بی‌پلن یا ساخته‌شده پیش از این ستون.
duration_days = models.PositiveIntegerField(null=True, 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)
# مبلغی (ریال) که در JWTِ «تخفیف پویا» برای همین سفارش امضا شد؛ بعد از آن کدِ تخفیف روی سفارش
# نمی‌نشیند (همان قاعده‌ی gateway_authority). null یعنی بازار قیمتِ پیشخان را گرفته/می‌گیرد.
bazaar_price_rial = models.PositiveIntegerField(null=True, blank=True)
# فقط اشتراکِ بازار: پایانِ دوره‌ای که همین سفارش پولش را داده، از پاسخِ بازار. تمدیدِ خودکار با
# جلو رفتنِ این عدد در بازار دیده می‌شود (payment.services.sync_bazaar_renewals).
bazaar_valid_until = models.DateTimeField(null=True, 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')
# ۳۰۰ مثلِ Order.bazaar_purchase_token: توکنِ خریدِ بازار همین‌جا می‌نشیند.
gateway_ref_id = models.CharField(max_length=300, 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 'متصل نیست'