There was no way to take an order back: staff could only edit the status by hand, and turning 'paid' into 'failed' left the credits, heart days or subscription days the order had given. Two OrderAdmin actions now do it: - cancel a pending order (a payment that does arrive later still counts); - refund a paid order, after a confirmation page: takes back the pack's unspent credits (never below zero), its heart days, or the plan's subscription days, and undoes the referrer's commission. The money itself is returned in the Zarinpal / Bazaar panel; the action says so. Status is read-only in the order form. Steps in docs/operations.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
17 KiB
Python
321 lines
17 KiB
Python
"""
|
||
منطق مشترک اپ 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)
|
||
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
|