Payment providers as classes (Strategy + factory); 10342 = monthly; health logs a failed Bazaar check
- payment/providers.py: ZarinpalProvider / BazaarProvider with one interface (check_plan, sku_for, payment_url, purchase_extras, discount_blocker, reconcile_user); provider_for / provider_for_method pick one; unknown fails. create_order, purchase view, discount redeem and reconcile_user_orders use it instead of scattered ifs. - subscription.0015: Bazaar SKU 10342 on the one-month plan (owner: "probably the monthly one"). - config/health: the Bazaar check no longer swallows its exception without a log. 982 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
32
subscription/migrations/0015_bazaar_sku_monthly.py
Normal file
32
subscription/migrations/0015_bazaar_sku_monthly.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
`10342` → اشتراکِ یکماهه (۲۰۲۶-۱۰-۱۲، به گفتهی مالک «احتمالاً همان یکماهه»؛ پیشخان را خودمان ندیدهایم).
|
||||
بازار آن را زیرِ com.tadrisino.math.app میشناسد؛ نوعش (اشتراکی یا تکخرید) را API بدونِ خریدِ واقعی نمیگوید.
|
||||
اگر اشتباه بود: «Bazaar sku» همان پلن را در ادمین خالی کنید. همان قاعدهی 0014: دقیقاً یک پلنِ فروشیِ ۳۰ روزه.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations
|
||||
|
||||
SKU = '10342'
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
if Plan.objects.filter(bazaar_sku=SKU).exists():
|
||||
return
|
||||
plans = list(Plan.objects.filter(kind='subscription', on_sale=True, duration_days=30))
|
||||
if len(plans) != 1:
|
||||
raise RuntimeError(f'SKUِ بازار {SKU}: باید دقیقاً یک اشتراکِ فروشیِ ۳۰ روزه باشد، {len(plans)} تا پیدا شد.')
|
||||
if not plans[0].bazaar_sku:
|
||||
plans[0].bazaar_sku = SKU
|
||||
plans[0].save(update_fields=['bazaar_sku'])
|
||||
cache.delete('subscription:plans:v3') # مثلِ 0014: سیگنالِ post_save در migration نمیآید
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
apps.get_model('subscription', 'Plan').objects.filter(bazaar_sku=SKU).update(bazaar_sku='')
|
||||
cache.delete('subscription:plans:v3')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('subscription', '0014_bazaar_skus_2026_10')]
|
||||
operations = [migrations.RunPython(forward, backward)]
|
||||
@@ -30,7 +30,8 @@ class PurchaseRequestSerializer(serializers.Serializer):
|
||||
# از request.headers خونده میشه، نه از بدنه.
|
||||
planId = serializers.CharField()
|
||||
# تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته میشد، پس «bazaar» بیصدا به زرینپال میرفت.
|
||||
paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal')
|
||||
paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal',
|
||||
help_text='payment/providers.py: هر مقدار یک کلاسِ PaymentProvider.')
|
||||
|
||||
|
||||
class PurchaseResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -41,8 +41,10 @@ def has_subscription_access(user_id: int) -> bool:
|
||||
"""
|
||||
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)
|
||||
# فقط بازار، نه همهی روشها: بازسنجیِ زرینپال در این مسیرِ پرترافیک برای هر بیاشتراکی با سفارشِ نیمهکاره
|
||||
# یک فراخوانیِ شبکه میشد؛ آن را صفحهی وضعیت و کالبک میکنند.
|
||||
from payment.providers import provider_for
|
||||
return bool(provider_for('bazaar').reconcile_user(user_id)) and has_active_subscription(user_id)
|
||||
|
||||
|
||||
def hearts_pass_until(user_id: int):
|
||||
|
||||
@@ -103,38 +103,24 @@ 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':
|
||||
if not plan.bazaar_sku:
|
||||
raise ValidationError({'planId': 'این مورد هنوز در کافهبازار تعریف نشده است.'})
|
||||
provider = 'bazaar'
|
||||
bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else ''
|
||||
# روشِ پرداخت (زرینپال/بازار) و تفاوتهایش از payment/providers.py؛ SKUِ بازار از پلن، نه از کلاینت.
|
||||
from payment.providers import provider_for_method
|
||||
provider = provider_for_method(v['paymentMethod'])
|
||||
provider.check_plan(plan)
|
||||
|
||||
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')
|
||||
data = {'orderId': str(order.id), 'paymentUrl': payment_url, **provider.purchase_extras(order, plan)}
|
||||
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=bazaar_sku,
|
||||
)
|
||||
return response(order, None)
|
||||
|
||||
if plan.kind == 'pack':
|
||||
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
||||
# خودِ سفارش داده میشوند (payment.services → subscription.services.grant_pack).
|
||||
order, payment_url = 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=provider.key, bazaar_product_sku=provider.sku_for(plan),
|
||||
)
|
||||
return Response(PurchaseResponseSerializer({'orderId': str(order.id), 'paymentUrl': payment_url}).data)
|
||||
return response(order, payment_url)
|
||||
|
||||
# Three-phase flow so create_order never runs inside a DB transaction/lock.
|
||||
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
|
||||
@@ -196,8 +182,8 @@ class PurchaseView(APIView):
|
||||
idempotency_key=idempotency_key,
|
||||
plan_id=plan.id,
|
||||
plan_title=plan.title,
|
||||
provider=provider,
|
||||
bazaar_product_sku=bazaar_sku,
|
||||
provider=provider.key,
|
||||
bazaar_product_sku=provider.sku_for(plan),
|
||||
)
|
||||
except Exception:
|
||||
# Guard by pk+status+plan_id+order_id+version so this cleanup only
|
||||
|
||||
Reference in New Issue
Block a user