Paywall: first unit of each grade free; 1/3/6-month subscriptions; AI sold separately

- content: unit_is_free/has_unit_access; questions, node quiz, answer and lesson/node
  complete return 402 subscription_required outside the first unit without an active
  subscription. /api/home units carry requiresSubscription.
- subscription.0013: monthly 498k, quarterly 798k, six-month 1,298k toman, unlimited
  hearts, 3 AI questions/day; packs stay on sale for more questions.
- AI_DAILY_QUOTA_FREE default 3 -> 2.
- /api/subscription/status adds planTitle, planDurationDays, aiDailyQuota.
- tests: content/tests_paywall.py; existing unlock-order tests get a subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 01:19:02 +03:30
parent 7d81d193bd
commit 64c4c09326
16 changed files with 214 additions and 28 deletions

View File

@@ -85,7 +85,7 @@ QDRANT_API_KEY=
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (پیش‌فرض ۳). سقفِ مشترکان روی
# هر پلن در ادمین است (ai_daily_quota). حسابِ هزینه: docs/ai-cost-and-guardrails.md
# AI_DAILY_QUOTA_FREE=3
# AI_DAILY_QUOTA_FREE=2
# ═══════════════════════════════════════════════════════════════════════
# ۵) پرداخت

View File

@@ -444,6 +444,8 @@ class AskAIViewThrottleTests(TestCase):
HTTP_IDEMPOTENCY_KEY=idempotency_key,
)
# سقفِ روزانه (پیش‌فرض ۲) نباید پیش از throttle برسد؛ این تست فقط throttle را می‌سنجد.
@override_settings(AI_DAILY_QUOTA_FREE=10)
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.

View File

@@ -877,8 +877,9 @@ OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
# docs/architecture.md. پیش‌فرض همین‌جاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد.
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
# ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '3'))
# ۲ از ۲۰۲۶-۱۰-۱۰ (تصمیمِ مالک): دستیار جدا از اشتراک فروخته می‌شود (بسته‌ی سوال)، پس سهمِ رایگان فقط
# برای آشنایی است. پیش از آن ۳، و تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
# سقفِ روزانه‌ی *کلِ سامانه* برای سوال‌هایی که به مدل می‌رسند (رایگان یا با اعتبار)، همان الگوی
# OTP_SMS_GLOBAL_DAILY_MAX. سقف‌های بالا برای هر کاربر و هر IP‌اند؛ کسی با چند IP و مهمان‌های
# تازه می‌توانست خرجِ GapGPT را بی‌سقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶

View File

@@ -51,6 +51,7 @@ class UnitWithNodesSerializer(serializers.Serializer):
id = serializers.CharField()
title = serializers.CharField()
blurb = serializers.CharField(allow_blank=True)
requiresSubscription = serializers.BooleanField(help_text='این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است).')
nodes = NodeStatusSerializer(many=True)

View File

