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

@@ -0,0 +1,39 @@
"""
شناسه‌ی کالای بازار برای اشتراک‌های سه‌ماهه و شش‌ماهه (۲۰۲۶-۱۰-۱۲).
از پیشخانِ بازار (عکسِ سینیور): «اشتراکی ۹۰ روزه» = 10343، «اشتراکی ۱۸۰ روزه» = 10344. بازار هر دو را زیرِ
com.tadrisino.math.app شناخت (پرسشِ API با توکنِ ساختگی: «purchase not found»، نه «product not found»).
10342 هم در بازار هست ولی کسی نگفته چیست؛ عمداً به هیچ پلنی وصل نشده.
پلن‌ها با مدت پیدا می‌شوند، نه با id (idها بین دیتابیس‌ها فرق دارند). اگر دقیقاً یک پلنِ فروشیِ هم‌مدت
نباشد، migration می‌شکند تا دستی دیده شود؛ SKUی که ادمین از پیش گذاشته دست نمی‌خورد.
"""
from django.db import migrations
SKUS = {90: '10343', 180: '10344'}
def forward(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
for days, sku in SKUS.items():
if Plan.objects.filter(bazaar_sku=sku).exists():
continue
plans = list(Plan.objects.filter(kind='subscription', on_sale=True, duration_days=days))
if len(plans) != 1:
raise RuntimeError(
f'SKUِ بازار {sku}: باید دقیقاً یک اشتراکِ فروشیِ {days} روزه باشد، {len(plans)} تا پیدا شد.')
plan = plans[0]
if plan.bazaar_sku:
continue
plan.bazaar_sku = sku
plan.save(update_fields=['bazaar_sku'])
def backward(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
Plan.objects.filter(bazaar_sku__in=SKUS.values()).update(bazaar_sku='')
class Migration(migrations.Migration):
dependencies = [('subscription', '0013_monthly_plans_2026_10')]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -43,7 +43,7 @@ class Plan(models.Model):
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
# پلنی که دیگر فروخته نمی‌شود ولی مشترک‌های فعلی‌اش هنوز به آن وصل‌اند.
on_sale = models.BooleanField(default=True, help_text='در صفحه‌ی خرید نشان داده شود.')
# شناسه‌ی همین بسته در پیشخانِ کافه‌بازار (محصولِ درون‌برنامه‌ای، مصرف‌شدنی). خریدِ بازار
# شناسه‌ی همین پلن در پیشخانِ کافه‌بازار: بسته = محصولِ مصرف‌شدنی، اشتراک = محصولِ «اشتراکی». خریدِ بازار
# SKU را فقط از اینجا می‌گیرد، هرگز از کلاینت: وگرنه توکنِ ارزان‌ترین محصول بسته‌ی گران را
# فعال می‌کرد. خالی یعنی این بسته در بازار فروخته نمی‌شود (درخواستش ۴۰۰ می‌گیرد).
bazaar_sku = models.CharField(

View File

@@ -16,7 +16,7 @@ class PlanSerializer(serializers.Serializer):
questionCredits = serializers.IntegerField(help_text='بسته: سوال‌هایی که به اعتبار اضافه می‌شود (منقضی نمی‌شود).')
heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.')
bazaarSku = serializers.CharField(
allow_blank=True, help_text='SKUِ همین بسته در کافه‌بازار؛ خالی یعنی در بازار خریدنی نیست.')
allow_blank=True, help_text='SKUِ همین پلن (بسته یا اشتراک) در کافه‌بازار؛ خالی یعنی در بازار خریدنی نیست.')
class PlansListResponseSerializer(serializers.Serializer):
@@ -40,6 +40,9 @@ class PurchaseResponseSerializer(serializers.Serializer):
bazaarSku = serializers.CharField(
required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey می‌خرد؛ '
'developerPayload همان orderId باشد.')
bazaarProductType = serializers.ChoiceField(
choices=['inapp', 'subscription'], required=False,
help_text='فقط بازار: inapp (بسته؛ purchaseProduct و بعد consume) یا subscription (subscribeProduct، بی‌consume).')
class SubscriptionStatusResponseSerializer(serializers.Serializer):

View File

@@ -32,6 +32,19 @@ def has_active_subscription(user_id: int) -> bool:
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)).exists()
def has_subscription_access(user_id: int) -> bool:
"""
دیوارِ پرداخت (نقشه و فصل‌ها). مثلِ has_active_subscription، با این فرق که اگر اشتراک تمام شده، اول
تمدیدِ خودکارِ بازار را می‌پرسد (payment.services.sync_bazaar_renewals): بازار خبر نمی‌دهد، و بی‌این
مشترکی که بازار تمدیدش کرده تا باز کردنِ فروشگاه پشتِ دیوار می‌ماند. برای کسی که سفارشِ اشتراکِ بازار
ندارد فقط یک کوئری است؛ فراخوانیِ بازار هر توکن ساعتی یک بار.
"""
if has_active_subscription(user_id):
return True
from payment.services import sync_bazaar_renewals
return bool(sync_bazaar_renewals(user_id)) and has_active_subscription(user_id)
def hearts_pass_until(user_id: int):
"""تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده."""
hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first()

View File

@@ -103,20 +103,29 @@ class PurchaseView(APIView):
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
# بازار: SKU از پلن، نه از کلاینت. بسته محصولِ مصرف‌شدنیِ بازار است و اشتراک محصولِ «اشتراکی»
# (از ۲۰۲۶-۱۰-۱۲؛ تمدیدِ خودکارش در payment.services.sync_bazaar_renewals). خریدِ واقعی هنوز آزموده نشده.
provider = 'gateway'
if v['paymentMethod'] == 'bazaar':
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
# اشتراک فعلاً فقط از زرین‌پال فروخته می‌شود. SKU از پلن، نه از کلاینت.
if plan.kind != 'pack':
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'})
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این بسته هنوز در کافه‌بازار تعریف نشده است.'})
raise ValidationError({'planId': 'این مورد هنوز در کافه‌بازار تعریف نشده است.'})
provider = 'bazaar'
bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else ''
def response(order, payment_url):
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
if provider == 'bazaar':
data.update(bazaarSku=order.bazaar_product_sku,
bazaarProductType='subscription' if plan.kind == 'subscription' else 'inapp')
return Response(PurchaseResponseSerializer(data).data)
if provider == 'bazaar' and plan.kind == 'pack':
order, _ = create_order(
user_id=user.id, amount=plan.price, currency=plan.currency,
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
provider='bazaar', bazaar_product_sku=plan.bazaar_sku,
provider='bazaar', bazaar_product_sku=bazaar_sku,
)
return Response(PurchaseResponseSerializer(
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
return response(order, None)
if plan.kind == 'pack':
# بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی
@@ -187,6 +196,8 @@ class PurchaseView(APIView):
idempotency_key=idempotency_key,
plan_id=plan.id,
plan_title=plan.title,
provider=provider,
bazaar_product_sku=bazaar_sku,
)
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only
@@ -212,8 +223,7 @@ class PurchaseView(APIView):
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
return Response(PurchaseResponseSerializer(data).data)
return response(order, payment_url)
class SubscriptionStatusView(APIView):