- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
535 lines
29 KiB
Python
535 lines
29 KiB
Python
import logging
|
||
from urllib.parse import urlsplit
|
||
|
||
from django.conf import settings
|
||
from django.db import transaction
|
||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||
from django.utils.html import escape
|
||
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.types import OpenApiTypes
|
||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||
from rest_framework import status
|
||
from rest_framework.exceptions import ValidationError
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from . import bazaar, zarinpal
|
||
from .models import Order
|
||
from .permissions import HasInternalServiceKey
|
||
from .serializers import (
|
||
InvoiceListResponseSerializer,
|
||
BazaarStartResponseSerializer,
|
||
BazaarVerifyRequestSerializer,
|
||
BazaarVerifyResponseSerializer,
|
||
CreateOrderRequestSerializer,
|
||
CreateOrderResponseSerializer,
|
||
OrderDetailResponseSerializer,
|
||
ZarinpalCallbackResponseSerializer,
|
||
)
|
||
from .services import apply_order_payment_result, create_order, pay_link_is_valid, reconcile_pending_order
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
|
||
"""
|
||
HttpResponseRedirect معمولی فقط اسکیمِ http/https/ftp رو قبول میکنه؛
|
||
ZARINPAL_REDIRECT_URL ممکنه یک deep link با اسکیمِ سفارشیِ اپ باشه
|
||
(مثلاً tadrisino://...). چون این URL از settings میاد (نه از ورودیِ
|
||
کاربر)، اضافهکردنِ اسکیمش به allowed_schemes امن است.
|
||
"""
|
||
scheme = urlsplit(url).scheme
|
||
redirect_cls = HttpResponseRedirect
|
||
if scheme and scheme not in redirect_cls.allowed_schemes:
|
||
redirect_cls = type('ConfiguredSchemeRedirect', (HttpResponseRedirect,), {
|
||
'allowed_schemes': [*HttpResponseRedirect.allowed_schemes, scheme],
|
||
})
|
||
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ی که
|
||
CreateOrderResponseSerializer برای provider='gateway' برمیگرداند؛
|
||
کلاینت (مرورگر/وبویو) این را باز میکند.
|
||
|
||
فراخوانیِ واقعیِ PaymentRequest به زرینپال عمداً همینجاست، نه در
|
||
create_order: create_order از subscription صدا زده میشود و نباید
|
||
موفقیتِ ساختِ سفارش به دسترسبودنِ زرینپال گره بخورد. اولین بار که این
|
||
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
||
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
||
میکند، نه یک PaymentRequest تازه.
|
||
|
||
AllowAny است چون مرورگر هدرِ Bearer نمیفرستد؛ بهجایش پارامترِ `t`، امضای
|
||
کوتاهعمری که فقط در paymentUrlِ صاحبِ سفارش میآید (payment/services.py،
|
||
pay_link_token)، سنجیده میشود. بیامضا یا امضای سفارشِ دیگر همان ۴۰۴ِ سفارشِ
|
||
ناموجود را میگیرد تا وجودِ شناسه هم لو نرود.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
throttle_scope = 'payment_pay'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
def dispatch(self, request, *args, **kwargs):
|
||
response = super().dispatch(request, *args, **kwargs)
|
||
# زرینپال در StartPay از روی Referer میسنجد کاربر از دامنهی ثبتشده
|
||
# آمده یا نه. پیشفرضِ جنگو `same-origin` است و روی همین ریدایرکتِ
|
||
# بیندامنهای Referer را حذف میکرد؛ زرینپال میگفت «دسترسی از این
|
||
# دامنه مجاز نمیباشد» (۲۰۲۶-۱۰-۰۱، پرداختِ آزمایشیِ صاحبِ پروژه).
|
||
# با این مقدار فقط origin صفحهی فرانت (math.tadrisino2.ir) میرود، نه
|
||
# مسیر و کوئری. SecurityMiddleware با setdefault این را دست نمیزند.
|
||
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||
return response
|
||
|
||
@extend_schema(
|
||
parameters=[OpenApiParameter(
|
||
't', OpenApiTypes.STR, required=True,
|
||
description='امضای همراهِ paymentUrl؛ کلاینت خودش نمیسازد، همان آدرسِ کامل را باز میکند.',
|
||
)],
|
||
responses={302: OpenApiTypes.NONE},
|
||
description='به درگاهِ زرینپال ریدایرکت میکند؛ بدنهی JSON ندارد.',
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def get(self, request, order_id):
|
||
if not pay_link_is_valid(order_id, request.query_params.get('t', '')):
|
||
raise Http404
|
||
order = get_object_or_404(Order, pk=order_id, provider='gateway')
|
||
|
||
# authority فقط وقتی ذخیره میشود که هنوز خالی است (درخواستِ همزمانِ دیگری زودتر نساخته باشد)
|
||
# و مبلغِ سفارش همانی است که به زرینپال گفته شد. کدِ تخفیفی که وسطِ PaymentRequest ثبت شود
|
||
# مبلغ را کم میکرد و authority با مبلغِ قبلی مینشست: کاربر مبلغِ کامل را میپرداخت و verify با
|
||
# مبلغِ تازه رد میشد (بازتولید شد). در آن حالت یک بار دیگر با مبلغِ تازه درخواست میشود.
|
||
for _attempt in range(2):
|
||
if order.status != 'pending' or order.gateway_authority:
|
||
break
|
||
requested_amount = order.amount
|
||
result = zarinpal.request_payment(order)
|
||
Order.objects.filter(pk=order.pk, gateway_authority='', amount=requested_amount).update(
|
||
gateway_authority=result.authority,
|
||
)
|
||
order.refresh_from_db(fields=['gateway_authority', 'amount', 'status'])
|
||
|
||
if order.status != 'pending':
|
||
# سفارش از قبل نتیجه گرفته (paid/failed، یا کدِ ۱۰۰٪ همین الان)؛ چیزی برای پرداختِ تازه نیست.
|
||
# کاربر به اپ برمیگردد با همان نتیجه، نه به ریشهی دامنهی API.
|
||
if settings.ZARINPAL_REDIRECT_URL:
|
||
return _redirect_to_configured_url(
|
||
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
|
||
)
|
||
return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/')
|
||
if not order.gateway_authority:
|
||
logger.error('pay link: amount of order %s kept changing during PaymentRequest', order.id)
|
||
return Response({'detail': 'مبلغِ سفارش در حینِ رفتن به درگاه عوض شد؛ دوباره «پرداخت» را بزن.'},
|
||
status=status.HTTP_409_CONFLICT)
|
||
|
||
return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}')
|
||
|
||
|
||
class ZarinpalCallbackView(APIView):
|
||
"""
|
||
GET /api/payment/zarinpal/callback — کالبکی که زرینپال بعدِ پرداخت،
|
||
مرورگرِ کاربر رو بهش ریدایرکت میکنه (Authority, Status در query string).
|
||
|
||
caller خودِ مرورگرِ کاربره، نه زرینپال مستقیماً — پس هیچ
|
||
هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک
|
||
فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK
|
||
رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرینپال چیزی paid نمیشه.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
throttle_scope = 'payment_webhook'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('Authority', OpenApiTypes.STR, required=True),
|
||
OpenApiParameter('Status', OpenApiTypes.STR, required=True),
|
||
],
|
||
responses=ZarinpalCallbackResponseSerializer,
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def get(self, request):
|
||
authority = request.query_params.get('Authority', '')
|
||
status_param = request.query_params.get('Status', '')
|
||
# بیAuthority، جستجو هر سفارشی را میگرفت که صفحهی پرداختش هنوز باز نشده (authority خالی):
|
||
# یک NOKِ ناشناس آن را «ناموفق» میکرد و با دو سفارش ۵۰۰ میداد.
|
||
if not authority:
|
||
raise ValidationError({'Authority': 'لازم است.'})
|
||
order = Order.objects.filter(gateway_authority=authority, provider='gateway').first()
|
||
if order is None:
|
||
# کالبکِ بیسفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد.
|
||
logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10])
|
||
raise Http404
|
||
if status_param != 'OK':
|
||
new_status, gateway_ref_id = 'failed', ''
|
||
else:
|
||
result = zarinpal.verify_payment(
|
||
authority=authority, amount=order.amount, currency=order.currency,
|
||
)
|
||
new_status = 'paid' if result.is_valid else 'failed'
|
||
gateway_ref_id = result.ref_id
|
||
|
||
with transaction.atomic():
|
||
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
||
apply_order_payment_result(
|
||
order=locked_order, gateway='zarinpal', gateway_ref_id=gateway_ref_id,
|
||
new_status=new_status, event='zarinpal_callback',
|
||
)
|
||
order.refresh_from_db(fields=['status'])
|
||
|
||
if settings.ZARINPAL_REDIRECT_URL:
|
||
return _redirect_to_configured_url(
|
||
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
|
||
)
|
||
data = {'orderId': str(order.id), 'status': order.status}
|
||
return Response(ZarinpalCallbackResponseSerializer(data).data)
|
||
|
||
|
||
def _bazaar_oauth_page(message: str, *, status: int) -> HttpResponse:
|
||
# این را صاحبِ حسابِ بازار در مرورگر میبیند، نه اپ؛ پس HTML ساده.
|
||
return HttpResponse(
|
||
'<!doctype html><meta charset="utf-8"><title>اتصال به بازار</title>'
|
||
f'<p dir="rtl" style="font:16px sans-serif;margin:2em">{escape(message)}</p>',
|
||
status=status,
|
||
)
|
||
|
||
|
||
class BazaarVerifyPurchaseView(APIView):
|
||
"""
|
||
POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار
|
||
GET /api/payment/bazaar/verify?code=… — Redirect URIِ OAuthِ بازار
|
||
|
||
همین آدرس در پنلِ بازار Redirect URI ثبت شده (خواستِ مدیرِ حساب،
|
||
۲۰۲۶-۰۹-۲۶)، پس دو کار دارد که با متد از هم جدا میشوند: GET را مرورگرِ
|
||
صاحبِ حساب بعد از «اجازه» میزند و بیاحراز است (فقط داخلِ پنجرهای که
|
||
start_authorization باز کرده اثر دارد)؛ POST را اپِ اندروید بعد از خرید.
|
||
|
||
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید میشه، در پرداخت
|
||
درونبرنامهای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
|
||
SDK بازار (IInAppBillingService)، یک purchaseToken میگیره و باید همون
|
||
رو برای این endpoint بفرسته تا بکاند با فراخوانی سرور-به-سرور به API
|
||
رسمی بازار (bazaar.verify_purchase)، صحتش رو تایید کنه — مشابه دقیقاً
|
||
همون چیزی که سند رسمی بازار توصیه میکنه: «هیچوقت فقط به گفتهی کلاینت
|
||
اعتماد نکنید، حتماً سمت سرور verify کنید».
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase)
|
||
# تریگر میکنه، برای همین throttle_scope مجزا و نرخِ سختگیرانهتر (۱۰/hour)
|
||
# داره.
|
||
throttle_scope = 'bazaar_verify'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
def get_permissions(self):
|
||
if self.request.method == 'GET':
|
||
return [AllowAny()]
|
||
return super().get_permissions()
|
||
|
||
def get_throttles(self):
|
||
if self.request.method == 'GET':
|
||
self.throttle_scope = 'bazaar_oauth'
|
||
return super().get_throttles()
|
||
|
||
@extend_schema(exclude=True)
|
||
def get(self, request):
|
||
if request.query_params.get('error'):
|
||
return _bazaar_oauth_page(
|
||
f'بازار اجازه نداد: {request.query_params.get("error")}', status=400,
|
||
)
|
||
code = request.query_params.get('code', '')
|
||
if not code:
|
||
return _bazaar_oauth_page(
|
||
'این آدرس برای برگشت از بازار است و بدون code کاری نمیکند.', status=400,
|
||
)
|
||
try:
|
||
bazaar.complete_authorization(code=code, state=request.query_params.get('state', ''))
|
||
except bazaar.BazaarAuthorizationError as exc:
|
||
return _bazaar_oauth_page(str(exc), status=400)
|
||
return _bazaar_oauth_page('اتصال به بازار انجام شد. میتوانید این صفحه را ببندید.', status=200)
|
||
|
||
@extend_schema(
|
||
request=BazaarVerifyRequestSerializer,
|
||
responses=BazaarVerifyResponseSerializer,
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = BazaarVerifyRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
# verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشتسرهم) عمداً *قبل* از
|
||
# بازشدنِ atomic/lock صدا زده میشه، نه داخلش — وگرنه ردیفِ Order (و هر
|
||
# تراکنشِ دیگهای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار
|
||
# قفل میموند. لاک فقط برای اعمالِ نتیجه (چند میلیثانیه) گرفته میشه.
|
||
order_lookup = get_object_or_404(
|
||
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||
)
|
||
|
||
# SKU فقط از خودِ سفارش. تا ۲۰۲۶-۱۰-۰۳ اگر سفارش SKU نداشت، SKUِ فرستادهی کلاینت سنجیده
|
||
# میشد؛ یعنی توکنِ خریدِ ارزانترین محصول سفارشِ گرانترین پلن را «paid» میکرد.
|
||
if not order_lookup.bazaar_product_sku:
|
||
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
|
||
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
|
||
return self._second_purchase_response()
|
||
# نوعِ محصول از سفارش: سفارشِ اشتراک subscription_id دارد (PurchaseView)، بسته ندارد. پیشتر نوع
|
||
# حدس زده میشد (اول تکخرید، بعد اشتراک).
|
||
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():
|
||
order = get_object_or_404(
|
||
Order.objects.select_for_update(),
|
||
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||
)
|
||
|
||
if self._second_purchase_on_paid_order(order, v['purchaseToken']):
|
||
return self._second_purchase_response()
|
||
new_status = 'paid' if result.is_valid else 'failed'
|
||
# توکن فقط با پاسخِ «معتبر» روی سفارش و تراکنش مینشیند. قیدِ یکتای (gateway, ref) همهی
|
||
# وضعیتها را میگیرد؛ وقتی ردِ بازار (not_found، یا توکن با سفارشِ اشتباه) هم توکن را ثبت
|
||
# میکرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمیشد (بازتولید شد).
|
||
if result.is_valid:
|
||
order.bazaar_purchase_token = v['purchaseToken']
|
||
order.bazaar_valid_until = result.valid_until
|
||
order.save(update_fields=['bazaar_purchase_token', 'bazaar_valid_until'])
|
||
|
||
apply_order_payment_result(
|
||
order=order,
|
||
gateway='bazaar',
|
||
gateway_ref_id=v['purchaseToken'] if result.is_valid else '',
|
||
new_status=new_status,
|
||
event='bazaar_verify',
|
||
)
|
||
# apply_order_payment_result سفارش را از نو میخواند و همان را عوض میکند؛ این نمونه هنوز
|
||
# وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالیکه سفارش «paid» شده بود.
|
||
order.refresh_from_db(fields=['status'])
|
||
|
||
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
|
||
return Response(BazaarVerifyResponseSerializer(data).data)
|
||
|
||
@staticmethod
|
||
def _second_purchase_on_paid_order(order, purchase_token: str) -> bool:
|
||
"""
|
||
خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیشتر پاسخ «paid» بود، چیزی داده
|
||
نمیشد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود.
|
||
"""
|
||
# «refunded» هم: سفارشِ بازپرداختشده با توکنِ تازه دوباره «paid» میشد ولی اعتبارش (که یک بار
|
||
# برای همین سفارش داده شده بود) دیگر داده نمیشد؛ کاربر پولِ خریدِ دوم را بیهیچ چیزی میداد.
|
||
return (order.status in ('paid', 'refunded') and bool(order.bazaar_purchase_token)
|
||
and order.bazaar_purchase_token != purchase_token)
|
||
|
||
@staticmethod
|
||
def _second_purchase_response():
|
||
return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'},
|
||
status=status.HTTP_409_CONFLICT)
|
||
|
||
|
||
class BazaarStartPurchaseView(APIView):
|
||
"""
|
||
POST /api/payment/bazaar/orders/{id}/start — آنچه اپِ اندروید برای شروعِ خرید در بازار لازم دارد.
|
||
|
||
پس از ساخت سفارش (`POST /api/subscription/purchase` با `paymentMethod: bazaar`) و کدِ تخفیفِ احتمالی
|
||
(`POST /api/discount/redeem`) صدا زده میشود. اگر کدِ تخفیف مبلغ را کم کرده، `dynamicPriceToken` همان
|
||
مبلغ را (به ریال) برای بازار امضا میکند؛ بیتخفیف null است و بازار قیمتِ پیشخان را میگیرد (عمداً: توکن
|
||
روی بازارِ قدیمیتر از ۱۳.۳ خطا میدهد). بعد از این، کدِ تخفیفِ تازه روی سفارش نمینشیند.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'bazaar_start'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(request=None, responses=BazaarStartResponseSerializer, tags=['Payment (payment)'])
|
||
def post(self, request, order_id):
|
||
with transaction.atomic():
|
||
order = get_object_or_404(
|
||
Order.objects.select_for_update(), pk=order_id, user_id=request.user.id, provider='bazaar')
|
||
if order.status != 'pending':
|
||
raise ValidationError({'orderId': 'این سفارش دیگر در انتظارِ پرداخت نیست.'})
|
||
if not order.bazaar_product_sku:
|
||
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
|
||
token = None
|
||
full_price = order.list_price if order.list_price is not None else order.amount
|
||
if order.amount < full_price:
|
||
if not bazaar.dynamic_price_configured():
|
||
# redeem بیکلید تخفیف را روی سفارشِ بازار نمیگذارد؛ رسیدن به اینجا یعنی کلید بعد از آن
|
||
# از .env رفته. بیتوکن بازار قیمتِ کامل را میگرفت.
|
||
logger.error('bazaar start: سفارشِ %s تخفیف دارد ولی BAZAAR_DYNAMIC_PRICE_KEY نیست', order.id)
|
||
raise bazaar.BazaarAPIError()
|
||
price_rial = _to_rial(order.amount, order.currency)
|
||
token = bazaar.dynamic_price_token(sku=order.bazaar_product_sku, price_rial=price_rial)
|
||
order.bazaar_price_rial = price_rial
|
||
order.save(update_fields=['bazaar_price_rial'])
|
||
elif order.bazaar_price_rial is None:
|
||
# علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است.
|
||
order.bazaar_price_rial = _to_rial(order.amount, order.currency)
|
||
order.save(update_fields=['bazaar_price_rial'])
|
||
data = {
|
||
'orderId': str(order.id),
|
||
'sku': order.bazaar_product_sku,
|
||
'productType': 'subscription' if order.subscription_id else 'inapp',
|
||
'amount': order.amount,
|
||
'dynamicPriceToken': token,
|
||
}
|
||
return Response(BazaarStartResponseSerializer(data).data)
|
||
|
||
|
||
def _to_rial(amount: int, currency: str) -> int:
|
||
if currency == 'IRT':
|
||
return amount * 10
|
||
if currency == 'IRR':
|
||
return amount
|
||
raise ValueError(f'unknown currency {currency!r}')
|
||
|
||
|
||
class OrderDetailView(APIView):
|
||
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
||
|
||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||
throttle_scope = 'read'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)'])
|
||
def get(self, request, order_id):
|
||
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
|
||
if reconcile_pending_order(order):
|
||
order.refresh_from_db()
|
||
data = {
|
||
'orderId': str(order.id),
|
||
'status': order.status,
|
||
'amount': order.amount,
|
||
'transactions': [
|
||
{
|
||
'id': str(t.id),
|
||
'gateway': t.gateway,
|
||
'status': t.status,
|
||
'paidAt': t.paid_at,
|
||
}
|
||
for t in order.transactions.all()
|
||
],
|
||
}
|
||
return Response(OrderDetailResponseSerializer(data).data)
|
||
|
||
|
||
INVOICE_LIMIT = 50
|
||
|
||
|
||
def invoice_payload(order) -> dict:
|
||
"""یک سفارش به شکلِ فاکتور. کدِ تخفیف و کدِ پیگیری از جدولهای خودشان خوانده میشوند."""
|
||
from discount.models import Redemption
|
||
|
||
redemption = Redemption.objects.filter(order_id=order.id).select_related('discount_code').first()
|
||
paid = next((t for t in order.transactions.all() if t.status == 'paid'), None)
|
||
# کدِ تخفیفِ ۱۰۰٪ هم یک Transactionِ «paid» میسازد، با refی ساختگی (discount-…)؛
|
||
# آن کدِ پیگیریِ بانکی نیست و به کاربر نشان داده نمیشود.
|
||
ref = paid.gateway_ref_id if paid and paid.gateway != 'discount' else ''
|
||
return {
|
||
'orderId': str(order.id),
|
||
'invoiceNumber': f'TD-{order.id:06d}',
|
||
'status': order.status,
|
||
'provider': order.provider,
|
||
'planTitle': order.plan_title,
|
||
'listPrice': order.list_price,
|
||
'discountAmount': (order.list_price - order.amount) if order.list_price is not None else None,
|
||
'discountCode': redemption.discount_code.code if redemption else '',
|
||
'amount': order.amount,
|
||
'currency': order.currency,
|
||
'createdAt': order.created_at,
|
||
'paidAt': paid.paid_at if paid else None,
|
||
'refId': ref,
|
||
}
|
||
|
||
|
||
class InvoiceListView(APIView):
|
||
"""
|
||
GET /api/payment/invoices — پرداختها و فاکتورهای خودِ کاربر، تازهترین اول.
|
||
|
||
همهی سفارشها برمیگردند، نه فقط پرداختشدهها، تا کسی که پولش کم شده ولی
|
||
اشتراکش فعال نشده سفارشِ «در انتظار» یا «ناموفق» خودش را ببیند و کدش را به
|
||
پشتیبانی بدهد. اینجا از زرینپال بازسنجی نمیشود (برای ۵۰ سفارش ۵۰ فراخوانی
|
||
میشد)؛ GET /api/payment/orders/{id} و وضعیتِ اشتراک این کار را میکنند.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
throttle_scope = 'read'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(responses=InvoiceListResponseSerializer, tags=['Payment (payment)'])
|
||
def get(self, request):
|
||
orders = (Order.objects.filter(user_id=request.user.id)
|
||
.prefetch_related('transactions').order_by('-created_at', '-id')[:INVOICE_LIMIT])
|
||
return Response(InvoiceListResponseSerializer({'invoices': [invoice_payload(o) for o in orders]}).data)
|