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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user