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:
39
subscription/migrations/0014_bazaar_skus_2026_10.py
Normal file
39
subscription/migrations/0014_bazaar_skus_2026_10.py
Normal 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)]
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user