@@ -5,6 +5,7 @@
(با correctCount=0)؛ نقشه هم وضعیتِ «باز» را از روی درس‌های تمام‌شده حساب می‌کرد، پس تمام‌کردنِ
درسِ جزیره‌ی قفل آن را باز می‌کرد. فقط قلبِ صفر جلوی بی‌جواب‌تمام‌کردن را می‌گرفت.
"""
from subscription.models import Subscription
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@@ -127,6 +128,9 @@ class FinishWithoutAnswersTests(_World, TestCase):
def test_the_normal_path_still_works_in_order(self):
"""جزیره‌ی اول، بعد دوم، بعد سوم؛ و اولین جزیره‌ی فصلِ دیگر از اول باز است."""
# فصلِ دوم اشتراک می‌خواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را می‌سنجد، نه پرداخت.
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
self.assertEqual(self.statuses(), {'یک': 'unlocked', 'دو': 'locked', 'سه': 'locked', 'الف': 'unlocked'})
for node in (self.n1, self.n2, self.n3, self.m1):
record_answers(self.user.id, node=node)

View File

@@ -273,8 +273,9 @@ class NodeQuizTests(TestCase):
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
"""
# پایه‌ی جدا تا فصلِ اولِ (رایگانِ) همان پایه باشد؛ پرداخت اینجا موضوع نیست.
thin_unit = Unit.objects.create(
grade=self.node.unit.grade, title='فصل ۵', order=2)
grade=Grade.objects.create(title='پایه دهم', icon='', color=''), title='فصل ۵', order=1)
thin = Node.objects.create(unit=thin_unit, title='کم‌سوال', order=1)
lesson = Lesson.objects.create(node=thin, title='تنها درس')
question = Question.objects.create(

View File

@@ -5,6 +5,7 @@
می‌کند، ولی جزیره فقط وقتی «تمام» حساب می‌شد که تک‌تکِ درس‌هایش (۸ تا ۱۳
درس) جدا تمام شده باشند؛ دانش‌آموز بعد از جزیره‌ی اولِ هر فصل گیر می‌کرد.
"""
from subscription.models import Subscription
from django.core.cache import cache
from django.test import TestCase
@@ -30,6 +31,8 @@ class NodeQuizCompleteTests(TestCase):
self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2)
self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id)
# فصلِ دوم اشتراک می‌خواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را می‌سنجد، نه پرداخت.
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def _node(self, unit, title, order, lessons):

70
content/tests_paywall.py Normal file
View File

@@ -0,0 +1,70 @@
"""
فصلِ اولِ هر پایه رایگان است و بقیه اشتراکِ زمانی می‌خواهند (۲۰۲۶-۱۰-۱۰).
بازی‌کردنِ فصلِ قفل از هر مسیری (سوال‌های درس، آزمونِ جزیره، جواب، پایانِ درس/جزیره) ۴۰۲ می‌گیرد.
"""
from django.core.cache import cache
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from subscription.models import Subscription
from .models import Choice, Grade, Lesson, Node, Question, Unit
class PaywallTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
self.free = self._node(Unit.objects.create(grade=grade, title='فصل ۱', order=1))
self.paid = self._node(Unit.objects.create(grade=grade, title='فصل ۲', order=2))
self.user = User.objects.create(phone='09120007901', grade_id=grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def _node(self, unit):
node = Node.objects.create(unit=unit, title=unit.title, order=1)
lesson = Lesson.objects.create(node=node, title='درس')
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
self.right = Choice.objects.create(question=q, value='۱', is_correct=True)
node.q, node.choice, node.lesson = q, self.right, lesson
return node
def _every_route(self, node):
return [
self.client.get(f'/api/lessons/{node.lesson.id}/questions').status_code,
self.client.get(f'/api/nodes/{node.id}/quiz').status_code,
self.client.post(f'/api/questions/{node.q.id}/answer', {'selectedOptionId': str(node.choice.id)},
format='json').status_code,
self.client.post(f'/api/nodes/{node.id}/quiz/complete', {'correctCount': 1, 'totalQuestions': 1},
format='json').status_code,
]
def test_the_second_unit_is_closed_without_a_subscription(self):
self.assertEqual(self._every_route(self.paid), [402, 402, 402, 402])
home = self.client.get('/api/home').json()
self.assertEqual([u['requiresSubscription'] for u in home['units']], [False, True])
def test_the_first_unit_is_free(self):
self.assertNotIn(402, self._every_route(self.free))
def test_an_active_subscription_opens_everything_and_an_expired_one_does_not(self):
sub = Subscription.objects.create(user_id=self.user.id, status='active',
expires_at=timezone.now() + timezone.timedelta(days=3))
self.assertNotIn(402, self._every_route(self.paid))
self.assertEqual([u['requiresSubscription'] for u in self.client.get('/api/home').json()['units']],
[False, False])
sub.expires_at = timezone.now() - timezone.timedelta(minutes=1)
sub.save()
self.assertEqual(self.client.get(f'/api/nodes/{self.paid.id}/quiz').status_code, 402)
def test_status_reports_plan_title_and_quota(self):
from subscription.models import Plan
plan = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
expires_at=timezone.now() + timezone.timedelta(days=10))
body = self.client.get('/api/subscription/status').json()
self.assertEqual((body['active'], body['planTitle'], body['planDurationDays'], body['aiDailyQuota']),
(True, 'سه‌ماهه', 90, 3))

View File

@@ -263,6 +263,36 @@ def node_is_unlocked(user_id, node) -> bool:
return False
def unit_is_free(unit_id: int, grade_id: int) -> bool:
"""
فصلِ رایگان: فقط اولین فصلِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه). بقیه‌ی فصل‌ها
اشتراکِ زمانی می‌خواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو،
برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت).
"""
tree = _grade_tree(grade_id) or []
return bool(tree) and tree[0]['id'] == str(unit_id)
def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool:
from subscription.services import has_active_subscription
if unit_is_free(unit_id, grade_id):
return True
return user_id is not None and has_active_subscription(user_id)
def subscription_required_response() -> Response:
# ۴۰۲ و نه ۴۰۳: فرانت از روی همین کد به صفحه‌ی اشتراک می‌رود، نه پیامِ «قفل است».
return Response(
{'detail': 'این فصل با اشتراک باز می‌شود. فصلِ اولِ هر پایه رایگان است.',
'code': 'subscription_required'},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
def _user_id_or_none(request):
return request.user.id if getattr(request.user, 'is_authenticated', False) else None
def _completion_body(title, stars, xp):
"""
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همان‌طور که
@@ -363,8 +393,10 @@ class HomeView(APIView):
).values_list('node_id', flat=True)
)
from subscription.services import has_active_subscription
subscribed = user_id is not None and has_active_subscription(user_id)
units_payload = []
for unit in tree:
for index, unit in enumerate(tree):
node_payload = []
unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه
for node in unit['nodes']:
@@ -395,6 +427,9 @@ class HomeView(APIView):
'id': unit['id'],
'title': unit['title'],
'blurb': unit.get('blurb', ''),
# فقط فصلِ اولِ هر پایه رایگان است (`unit_is_free`)؛ true یعنی همین کاربر برای بازی‌کردنش
# اشتراک لازم دارد. وضعیتِ جزیره‌ها عوض نمی‌شود تا پیشرفتِ کسی که اشتراکش تمام شد گم نشود.
'requiresSubscription': index > 0 and not subscribed,
'nodes': node_payload,
})
@@ -459,7 +494,9 @@ class LessonQuestionsView(APIView):
@extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)'])
def get(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id)
lesson = get_object_or_404(Lesson.objects.select_related('node__unit'), pk=lesson_id)
if not has_unit_access(_user_id_or_none(request), lesson.node.unit_id, lesson.node.unit.grade_id):
return subscription_required_response()
questions = lesson.questions.prefetch_related('choices').all()
data = {'questions': [
{
@@ -501,7 +538,10 @@ class QuestionAnswerView(APIView):
tags=['Curriculum (content)'],
)
def post(self, request, question_id):
question = get_object_or_404(Question, pk=question_id)
question = get_object_or_404(Question.objects.select_related('lesson__node__unit'), pk=question_id)
unit = question.lesson.node.unit
if not has_unit_access(request.user.id, unit.id, unit.grade_id):
return subscription_required_response()
serializer = AnswerRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
selected_option_id = serializer.validated_data['selectedOptionId']
@@ -677,6 +717,8 @@ class LessonCompleteView(APIView):
# بی‌امتیاز؛ فقط قفل بودنِ جزیره‌اش بررسی می‌شود.
# جزیره‌ی قفل را با تمام‌کردنِ درس‌هایش هم نمی‌شود باز کرد (وضعیتِ «باز» از روی
# درس‌های تمام‌شده هم حساب می‌شود، پس بدونِ این قفل از همین راه دور زده می‌شد).
if not has_unit_access(user_id, lesson.node.unit_id, lesson.node.unit.grade_id):
return subscription_required_response()
if not node_is_unlocked(user_id, lesson.node):
return node_locked_response()
@@ -885,6 +927,8 @@ class NodeQuizView(APIView):
node = get_object_or_404(
Node.objects.select_related('unit'), pk=node_id,
)
if not has_unit_access(_user_id_or_none(request), node.unit_id, node.unit.grade_id):
return subscription_required_response()
try:
size = int(request.query_params.get('size', self.DEFAULT_SIZE))
except (TypeError, ValueError):
@@ -1033,6 +1077,8 @@ class NodeQuizCompleteView(APIView):
return respond(existing, UserStats.objects.filter(user_id=user_id).first())
# قفل: جزیره‌ی قفل را نمی‌شود تمام کرد (تا ۲۰۲۶-۱۰-۰۷ می‌شد، با correctCount=0).
if not has_unit_access(user_id, node.unit_id, node.unit.grade_id):
return subscription_required_response()
if not node_is_unlocked(user_id, node):
return node_locked_response()

View File

@@ -51,8 +51,9 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa
پلن‌های اشتراکی پاک نشدند (`on_sale=False`) تا مشترکِ فعلی سقفش را نگه دارد.
- **ترتیبِ خرج:** اول `AI_DAILY_QUOTA_FREE` سوالِ رایگانِ همان روز (پیش‌فرض ۳)، بعد اعتبارِ بسته؛ مشترکِ
قدیمی سقفِ `Plan.ai_daily_quota`ِ خودش را دارد.
- **ترتیبِ خرج:** اول سقفِ روزانه (بی‌اشتراک `AI_DAILY_QUOTA_FREE`، پیش‌فرض ۲؛ مشترک `Plan.ai_daily_quota`،
برای یک/سه/شش‌ماهه ۳)، بعد اعتبارِ بسته. اشتراک محتوا و قلب می‌فروشد؛ سوالِ بیشتر از دستیار فقط با بسته.
بدترین حالتِ مشترکِ شش‌ماهه با سقفِ ۳: ~۶۳ هزار تومان در ماه، ۲۹٪ِ سهمِ ماهانه‌ی قیمت.
- **چه چیزی خرج می‌شود:** هر سوالی که به API برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل. ردِ صافیِ سرور،
جوابِ کش‌شده و جوابی که ساخته نشد چیزی کم نمی‌کنند. `Message.charge` روی هر پیامِ کاربر می‌گوید
سوال از کجا پرداخت شد (`free` / `credit` / `none`).

View File

@@ -27,7 +27,7 @@
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقف‌های مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر؛ بکاپِ پیش از migrate خودکار است ولی بیرون از ماشین نیست | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
@@ -106,8 +106,8 @@
## ۱۲. هزینه‌ی دستیارِ AI
- **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۳ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک
- **چیست:** هر کاربر روزی ۲ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۲ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک
(بند ۲۴) محدود است. سقفِ
روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵

View File

@@ -700,7 +700,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| انتخابِ مدلِ جواب: ۱۰۲ مسئله‌ی تصحیح‌شده‌ی دستی | `gpt-4.1-mini` : ۹۵–۹۶ درست · `gpt-4o-mini` : ۸۵–۹۰ |
| شکل‌ها: ۶۴ مسئله‌ی تصادفیِ کتاب، وارسیِ چشمیِ تک‌تک | ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بی‌شکل — حدودِ یکی از پنج هنوز ایراد دارد؛ برای همین شکل همیشه کنارِ حلِ متنی می‌آید |
| سرعت | جوابِ کامل با شکل ~۳۰ ثانیه؛ اندازه‌گیری شد که در خودِ GapGPT است («فقط بگو سلام» = ۷ ثانیه) |
| سقف‌ها | ۳ سؤالِ رایگان در روز، بعد از اعتبارِ بسته؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
| سقف‌ها | ۲ سؤالِ رایگان در روز (مشترک ۳)، بعد از اعتبارِ بسته؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
### ۹-۷ فقط ریاضی، و چه چیزی هزینه دارد
@@ -708,7 +708,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| قاعده‌ی خرج | |
|---|---|
| ترتیب | اول ۳ سوالِ رایگانِ همان روز، بعد اعتبارِ بسته ( `Message.charge` : `free` / `credit` / `none` ) |
| ترتیب | اول سقفِ روزانه (۲ رایگان، مشترک ۳)، بعد اعتبارِ بسته ( `Message.charge` : `free` / `credit` / `none` ) |
| خرج می‌شود | هر سوالی که به مدل برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل |
| خرج نمی‌شود | ردِ صافیِ سرور، جوابِ کش‌شده، جوابی که ساخته نشد، و تکرارِ درخواست با همان `Idempotency-Key` |
| هم‌زمانی | دو سوالِ هم‌زمان با یک اعتبار فقط یک جواب می‌گیرند؛ با دو thread واقعی آزموده می‌شود ( `ai_tutor/tests_credit_concurrency.py` ) |
@@ -881,7 +881,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| `ZARINPAL_MERCHANT_ID` · `BAZAAR_*` | پرداخت بسته می‌ماند (۵۰۳). |
| `CACHE_URL` | کشِ جدا برای هر worker؛ `/api/health` هشدار می‌دهد. |
| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش می‌کند) · هیچ حسابِ آزمایشی. |
| `AI_DAILY_QUOTA_FREE` | ۳ سوالِ رایگان در روز. |
| `AI_DAILY_QUOTA_FREE` | ۲ سوالِ رایگان در روز (پیش‌فرض). |
| `DJANGO_DEBUG` | خاموش — و **هرگز روی سرور روشن نشود** (کدِ ورودِ همگانیِ `0000` را فعال می‌کند). |
مقدارهای `.env` با پیش‌فرض‌های کد ادغام می‌شوند؛ یک `.env` ِ ناقص سرویس را نمی‌خواباند. شرحِ کامل در `.env.example` و `docs/operations.md`.

View File

@@ -0,0 +1,44 @@
"""
اشتراکِ زمانی برمی‌گردد (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰): فصلِ اولِ هر پایه رایگان است و
بقیه‌ی فصل‌ها با اشتراک باز می‌شوند (content.views.unit_is_free). بسته‌های سوال کنارِ اشتراک می‌مانند.
قیمت‌ها از سینیور: یک‌ماهه ۴۹۸، سه‌ماهه ۷۹۸، شش‌ماهه ۱٬۲۹۸ هزار تومان.
اشتراک «محتوا و قلب» می‌فروشد، نه دستیار (تصمیمِ مالک، ۲۰۲۶-۱۰-۱۰): سقفِ دستیار برای مشترک ۳ سوال در
روز است و بیشتر از آن با بسته‌ی سوال خریده می‌شود. حساب: هر سوال دستِ بالا ~۷۰۰ تومان
(docs/ai-cost-and-guardrails.md). با سقفِ ۸، مشترکِ شش‌ماهه‌ای که هر روز سقف را پر کند ~۱۶۸ هزار تومان در
ماه هزینه داشت، یعنی ۷۸٪ِ سهمِ ماهانه‌ی قیمت (۲۱۶ هزار)، و با ۱۵٪ِ کافه‌بازار ~۷٪ می‌ماند. با سقفِ ۳ همین
بدترین حالت ~۶۳ هزار تومان است: ۲۹٪ِ شش‌ماهه، ۲۴٪ِ سه‌ماهه، ۱۳٪ِ یک‌ماهه. این بدترین حالت است، نه
میانگین؛ مصرفِ واقعی سنجیده نشده. سقف از ادمین عوض می‌شود (Plan.ai_daily_quota).
پلن‌های اشتراکیِ قدیمی (ماهانه/سالانه) دست نمی‌خورند و on_sale=False می‌مانند.
"""
from django.db import migrations
FEATURES = ['همه‌ی فصل‌ها و جزیره‌ها باز', 'قلبِ نامحدود', '۳ سوال در روز از دستیارِ هوشمند']
PLANS = [
{'title': 'یک‌ماهه', 'price': 498_000, 'duration_days': 30, 'badge': '', 'popular': False},
{'title': 'سه‌ماهه', 'price': 798_000, 'duration_days': 90, 'badge': 'محبوب‌ترین', 'popular': True},
{'title': 'شش‌ماهه', 'price': 1_298_000, 'duration_days': 180, 'badge': 'به‌صرفه‌ترین', 'popular': False},
]
def seed(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
# بسته‌ها هم «محبوب‌ترین» داشتند؛ دو کارتِ «محبوب‌ترین» در یک صفحه معنا ندارد.
Plan.objects.filter(kind='pack').update(popular=False)
for plan in PLANS:
if not Plan.objects.filter(kind='subscription', on_sale=True, duration_days=plan['duration_days']).exists():
Plan.objects.create(kind='subscription', currency='IRT', discount_percent=0, ai_daily_quota=3,
features=FEATURES, on_sale=True, question_credits=0, hearts_days=0, **plan)
def undo(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
Plan.objects.filter(kind='subscription', on_sale=True, subscriptions__isnull=True,
title__in=[p['title'] for p in PLANS]).delete()
class Migration(migrations.Migration):
dependencies = [('subscription', '0012_plan_bazaar_sku')]
operations = [migrations.RunPython(seed, undo)]

View File

@@ -43,7 +43,10 @@ class PurchaseResponseSerializer(serializers.Serializer):
class SubscriptionStatusResponseSerializer(serializers.Serializer):
active = serializers.BooleanField(help_text='اشتراکِ زمانی (ماهانه/سالانه‌ی قدیمی)؛ نه بسته.')
active = serializers.BooleanField(help_text='اشتراکِ زمانی فعال (یک/سه/شش‌ماهه)؛ نه بسته.')
planTitle = serializers.CharField(allow_null=True, help_text='نامِ پلنِ اشتراکِ فعال؛ null بی‌اشتراک.')
planDurationDays = serializers.IntegerField(allow_null=True, help_text='طولِ دوره‌ی پلنِ فعال به روز.')
aiDailyQuota = serializers.IntegerField(help_text='سقفِ سوالِ روزانه‌ی همین کاربر از دستیار.')
planId = serializers.CharField(allow_null=True)
expiresAt = serializers.DateTimeField(allow_null=True)
questionCredits = serializers.IntegerField(help_text='سوال‌های خریده‌شده‌ی باقی‌مانده (منقضی نمی‌شوند).')

View File

@@ -486,7 +486,7 @@ class YearlyPlanSeedTests(TestCase):
self.assertTrue(plan.features)
def test_a_monthly_plan_is_created_next_to_it(self):
plan = Plan.objects.get(duration_days=30)
plan = Plan.objects.get(duration_days=30, on_sale=False)
self.assertEqual((plan.price, plan.ai_daily_quota), (179_000, 10))
def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self):
@@ -498,16 +498,20 @@ class YearlyPlanSeedTests(TestCase):
importlib.import_module('subscription.migrations.0008_paid_quota_ten_a_day').apply(global_apps, None)
plan = Plan.objects.get(title='اشتراک یک‌ساله')
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
self.assertEqual(Plan.objects.filter(duration_days=30).count(), 1)
self.assertEqual(Plan.objects.filter(duration_days=30, on_sale=False).count(), 1)
def test_only_question_packs_are_on_sale(self):
def test_packs_and_new_subscriptions_are_on_sale(self):
from django.core.cache import cache
cache.clear()
data = self.client.get('/api/subscription/plans').json()
self.assertEqual({p['kind'] for p in data['plans']}, {'pack'})
self.assertEqual([p['questionCredits'] for p in data['plans']], [20, 60, 150])
self.assertEqual([p['heartsDays'] for p in data['plans']], [7, 30, 60])
self.assertEqual(data['freeAiDailyQuota'], 3)
packs = [p for p in data['plans'] if p['kind'] == 'pack']
subs = [p for p in data['plans'] if p['kind'] == 'subscription']
self.assertEqual([p['questionCredits'] for p in packs], [20, 60, 150])
self.assertEqual([p['heartsDays'] for p in packs], [7, 30, 60])
# subscription.0013: یک/سه/شش‌ماهه با سقفِ ۳ سوال در روز.
self.assertEqual([(p['durationDays'], p['price'], p['aiDailyQuota']) for p in subs],
[(30, 498_000, 3), (90, 798_000, 3), (180, 1_298_000, 3)])
self.assertEqual(data['freeAiDailyQuota'], 2)
# پلن‌های اشتراکی پاک نشده‌اند؛ فقط فروخته نمی‌شوند.
self.assertFalse(Plan.objects.get(title='اشتراک یک‌ساله').on_sale)

View File

@@ -21,7 +21,7 @@ from .serializers import (
PurchaseResponseSerializer,
SubscriptionStatusResponseSerializer,
)
from .services import credit_balance, has_active_subscription, hearts_pass_until
from .services import ai_daily_quota, credit_balance, has_active_subscription, hearts_pass_until
# پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند.
@@ -104,8 +104,8 @@ class PurchaseView(APIView):
idempotency_key = request.headers.get('Idempotency-Key')
if v['paymentMethod'] == 'bazaar':
# فقط بسته: اشتراک‌ها دیگر فروخته نمی‌شوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
# اشتراک فعلاً فقط از زرین‌پال فروخته می‌شود. SKU از پلن، نه از کلاینت.
if plan.kind != 'pack':
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'})
if not plan.bazaar_sku:
@@ -229,11 +229,17 @@ class SubscriptionStatusView(APIView):
def get(self, request):
# پرداختی که تأییدش در کال‌بک گم شده، اینجا دوباره سنجیده می‌شود.
reconcile_user_orders(request.user.id)
subscription = Subscription.objects.filter(user_id=request.user.id).first()
subscription = Subscription.objects.select_related('plan').filter(user_id=request.user.id).first()
active = has_active_subscription(request.user.id)
data = {
# وضعیتِ «active» با گذشتِ تاریخ خودبه‌خود عوض نمی‌شود؛ همان شرطی که
# قلبِ نامحدود را می‌دهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده می‌شد.
'active': has_active_subscription(request.user.id),
'active': active,
'planTitle': subscription.plan.title if active and subscription.plan else None,
# طولِ دوره‌ی پلن، برای نوارِ «چقدر مانده» در پروفایل. تمدیدِ زودتر از موعد روزها را روی هم
# می‌گذارد، پس روزِ مانده می‌تواند از این بیشتر باشد؛ فرانت نوار را در ۱۰۰٪ می‌بندد.
'planDurationDays': subscription.plan.duration_days if active and subscription.plan else None,
'aiDailyQuota': ai_daily_quota(request.user.id),
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
'expiresAt': subscription.expires_at if subscription else None,
'questionCredits': credit_balance(request.user.id),