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:
2026-10-12 17:53:11 +03:30
parent 2e0753587c
commit 8077ab4350
20 changed files with 580 additions and 40 deletions

View File

@@ -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} — وضعیت سفارش"""