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:
@@ -4,6 +4,7 @@
|
||||
جنگوییه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
|
||||
پیادهسازی شده، نه یک HTTP call به خودمون.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
@@ -281,12 +282,97 @@ def reconcile_pending_order(order: Order) -> bool:
|
||||
|
||||
|
||||
def reconcile_user_orders(user_id: int) -> None:
|
||||
"""سفارشهای pendingِ اخیرِ یک کاربر؛ از صفحهی وضعیتِ اشتراک صدا زده میشود."""
|
||||
"""سفارشهای pendingِ اخیرِ یک کاربر و تمدیدِ اشتراکِ بازار؛ از صفحهی وضعیتِ اشتراک صدا زده میشود."""
|
||||
since = timezone.now() - timezone.timedelta(days=2)
|
||||
for order in Order.objects.filter(
|
||||
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
|
||||
).exclude(gateway_authority='')[:3]:
|
||||
reconcile_pending_order(order)
|
||||
sync_bazaar_renewals(user_id)
|
||||
|
||||
|
||||
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده میشود که تمدید شده یا نه. بازار روزِ آخر تمدید میکند.
|
||||
BAZAAR_RENEWAL_LOOKAHEAD = timezone.timedelta(days=1)
|
||||
|
||||
|
||||
def sync_bazaar_renewals(user_id: int) -> int:
|
||||
"""
|
||||
اشتراکِ بازار خودکار تمدید میشود و بازار به ما خبر نمیدهد (webhook ندارد). پس وقتی دورهی آخرِ
|
||||
پرداختشدهی کاربر (`bazaar_valid_until`ِ آخرین سفارشِ همان توکن) نزدیکِ پایان یا گذشته است، همان توکن
|
||||
دوباره از بازار پرسیده میشود؛ اگر `validUntil` جلو رفته، برای هر دورهی تازه یک سفارشِ «paid» ساخته و
|
||||
همان مسیرِ پرداخت (activate_subscription، اعلان، پورسانت) اجرا میشود. هر توکن ساعتی یک بار.
|
||||
|
||||
مبلغِ سفارشِ تمدید = قیمتِ فعلیِ پلن در ادمین؛ بازار تمدید را با قیمتِ پیشخان میگیرد و آن را به ما
|
||||
نمیگوید. روزها مثلِ هر خریدِ دیگر روی پایانِ فعلی اضافه میشوند، پس کاربری که چند روز بعد از تمدید
|
||||
میآید همان چند روز را بیشتر دارد. نسنجیده: تمدیدِ واقعی در بازار. خروجی: تعدادِ دورههای تازه.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
from . import bazaar
|
||||
|
||||
now = timezone.now()
|
||||
latest_by_token = {}
|
||||
for order in Order.objects.filter(
|
||||
user_id=user_id, provider='bazaar', status='paid', subscription_id__isnull=False,
|
||||
bazaar_valid_until__isnull=False,
|
||||
).exclude(bazaar_purchase_token='').order_by('bazaar_valid_until'):
|
||||
latest_by_token[order.bazaar_purchase_token] = order
|
||||
added = 0
|
||||
for token, last in latest_by_token.items():
|
||||
if last.bazaar_valid_until > now + BAZAAR_RENEWAL_LOOKAHEAD:
|
||||
continue
|
||||
token_key = hashlib.sha256(token.encode()).hexdigest()[:32]
|
||||
if not cache.add(f'payment:bazaar-renewal:{token_key}', 1, 3600):
|
||||
continue
|
||||
try:
|
||||
result = bazaar.verify_purchase(
|
||||
product_sku=last.bazaar_product_sku, purchase_token=token, is_subscription=True)
|
||||
except bazaar.BazaarAPIError: # خودِ bazaar لاگ کرده؛ ساعتِ بعد
|
||||
continue
|
||||
except Exception: # noqa: BLE001 — صفحهی وضعیت نباید با خطای بازار ۵۰۰ بدهد؛ ولی دیده شود
|
||||
logger.exception('bazaar renewal: سنجشِ تمدیدِ سفارشِ %s شکست خورد', last.id)
|
||||
continue
|
||||
if result.valid_until is None or result.valid_until <= last.bazaar_valid_until + timezone.timedelta(hours=12):
|
||||
continue
|
||||
added += _record_bazaar_renewal(last, token, token_key, result.valid_until)
|
||||
return added
|
||||
|
||||
|
||||
def _record_bazaar_renewal(last: Order, token: str, token_key: str, valid_until) -> int:
|
||||
from subscription.models import Plan
|
||||
|
||||
plan = Plan.objects.filter(pk=last.plan_id).first() if last.plan_id else None
|
||||
days = last.duration_days or (plan.duration_days if plan else None)
|
||||
if not days:
|
||||
logger.error('bazaar renewal: سفارشِ %s مدت ندارد؛ تمدید ثبت نشد', last.id)
|
||||
return 0
|
||||
gap = (valid_until - last.bazaar_valid_until).total_seconds() / 86400
|
||||
periods = max(1, round(gap / days))
|
||||
if periods > 1:
|
||||
logger.warning('bazaar renewal: %s دورهی تمدید برای سفارشِ %s یکجا ثبت میشود', periods, last.id)
|
||||
msec = int(valid_until.timestamp() * 1000)
|
||||
added = 0
|
||||
for i in range(periods):
|
||||
period_end = valid_until - timezone.timedelta(days=days * (periods - 1 - i))
|
||||
price = plan.price if plan else last.list_price or last.amount
|
||||
order, created = Order.objects.get_or_create(
|
||||
idempotency_key=f'bazaar-renew-{token_key}-{msec}-{i}',
|
||||
defaults=dict(
|
||||
user_id=last.user_id, subscription_id=last.subscription_id, plan_id=last.plan_id,
|
||||
duration_days=days, amount=price, list_price=price,
|
||||
plan_title=(plan.title if plan else last.plan_title)[:50], currency=last.currency,
|
||||
status='pending', provider='bazaar', bazaar_product_sku=last.bazaar_product_sku,
|
||||
bazaar_purchase_token=token, bazaar_valid_until=period_end,
|
||||
),
|
||||
)
|
||||
if not created and order.status != 'pending':
|
||||
continue
|
||||
if apply_order_payment_result(
|
||||
order=order, gateway='bazaar', gateway_ref_id=f'bazaar-renew-{token_key}-{msec}-{i}', # توکن تا ۳۰۰ نویسه است؛ ref هم ۳۰۰
|
||||
new_status='paid', event='bazaar_renewal',
|
||||
):
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
class OrderStateError(Exception):
|
||||
|
||||
Reference in New Issue
Block a user