""" منطق مشترک اپ Payment که هم از views.py همین اپ و هم به‌صورت سرور-به-سرور از اپ‌های دیگه (مثل subscription) صدا زده می‌شه. چون این پروژه یک مونولیت جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم پیاده‌سازی شده، نه یک HTTP call به خودمون. """ from django.conf import settings from django.core import signing 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, provider: str | 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)) or (provider is not None and order.provider != provider)): 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?t={pay_link_token(order.id)}' # لینکِ پرداخت بی‌توکنِ ورود باز می‌شود (مرورگر هدرِ Bearer نمی‌فرستد) و شناسه‌ی سفارش ترتیبی # است؛ امضای کوتاه‌عمر جلوی این را می‌گیرد که غریبه با پیمایشِ شناسه‌ها روی سفارشِ دیگران # authority بسازد (و کدِ تخفیفِ صاحبش را ببندد) یا وضعیتِ سفارششان را از ریدایرکت بخواند. # امضا با همان DJANGO_SECRET_KEY است؛ تا وقتی آن کلید روی سرور همان کلیدِ داخلِ ریپوست، # این امضا هم برای کسی که ریپو را دیده جعل‌شدنی است. یک روز: لینک بلافاصله بعد از ساختِ # سفارش باز می‌شود؛ یک روز صفحه‌ای را هم که باز مانده پوشش می‌دهد. PAY_LINK_SALT = 'tadrisino-pay-link' PAY_LINK_MAX_AGE_SECONDS = 24 * 3600 def pay_link_token(order_id: int) -> str: return signing.TimestampSigner(salt=PAY_LINK_SALT).sign(str(order_id)) def pay_link_is_valid(order_id: int, token: str) -> bool: try: value = signing.TimestampSigner(salt=PAY_LINK_SALT).unsign(token, max_age=PAY_LINK_MAX_AGE_SECONDS) except signing.BadSignature: # SignatureExpired هم زیرکلاسِ همین است return False return value == str(order_id) 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, provider=provider, ) 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=provider, ) # برای 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 # نتیجه‌ی ناموفق فقط سفارشِ «در انتظار» را عوض می‌کند. کال‌بکِ زرین‌پال بی‌احراز است و # authority از هدرِ Location ِ /pay خوانده می‌شود؛ تا ۲۰۲۶-۱۰-۰۳ یک Status=NOKِ جعلی سفارشِ # «بازپرداخت‌شده» یا «لغوشده» را «ناموفق» می‌کرد و ردِ بازپرداخت از وضعیتِ سفارش می‌رفت # (بازتولید شد). «paid» عمداً از این قاعده بیرون است: کسی که پول داده باید چیزش را بگیرد. if new_status != 'paid' and old_status != 'pending': 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) elif order.plan_id: # بسته‌ی سوال: اعتبار و روزهای قلبِ نامحدود، بی‌دست زدن به اشتراک. from subscription.services import grant_pack grant_pack(order) # پورسانتِ معرف فقط روی پرداختِ واقعی (نه ثبت‌نام، نه مهمان). 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) class OrderStateError(Exception): """سفارش در وضعیتی نیست که این کار رویش معنا داشته باشد (پیامش به ادمین نشان داده می‌شود).""" def cancel_pending_order(order: Order, *, staff: str = '') -> None: """ سفارشِ «در انتظار» را لغو می‌کند (پنلِ ادمین). پولی جابه‌جا نشده و چیزی داده نشده. اگر کاربر در همان لحظه واقعاً در درگاه پرداخت کرده باشد، تأییدِ بعدیِ زرین‌پال سفارش را باز «paid» می‌کند (apply_order_payment_result فقط سفارشِ از قبل paid را نادیده می‌گیرد)؛ این عمدی است: کسی که پول داده باید چیزش را بگیرد. """ with db_transaction.atomic(): order = Order.objects.select_for_update().get(pk=order.pk) if order.status != 'pending': raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ در انتظار لغو می‌شود.') order.status = 'canceled' order.save(update_fields=['status']) status_changed.send(sender='payment.Order', object_id=order.id, old_status='pending', new_status='canceled', event=f'admin_cancel:{staff}'[:50], user_id=order.user_id) def refund_order(order: Order, *, staff: str = '') -> dict: """ سفارشِ پرداخت‌شده را «بازپرداخت‌شده» می‌کند و هر چه داده بود تا جایی که هنوز مانده پس می‌گیرد. **پول را برنمی‌گرداند.** برگرداندنِ پول در پنلِ زرین‌پال (یا کافه‌بازار) انجام می‌شود؛ این تابع فقط حساب‌وکتابِ خودِ اپ را با آن یکی می‌کند. چه چیزی پس گرفته می‌شود (تصمیمِ ۲۰۲۶-۱۰-۰۲): - بسته: اعتبارِ همین سفارش، ولی فقط آن‌قدر که هنوز خرج نشده (موجودی منفی نمی‌شود)، و روزهای قلبِ نامحدودِ همین بسته از پایانِ کارتِ قلب. - اشتراک: روزهای همین پلن از پایانِ اشتراک؛ اگر چیزی نماند اشتراک «لغوشده» می‌شود. - پورسانتِ معرف برای همین سفارش به «در انتظار» برمی‌گردد. خروجی: خلاصه برای پیامِ ادمین. """ from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription from subscription.services import credit_balance with db_transaction.atomic(): order = Order.objects.select_for_update().get(pk=order.pk) if order.status != 'paid': raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ پرداخت‌شده بازپرداخت می‌شود.') now = timezone.now() summary = {'credits_granted': 0, 'credits_taken': 0, 'hearts_days': 0, 'subscription_days': 0} plan = Plan.objects.filter(pk=order.plan_id).first() if order.plan_id else None if order.subscription_id: sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first() days = plan.duration_days if plan else 30 if sub is not None and sub.expires_at is not None: sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now) if sub.expires_at <= now: sub.status = 'canceled' sub.version += 1 # PurchaseView با version تشخیص می‌دهد کسی ردیف را هم‌زمان عوض کرده sub.save(update_fields=['expires_at', 'status', 'version']) summary['subscription_days'] = days else: granted = QuestionCredit.objects.filter(order_id=order.id, reason='purchase').first() if granted is not None: take = min(granted.delta, max(credit_balance(order.user_id), 0)) summary['credits_granted'] = granted.delta summary['credits_taken'] = take if take: QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund', order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}') if plan is not None and plan.hearts_days: hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() if hp is not None: hp.expires_at = max(hp.expires_at - timezone.timedelta(days=plan.hearts_days), now) hp.save(update_fields=['expires_at']) summary['hearts_days'] = plan.hearts_days from affiliate.models import Conversion Conversion.objects.filter(order_id=order.id, status='paid').update( status='pending', order_id=None, commission_amount=0) order.status = 'refunded' order.save(update_fields=['status']) Transaction.objects.create(order=order, gateway='admin', status='refunded', gateway_ref_id=f'refund-{order.id}') status_changed.send(sender='payment.Order', object_id=order.id, old_status='paid', new_status='refunded', event=f'admin_refund:{staff}'[:50], user_id=order.user_id) return summary