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

@@ -23,8 +23,6 @@ from .serializers import (
BazaarStartResponseSerializer,
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
ZarinpalCallbackResponseSerializer,
)
@@ -49,73 +47,6 @@ def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
return redirect_cls(url)
class CreateOrderView(APIView):
"""
POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription)
طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از
کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو
import و صدا می‌زنه، پس این endpoint برای جریان عادی از بیرون صدا زده
نمی‌شه؛ برای همین به‌جای AllowAny یک shared-secret داخلی
(HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی
می‌تونست با یک userId دلخواه سفارش بسازه.
"""
permission_classes = [HasInternalServiceKey]
# ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً
# مستند نشده بودن و از Swagger UI اصلاً قابل ست‌کردن/تست‌کردن نبودن.
@extend_schema(
request=CreateOrderRequestSerializer,
responses=CreateOrderResponseSerializer,
parameters=[
OpenApiParameter(
'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).',
),
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.'
),
),
],
tags=['Payment (payment)'],
)
def post(self, request):
serializer = CreateOrderRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
if v.get('provider') == 'bazaar' and not v.get('bazaarProductSku'):
raise ValidationError({'bazaarProductSku': 'سفارشِ بازار بدونِ SKU ساخته نمی‌شود.'})
plan = None
if v.get('planId'):
from subscription.models import Plan
plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
order, payment_url = create_order(
user_id=int(v['userId']),
amount=v['amount'],
currency=v.get('currency') or 'IRT',
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
idempotency_key=request.headers.get('Idempotency-Key'),
provider=v.get('provider') or 'gateway',
bazaar_product_sku=v.get('bazaarProductSku') or '',
plan_id=plan.id if plan else None,
plan_title=plan.title if plan else '',
)
data = {
'orderId': str(order.id), 'status': order.status,
'paymentUrl': payment_url, 'provider': order.provider,
}
return Response(CreateOrderResponseSerializer(data).data)
class PaymentRedirectView(APIView):
"""
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
@@ -340,12 +271,12 @@ class BazaarVerifyPurchaseView(APIView):
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
# نوعِ محصول از سفارش: سفارشِ اشتراک subscription_id دارد (PurchaseView)، بسته ندارد. پیش‌تر نوع
# حدس زده می‌شد (اول تک‌خرید، بعد اشتراک).
# فقط اشتراک از بازار فروخته می‌شود (subscription.0017)؛ سفارشِ بی‌اشتراک چیزی برای فعال کردن ندارد.
if not order_lookup.subscription_id:
raise ValidationError({'orderId': 'این سفارش اشتراکی ندارد.'})
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
is_subscription=bool(order_lookup.subscription_id),
)
with transaction.atomic():
@@ -438,7 +369,6 @@ class BazaarStartPurchaseView(APIView):
data = {
'orderId': str(order.id),
'sku': order.bazaar_product_sku,
'productType': 'subscription' if order.subscription_id else 'inapp',
'amount': order.amount,
'dynamicPriceToken': token,
}