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>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -21,12 +21,12 @@ from .serializers import (
PurchaseResponseSerializer,
SubscriptionStatusResponseSerializer,
)
from .services import ai_daily_quota, credit_balance, has_active_subscription, hearts_pass_until
from .services import ai_daily_quota, has_active_subscription
# پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند.
# با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py).
PLANS_CACHE_KEY = 'subscription:plans:v3' # v3: بسته‌های سوال
PLANS_CACHE_KEY = 'subscription:plans:v4' # v4: بی بسته‌ی سوال (subscription.0017)
PLANS_CACHE_TTL = 3600
@@ -53,12 +53,9 @@ 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,
'bazaarSku': p.bazaar_sku,
}
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
for p in Plan.objects.filter(on_sale=True).order_by('price')
],
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
}
@@ -99,7 +96,7 @@ class PurchaseView(APIView):
v = serializer.validated_data
user = request.user
# پلنی که دیگر فروخته نمی‌شود (اشتراک‌های ماهانه/سالانه پس از آمدنِ بسته‌ها) خریدنی نیست.
# پلنی که دیگر فروخته نمی‌شود (on_sale=False، مثلِ اشتراک‌های قدیمیِ ماهانه/سالانه) خریدنی نیست.
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
@@ -112,16 +109,6 @@ class PurchaseView(APIView):
data = {'orderId': str(order.id), 'paymentUrl': payment_url, **provider.purchase_extras(order, plan)}
return Response(PurchaseResponseSerializer(data).data)
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,
provider=provider.key, bazaar_product_sku=provider.sku_for(plan),
)
return response(order, payment_url)
# 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
@@ -238,7 +225,5 @@ class SubscriptionStatusView(APIView):
'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),
'unlimitedHeartsUntil': hearts_pass_until(request.user.id),
}
return Response(SubscriptionStatusResponseSerializer(data).data)