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>
119 lines
8.1 KiB
Python
119 lines
8.1 KiB
Python
from django.db import models
|
||
|
||
|
||
class Plan(models.Model):
|
||
"""
|
||
⚠️ اصلاح نسبت به سند اولیه: نمونهی پاسخ GET /api/subscription/plans در
|
||
سند Endpointهای API یک فیلد boolean به اسم `popular` داره («ماهانه» →
|
||
false، «سالانه» → true) که در دیاگرام مدلها (سند معماری داده) برای
|
||
Plan اصلاً ذکر نشده. چون بدون این فیلد نمیشه دقیقاً همون response
|
||
schema رو پیادهسازی کرد، اینجا اضافه شده — دقیقاً مثل فیکس is_correct
|
||
روی content.Choice در فاز ۱.
|
||
"""
|
||
title = models.CharField(max_length=50)
|
||
price = models.PositiveIntegerField()
|
||
currency = models.CharField(max_length=10, default='IRT')
|
||
discount_percent = models.PositiveSmallIntegerField(default=0)
|
||
popular = models.BooleanField(default=False)
|
||
# منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو
|
||
# از رویِ متنِ title (مثلاً کلمهی «سالانه») حدس بزنه.
|
||
duration_days = models.PositiveIntegerField(default=30)
|
||
|
||
# کارتِ اشتراک زیرِ هر پلن فهرستِ امکانات نشان میدهد («درسهای نامحدود»،
|
||
# «دستیار هوشمند»…). تا امروز این فهرست در کدِ فرانت هاردکد بود، یعنی
|
||
# عوض کردنِ یک بند به دیپلویِ تازهی اپ نیاز داشت.
|
||
features = models.JSONField(default=list, blank=True)
|
||
# برچسبِ بالای کارت: «محبوبترین»، «بهترین ارزش». `popular` فقط بولین است
|
||
# و یک متن نمیدهد؛ این جایگزینش نیست، کنارش است.
|
||
badge = models.CharField(max_length=30, blank=True)
|
||
# چند سوال در روز از دستیار (۱۰ برای پلنهای فعلی، subscription.0008). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینهی API
|
||
# دارد (docs/ai-cost-and-guardrails.md)، پس این عدد مستقیم سودِ پلن را تعیین میکند.
|
||
# کاربرِ بدونِ اشتراک سقفِ AI_DAILY_QUOTA_FREE (settings) را دارد.
|
||
ai_daily_quota = models.PositiveSmallIntegerField(
|
||
default=15, help_text='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.')
|
||
|
||
# از ۲۰۲۶-۱۰-۰۲ پلن دو نوع است. «بستهی سوال» (pack) تعدادی سوال از دستیار میفروشد که
|
||
# منقضی نمیشود، بهعلاوهی چند روز قلبِ نامحدود؛ به Subscription دست نمیزند و سقفِ روزانه
|
||
# نمیدهد. «اشتراک» (subscription) همان مدلِ قبلی است و برای مشترکهای فعلی میماند.
|
||
KIND_CHOICES = [('pack', 'بستهی سوال'), ('subscription', 'اشتراکِ زمانی')]
|
||
kind = models.CharField(max_length=15, choices=KIND_CHOICES, default='subscription')
|
||
question_credits = models.PositiveIntegerField(
|
||
default=0, help_text='فقط بسته: چند سوال از دستیار به اعتبارِ خریدار اضافه میشود.')
|
||
hearts_days = models.PositiveSmallIntegerField(
|
||
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
|
||
# پلنی که دیگر فروخته نمیشود ولی مشترکهای فعلیاش هنوز به آن وصلاند.
|
||
on_sale = models.BooleanField(default=True, help_text='در صفحهی خرید نشان داده شود.')
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
class Subscription(models.Model):
|
||
"""وضعیتِ pending برای فاصلهی بینِ ساختنِ Subscription (در purchase) و
|
||
تاییدِ پرداخت (webhook)؛ فعالسازیِ واقعی توسطِ
|
||
subscription.services.activate_subscription انجام میشه."""
|
||
STATUS_CHOICES = [
|
||
('pending', 'در انتظار پرداخت'),
|
||
('active', 'فعال'),
|
||
('expired', 'منقضی'),
|
||
('canceled', 'لغوشده'),
|
||
]
|
||
|
||
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
|
||
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, related_name='subscriptions')
|
||
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending')
|
||
started_at = models.DateTimeField(auto_now_add=True)
|
||
expires_at = models.DateTimeField(null=True, blank=True)
|
||
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
|
||
# چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ همزمانِ
|
||
# همون کاربر میتونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسهی
|
||
# فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ
|
||
# version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو
|
||
# از نوشتنِ همزمانِ دیگری تمیز میده.
|
||
version = models.PositiveIntegerField(default=0)
|
||
|
||
def __str__(self):
|
||
return f"sub-user{self.user_id}-{self.status}"
|
||
|
||
|
||
class QuestionCredit(models.Model):
|
||
"""
|
||
دفترِ اعتبارِ سوال: هر خریدِ بسته یک ردیفِ مثبت، هر سوالی که با اعتبار پرسیده شد یک ردیفِ ۱−.
|
||
موجودی = جمعِ delta. چرا دفتر و نه یک عددِ «موجودی»: با عدد، دو درخواستِ همزمان میتوانستند
|
||
یک اعتبار را دو بار خرج کنند و هیچ راهی نبود بفهمیم هر سوال از کدام خرید آمده. کم کردن داخلِ
|
||
همان تراکنشی است که ردیفِ کاربر را قفل میکند (ai_tutor.views، SQLite با IMMEDIATE).
|
||
|
||
ردیفِ ۱− فقط وقتی پاک میشود که خودِ سوال پس گرفته شود: جوابِ ناموفق یا جوابی که از کش آمد و
|
||
به API نرسید. اعتبار منقضی نمیشود (تصمیمِ صاحبِ پروژه).
|
||
"""
|
||
# «refund»: ردیفِ منفیِ بازپرداختِ سفارش (payment.services.refund_order)؛ فقط اعتبارِ خرجنشده را پس میگیرد.
|
||
REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیهی ادمین'), ('refund', 'بازپرداخت')]
|
||
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
delta = models.IntegerField()
|
||
reason = models.CharField(max_length=10, choices=REASON_CHOICES)
|
||
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
|
||
message_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به ai_tutor.Message
|
||
note = models.CharField(max_length=100, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
constraints = [
|
||
# هر سفارش فقط یک بار اعتبار میدهد، حتی اگر تأییدِ پرداخت دو بار برسد.
|
||
models.UniqueConstraint(fields=['order_id'], condition=models.Q(reason='purchase'),
|
||
name='uniq_credit_per_paid_order'),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'credit-user{self.user_id}:{self.delta:+d} ({self.reason})'
|
||
|
||
|
||
class HeartsPass(models.Model):
|
||
"""قلبِ نامحدودِ هدیهی بستهها تا expires_at؛ جدا از Subscription تا خریدِ بسته اشتراکِ کسی را عوض نکند."""
|
||
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
|
||
expires_at = models.DateTimeField()
|
||
|
||
def __str__(self):
|
||
return f'hearts-pass-user{self.user_id}-{self.expires_at:%Y-%m-%d}'
|