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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user