question packs instead of monthly/yearly plans; hearts are real

- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:41:39 +03:30
parent 2edc56fc08
commit 58c8e6d014
18 changed files with 572 additions and 55 deletions

View File

@@ -20,7 +20,7 @@ from .serializers import (
PurchaseResponseSerializer,
SubscriptionStatusResponseSerializer,
)
from .services import has_unlimited_hearts
from .services import credit_balance, has_active_subscription, hearts_pass_until
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
@@ -43,7 +43,7 @@ class GuestAwarePurchaseThrottle(ScopedRateThrottle):
# پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند.
# با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py).
PLANS_CACHE_KEY = 'subscription:plans:v2' # v2: aiDailyQuota و freeAiDailyQuota
PLANS_CACHE_KEY = 'subscription:plans:v3' # v3: بسته‌های سوال
PLANS_CACHE_TTL = 3600
@@ -66,8 +66,11 @@ class PlansListView(APIView):
'features': p.features or [],
'badge': p.badge,
'aiDailyQuota': p.ai_daily_quota,
'kind': p.kind,
'questionCredits': p.question_credits,
'heartsDays': p.hearts_days,
}
for p in Plan.objects.order_by('duration_days', 'price')
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
],
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
}
@@ -112,9 +115,19 @@ class PurchaseView(APIView):
v = serializer.validated_data
user = request.user
plan = get_object_or_404(Plan, pk=v['planId'])
# پلنی که دیگر فروخته نمی‌شود (اشتراک‌های ماهانه/سالانه پس از آمدنِ بسته‌ها) خریدنی نیست.
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
if plan.kind == 'pack':
# بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی
# خودِ سفارش داده می‌شوند (payment.services → subscription.services.grant_pack).
order, payment_url = create_order(
user_id=user.id, amount=plan.price, currency=plan.currency,
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
)
return Response(PurchaseResponseSerializer({'orderId': str(order.id), 'paymentUrl': payment_url}).data)
# Three-phase flow so create_order never runs inside a DB transaction/lock.
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
@@ -137,7 +150,7 @@ class PurchaseView(APIView):
# همین‌جا «pending» می‌شد و کاربرِ پولی اگر خرید را تمام نمی‌کرد
# مزیتش را از دست می‌داد. پلن و تمدید از خودِ سفارش می‌آیند
# (activate_subscription)، پس لازم نیست Subscription الان عوض شود.
currently_active = prior_subscription is not None and has_unlimited_hearts(user.id)
currently_active = prior_subscription is not None and has_active_subscription(user.id)
if currently_active:
subscription = prior_subscription
@@ -217,8 +230,10 @@ class SubscriptionStatusView(APIView):
data = {
# وضعیتِ «active» با گذشتِ تاریخ خودبه‌خود عوض نمی‌شود؛ همان شرطی که
# قلبِ نامحدود را می‌دهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده می‌شد.
'active': has_unlimited_hearts(request.user.id),
'active': has_active_subscription(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),
'unlimitedHeartsUntil': hearts_pass_until(request.user.id),
}
return Response(SubscriptionStatusResponseSerializer(data).data)