Files
MathApp/discount/views.py
Zareian c0fa46be44 Payment providers as classes (Strategy + factory); 10342 = monthly; health logs a failed Bazaar check
- payment/providers.py: ZarinpalProvider / BazaarProvider with one interface (check_plan, sku_for, payment_url,
  purchase_extras, discount_blocker, reconcile_user); provider_for / provider_for_method pick one; unknown fails.
  create_order, purchase view, discount redeem and reconcile_user_orders use it instead of scattered ifs.
- subscription.0015: Bazaar SKU 10342 on the one-month plan (owner: "probably the monthly one").
- config/health: the Bazaar check no longer swallows its exception without a log.
982 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:21:45 +03:30

221 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import DiscountCode, Redemption
from .serializers import (
RedeemOfferCodeRequestSerializer,
RedeemRequestSerializer,
RedeemResponseSerializer,
UserOfferResponseSerializer,
ValidateResponseSerializer,
)
from .services import redeem_offer_code
def _is_usable(discount: DiscountCode) -> bool:
# ⚠️ هر Redemption شمرده می‌شود، حتی روی سفارشی که هرگز پرداخت نشد؛ پس
# سبدِ رهاشده سهمِ max_uses را برای همیشه می‌سوزاند و خودِ همان کاربر هم
# روی سفارشِ تازه‌اش دیگر نمی‌تواند کد بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
return bool(
discount.redemptions.count() < discount.max_uses
and (discount.expires_at is None or discount.expires_at >= timezone.now())
)
class ValidateDiscountView(APIView):
"""GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید"""
# این ویو مثل RedeemDiscountView به IsAuthenticated نیاز دارد؛ در غیر این
# صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند.
permission_classes = [IsAuthenticated]
# این endpoint type/value کد رو برمی‌گردونه، پس بدونِ throttle یک
# کاربر می‌تونست بی‌نهایت کد رو probe کنه؛ نرخ کمی بالاتر از
# offer_redeem چون این یک چکِ read-only پیش از خریده.
throttle_scope = 'discount_validate'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
responses=ValidateResponseSerializer,
tags=['Discount (discount)'],
)
def get(self, request):
code = request.query_params.get('code', '')
discount = DiscountCode.objects.filter(code=code).first()
if not discount or not _is_usable(discount):
return Response(ValidateResponseSerializer({'valid': False}).data)
data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value}
return Response(ValidateResponseSerializer(data).data)
class RedeemDiscountView(APIView):
"""
POST /api/discount/redeem — ثبت مصرف کد هنگام خرید
شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی redeem زیر
select_for_update چک می‌شود تا از over-redemption هم‌زمان جلوگیری شود.
هر کاربر برای یک کد حداکثر یک‌بار می‌تواند redeem کند (قید یکتایی مدل).
discountAmount با یک خواندنِ مستقیم از payment.models.Order پر می‌شود
(بدون FK واقعی، مطابق قرارداد پروژه برای ارجاع بین اپ‌ها).
"""
permission_classes = [IsAuthenticated]
# همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
# درست یه کدِ واقعی رو مصرف می‌کنه (سهمِ max_uses مشترک) و مبلغِ سفارش
# رو واقعاً کم می‌کنه، ضمن اینکه هر تلاش دو select_for_update و یه
# تراکنشه.
throttle_scope = 'discount_redeem'
throttle_classes = [ScopedRateThrottle]
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
tags=['Discount (discount)'])
def post(self, request):
serializer = RedeemRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
order_id = int(v['orderId'])
from payment.models import Order
# سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
try:
with transaction.atomic():
discount = get_object_or_404(
DiscountCode.objects.select_for_update(), code=v['code']
)
if not _is_usable(discount):
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
if discount.redemptions.filter(user_id=request.user.id).exists():
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'})
# سفارش زیر قفل خوانده می‌شود و چک pending بودن *بعد* از قفل روی همان
# ردیف انجام می‌شود، تا با یک webhook هم‌زمان که سفارش را paid کرده race نکند.
order_locked = Order.objects.select_for_update().get(pk=order.id)
if order_locked.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
# هر روشِ پرداخت خودش می‌داند کی دیگر تخفیف نمی‌نشیند (زرین‌پال: بعد از رفتن به درگاه؛ بازار:
# بی‌کلیدِ «تخفیف پویا» یا بعد از امضای قیمت). payment/providers.py.
from payment.providers import provider_for
blocker = provider_for(order_locked.provider).discount_blocker(order_locked)
if blocker:
raise ValidationError({'code': blocker})
# یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند.
if Redemption.objects.filter(order_id=order_locked.id).exists():
raise ValidationError({'code': 'روی این سفارش قبلاً کدِ تخفیف اعمال شده است.'})
if discount.type == 'percent':
discount_amount = order_locked.amount * discount.value // 100
else:
discount_amount = min(discount.value, order_locked.amount)
Redemption.objects.create(
discount_code=discount,
user_id=request.user.id,
order_id=order_id,
)
# مبلغ سفارش واقعاً کم می‌شود تا مرحله‌ی پرداخت مبلغ تخفیف‌خورده را ببیند.
order_locked.amount = max(order_locked.amount - discount_amount, 0)
order_locked.save(update_fields=['amount'])
# سفارشِ صفرتومانی را هیچ درگاهی نمی‌پذیرد؛ کدِ ۱۰۰٪ یعنی خرید
# همین‌جا تمام است (همان مسیرِ پرداختِ واقعی: اشتراک، اعلان، پورسانت).
if order_locked.amount == 0:
from payment.services import apply_order_payment_result
apply_order_payment_result(
order=order_locked, gateway='discount',
gateway_ref_id=f'discount-{discount.id}-order-{order_locked.id}',
new_status='paid', event='discount_full',
)
except IntegrityError:
# fallback برای رقابت هم‌زمان روی قید یکتایی، حتی اگر قفل select_for_update رد شود.
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
order.refresh_from_db(fields=['status', 'amount'])
data = {'success': True, 'discountAmount': discount_amount,
'orderStatus': order.status, 'amount': order.amount}
return Response(RedeemResponseSerializer(data).data)
class RedeemOfferCodeView(APIView):
"""
POST /api/discount/offers/redeem — اعمال آفر (اشتراک رایگان/محدود) با کد،
برخلاف auto_assign_on_signup که خودکارِ هنگام ثبت‌نام‌ه، این برای زمانی‌ه
که سینیور بخواد یک آفر رو بعداً (نه فقط هنگام ثبت‌نام) با یک کد به کاربر
بده — مثلاً یک کمپین تبلیغاتی.
"""
permission_classes = [IsAuthenticated]
# بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساس‌تره چون
# Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
# کاربر رو می‌گیره.
throttle_scope = 'offer_redeem'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
tags=['Discount (discount)'],
)
def post(self, request):
serializer = RedeemOfferCodeRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user_offer = redeem_offer_code(user_id=request.user.id, code=serializer.validated_data['code'])
data = {
'id': user_offer.id,
'offerName': user_offer.offer.name,
'kind': user_offer.offer.kind,
'status': user_offer.status,
'grantedAt': user_offer.granted_at,
'expiresAt': user_offer.expires_at,
}
return Response(UserOfferResponseSerializer(data).data)
class MyOffersView(APIView):
"""GET /api/discount/offers/mine — لیست آفرهایی که کاربر گرفته (خودکار یا با کد)"""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(responses=UserOfferResponseSerializer(many=True), tags=['Discount (discount)'])
def get(self, request):
from .models import UserOffer
user_offers = UserOffer.objects.filter(user_id=request.user.id).select_related('offer')
data = [
{
'id': uo.id,
'offerName': uo.offer.name,
'kind': uo.offer.kind,
'status': uo.status,
'grantedAt': uo.granted_at,
'expiresAt': uo.expires_at,
}
for uo in user_offers
]
return Response(UserOfferResponseSerializer(data, many=True).data)