""" منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم پیاده‌سازی شده، نه یک HTTP call به خودمون. """ from django.conf import settings from django.db import IntegrityError from django.db import transaction as db_transaction from django.utils import timezone from django.utils.crypto import get_random_string from rest_framework.exceptions import APIException from statuslog.signals import status_changed from .models import Order, Transaction class IdempotencyKeyConflict(APIException): """اگر Idempotency-Key موجود متعلق به کاربر یا پارامترهای دیگری باشد، 409 برمی‌گرداند.""" status_code = 409 default_detail = 'این Idempotency-Key قبلاً برای درخواست دیگری استفاده شده است.' default_code = 'idempotency_key_conflict' def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str, subscription_id: int | None, plan_id: int | None = None) -> None: if order.user_id != user_id: raise IdempotencyKeyConflict( 'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.' ) # مبلغ فقط برای سفارشی که هنوز تخفیف نگرفته مقایسه می‌شود؛ بعد از کدِ # تخفیف، مبلغِ سفارش عمداً کمتر از قیمتِ پلن است. discounted = order.amount < amount and order.status == 'pending' if ((order.amount != amount and not discounted) or order.currency != currency or order.subscription_id != subscription_id or (plan_id is not None and order.plan_id not in (None, plan_id))): raise IdempotencyKeyConflict( 'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.' ) def call_payment_gateway(order: Order) -> str: """ paymentUrlی که به کلاینت برمی‌گردد: آدرسِ داخلیِ خودمان (PaymentRedirectView)، نه مستقیماً StartPay زرین‌پال. فراخوانیِ واقعیِ PaymentRequest به زرین‌پال (یک HTTP call خارجی) عمداً همین‌جا انجام نمی‌شود — create_order از subscription صدا زده می‌شود و نباید موفقیتِ ساختِ سفارش به دسترس‌بودن/سرعتِ زرین‌پال گره بخورد؛ PaymentRequest اولین باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به payment/zarinpal.py و PaymentRedirectView در views.py). """ return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay' def create_order(*, user_id: int, amount: int, currency: str = 'IRT', subscription_id: int | None = None, idempotency_key: str | None = None, provider: str = 'gateway', bazaar_product_sku: str = '', plan_id: int | None = None, plan_title: str = ''): """ ساخت سفارش با پشتیبانی از Idempotency-Key: اگر سفارشی با همین کلید قبلاً ساخته شده باشد، همان رکورد برگردانده می‌شود (نه رکورد تکراری)، فقط اگر متعلق به همین کاربر و همین پارامترها باشد. """ if not idempotency_key: idempotency_key = get_random_string(32) existing = Order.objects.filter(idempotency_key=idempotency_key).first() if existing is not None: _ensure_idempotent_match( existing, user_id=user_id, amount=amount, currency=currency, subscription_id=subscription_id, plan_id=plan_id, ) payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing) return existing, payment_url try: with db_transaction.atomic(): order = Order.objects.create( user_id=user_id, subscription_id=subscription_id, plan_id=plan_id, amount=amount, list_price=amount, plan_title=plan_title[:50], currency=currency, status='pending', provider=provider, bazaar_product_sku=bazaar_product_sku, idempotency_key=idempotency_key, ) except IntegrityError: # درخواست هم‌زمان دیگری با همین کلید زودتر ساخت. order = Order.objects.filter(idempotency_key=idempotency_key).first() if order is None: raise _ensure_idempotent_match( order, user_id=user_id, amount=amount, currency=currency, subscription_id=subscription_id, plan_id=plan_id, ) # برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً # با SDK بازار خرید را انجام می‌دهد و purchaseToken را به verify می‌فرستد. payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order) return order, payment_url def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: str, new_status: str, event: str) -> bool: """ منطق مشترکِ «نتیجه‌ی پرداخت رسید»، هم برای ZarinpalCallbackView و هم BazaarVerifyPurchaseView استفاده می‌شود. idempotent: اگر Transaction مشابه (order/status/gateway_ref_id) از قبل موجود باشد، دوباره اثر جانبی اجرا نمی‌شود. خروجی: True اگر این فراخوانی واقعاً باعث paid شدن تازه شده باشد. Order زیر select_for_update قفل و رفرش می‌شود و already_paid/is_duplicate *بعد* از قفل محاسبه می‌شوند تا در برابر فراخوانی‌های هم‌زمان ایمن باشد. """ with db_transaction.atomic(): order = Order.objects.select_for_update().get(pk=order.pk) already_paid = order.status == 'paid' old_status = order.status is_duplicate = Transaction.objects.filter( order=order, status=new_status, gateway_ref_id=gateway_ref_id, ).exists() # callback دیرهنگام/خارج از ترتیب با status غیر از paid نباید سفارشِ # از قبل paid شده را به عقب برگرداند؛ چنین callback ای idempotent/no-op است. if already_paid and new_status != 'paid': is_duplicate = True # reuse رفرنسِ پرداخت روی سفارش دیگر هم duplicate/no-op در نظر گرفته می‌شود. if gateway_ref_id and Transaction.objects.filter( gateway=gateway, gateway_ref_id=gateway_ref_id, ).exclude(order=order).exists(): is_duplicate = True if not is_duplicate: order.status = new_status order.save(update_fields=['status']) try: # savepoint تودرتو: اگر IntegrityError بدهد فقط همین savepoint # rollback می‌شود و atomic بیرونی سالم می‌ماند (مخصوصاً روی PostgreSQL # که یک تراکنش بعد از IntegrityError به‌طور کامل broken می‌شود). with db_transaction.atomic(): Transaction.objects.create( order=order, gateway=gateway, gateway_ref_id=gateway_ref_id, status=new_status, paid_at=timezone.now() if new_status == 'paid' else None, ) except IntegrityError: # دفاع در عمق: قید یکتاییِ سراسری (uniq_transaction_gateway_ref_global) # جلوی ساخت Transaction تکراری با همین رفرنس را می‌گیرد؛ در این حالت # status سفارش به مقدار قبل از تغییر برمی‌گردد. is_duplicate = True if order.status != old_status: order.status = old_status order.save(update_fields=['status']) else: status_changed.send( sender='payment.Order', object_id=order.id, old_status=old_status, new_status=new_status, event=event, user_id=order.user_id, ) newly_paid = new_status == 'paid' and not already_paid and not is_duplicate if newly_paid: if order.subscription_id: from subscription.services import activate_subscription activate_subscription(order.subscription_id, plan_id=order.plan_id) # پورسانتِ معرف فقط روی پرداختِ واقعی (نه ثبت‌نام، نه مهمان). from affiliate.services import record_paid_order record_paid_order(order) from notifications.services import notify notify( user_id=order.user_id, type='order_paid', title='پرداخت با موفقیت انجام شد', related_id=order.id, ) return newly_paid def reconcile_pending_order(order: Order) -> bool: """ سفارشی که کاربر در درگاه پرداخت کرده ولی تأییدش نرسیده (مثلاً زرین‌پال هنگامِ کال‌بک جواب نداد) را دوباره با verify می‌سنجد. پیش‌تر چنین سفارشی برای همیشه pending می‌ماند: پولِ دانش‌آموز گرفته شده بود و اشتراکی در کار نبود. فقط به سمتِ paid می‌برد، هرگز failed نمی‌کند: کاربری که هنوز در صفحه‌ی درگاه است نباید سفارشش زیرِ پایش باطل شود. هر سفارش حداکثر دقیقه‌ای یک بار سنجیده می‌شود. خروجی: True اگر همین فراخوانی سفارش را paid کرد. """ from django.core.cache import cache from . import zarinpal if order.provider != 'gateway' or order.status != 'pending' or not order.gateway_authority: return False if not cache.add(f'payment:reconcile:{order.id}', 1, 60): return False try: result = zarinpal.verify_payment( authority=order.gateway_authority, amount=order.amount, currency=order.currency, ) except Exception: # noqa: BLE001 — درگاه در دسترس نیست؛ دفعه‌ی بعد return False if not result.is_valid: return False return apply_order_payment_result( order=order, gateway='zarinpal', gateway_ref_id=result.ref_id, new_status='paid', event='zarinpal_reconcile', ) def reconcile_user_orders(user_id: int) -> None: """سفارش‌های 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)