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:
2026-10-12 18:21:45 +03:30
parent 76b0ef976e
commit c0fa46be44
11 changed files with 246 additions and 66 deletions

View 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)]

View File

@@ -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):

View File

@@ -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):

View File

@@ -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