Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- 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>
This commit is contained in:
@@ -20,6 +20,7 @@ from .models import Order
|
||||
from .permissions import HasInternalServiceKey
|
||||
from .serializers import (
|
||||
InvoiceListResponseSerializer,
|
||||
BazaarStartResponseSerializer,
|
||||
BazaarVerifyRequestSerializer,
|
||||
BazaarVerifyResponseSerializer,
|
||||
CreateOrderRequestSerializer,
|
||||
@@ -339,9 +340,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)، بسته ندارد. پیشتر نوع
|
||||
# حدس زده میشد (اول تکخرید، بعد اشتراک).
|
||||
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():
|
||||
@@ -358,7 +362,8 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
# میکرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمیشد (بازتولید شد).
|
||||
if result.is_valid:
|
||||
order.bazaar_purchase_token = v['purchaseToken']
|
||||
order.save(update_fields=['bazaar_purchase_token'])
|
||||
order.bazaar_valid_until = result.valid_until
|
||||
order.save(update_fields=['bazaar_purchase_token', 'bazaar_valid_until'])
|
||||
|
||||
apply_order_payment_result(
|
||||
order=order,
|
||||
@@ -391,6 +396,63 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
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} — وضعیت سفارش"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user