admin: cancel a pending order, refund a paid one

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>
This commit is contained in:
2026-10-02 22:37:30 +03:30
parent 4700ccbf91
commit d7d168e82f
10 changed files with 401 additions and 4 deletions

View File

@@ -234,3 +234,87 @@ def reconcile_user_orders(user_id: int) -> None:
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