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:
@@ -12,9 +12,59 @@ class TransactionInline(admin.TabularInline):
|
||||
|
||||
@admin.register(Order)
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'provider', 'created_at')
|
||||
"""
|
||||
لغو و بازپرداخت فقط با دو action (از ۲۰۲۶-۱۰-۰۲)، نه با عوض کردنِ دستیِ وضعیت: «paid» را با دست
|
||||
«failed» کردن اعتبار یا اشتراکی را که سفارش داده بود پس نمیگرفت. برای همین وضعیت اینجا فقطخواندنی است.
|
||||
منطق در payment.services (cancel_pending_order، refund_order) است و تستهایش در payment/tests_refund.py.
|
||||
"""
|
||||
|
||||
list_display = ('id', 'user_id', 'plan_title', 'amount', 'currency', 'status', 'provider', 'created_at')
|
||||
list_filter = ('status', 'provider', 'currency')
|
||||
search_fields = ('=id', '=user_id')
|
||||
readonly_fields = ('status',)
|
||||
inlines = [TransactionInline]
|
||||
actions = ['cancel_pending', 'refund']
|
||||
|
||||
@admin.action(description='لغوِ سفارشِ در انتظار')
|
||||
def cancel_pending(self, request, queryset):
|
||||
from .services import OrderStateError, cancel_pending_order
|
||||
|
||||
done = 0
|
||||
for order in queryset:
|
||||
try:
|
||||
cancel_pending_order(order, staff=request.user.get_username())
|
||||
done += 1
|
||||
except OrderStateError as exc:
|
||||
self.message_user(request, str(exc), messages.WARNING)
|
||||
if done:
|
||||
self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS)
|
||||
|
||||
@admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداختشده)')
|
||||
def refund(self, request, queryset):
|
||||
from django.template.response import TemplateResponse
|
||||
|
||||
from .services import OrderStateError, refund_order
|
||||
|
||||
if request.POST.get('confirm') != 'yes':
|
||||
return TemplateResponse(request, 'admin/payment/order/refund_confirm.html', {
|
||||
**self.admin_site.each_context(request), 'orders': list(queryset), 'opts': self.model._meta,
|
||||
'title': 'تأییدِ بازپرداخت',
|
||||
})
|
||||
for order in queryset:
|
||||
try:
|
||||
r = refund_order(order, staff=request.user.get_username())
|
||||
except OrderStateError as exc:
|
||||
self.message_user(request, str(exc), messages.WARNING)
|
||||
continue
|
||||
if r['subscription_days']:
|
||||
what = f'{r["subscription_days"]} روز از اشتراک کم شد'
|
||||
else:
|
||||
what = f'{r["credits_taken"]} از {r["credits_granted"]} سوالِ بسته پس گرفته شد'
|
||||
if r['credits_taken'] < r['credits_granted']:
|
||||
what += f' (بقیه خرج شده بود)'
|
||||
if r['hearts_days']:
|
||||
what += f'، {r["hearts_days"]} روز قلبِ نامحدود کم شد'
|
||||
self.message_user(request, f'سفارشِ {order.id}: {what}. پول را از پنلِ درگاه برگردانید.', messages.SUCCESS)
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
|
||||
18
payment/migrations/0010_order_cancel_refund.py
Normal file
18
payment/migrations/0010_order_cancel_refund.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-02 19:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0009_order_invoice_snapshot'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت\u200cشده'), ('failed', 'ناموفق'), ('canceled', 'لغوشده'), ('refunded', 'بازپرداخت\u200cشده')], default='pending', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,11 @@ class Order(models.Model):
|
||||
تنها اپی که واقعاً پول جابهجا میکنه. عمداً از Subscription جدا شده
|
||||
چون منطق و ریسکش فرق داره (درگاه، retry، idempotency).
|
||||
"""
|
||||
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق')]
|
||||
# «لغوشده» و «بازپرداختشده» فقط از پنلِ ادمین (actionهای OrderAdmin) گذاشته میشوند، از ۲۰۲۶-۱۰-۰۲.
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق'),
|
||||
('canceled', 'لغوشده'), ('refunded', 'بازپرداختشده'),
|
||||
]
|
||||
|
||||
# 'gateway' و 'bazaar' منطق و صحتسنجیِ کاملاً متفاوتی دارن (webhook در
|
||||
# برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست
|
||||
|
||||
@@ -64,7 +64,7 @@ class OrderDetailResponseSerializer(serializers.Serializer):
|
||||
class InvoiceSerializer(serializers.Serializer):
|
||||
orderId = serializers.CharField()
|
||||
invoiceNumber = serializers.CharField(help_text='شمارهی فاکتور: «TD-» و شناسهی سفارش، شش رقمی.')
|
||||
status = serializers.ChoiceField(choices=['pending', 'paid', 'failed'])
|
||||
status = serializers.ChoiceField(choices=['pending', 'paid', 'failed', 'canceled', 'refunded'])
|
||||
provider = serializers.CharField(help_text='gateway (زرینپال) یا bazaar')
|
||||
planTitle = serializers.CharField(allow_blank=True)
|
||||
listPrice = serializers.IntegerField(allow_null=True, help_text='قیمتِ پایه پیش از تخفیف؛ null برای سفارشهای پیش از ۱۴۰۵-۰۷-۰۹.')
|
||||
|
||||
@@ -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
|
||||
|
||||
177
payment/tests_refund.py
Normal file
177
payment/tests_refund.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
لغو و بازپرداختِ سفارش از پنلِ ادمین (۲۰۲۶-۱۰-۰۲).
|
||||
|
||||
راههای شکستن که امتحان میشوند: بازپرداختِ دوبارهی یک سفارش، منفی شدنِ موجودیِ کسی که بخشی از
|
||||
بسته را خرج کرده، بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بیصفحهی تأیید)، و
|
||||
سفارشِ لغوشدهای که کاربرش واقعاً پول داده بود. خودِ برگرداندنِ پول در درگاه آزموده نمیشود چون این
|
||||
کد آن را انجام نمیدهد.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from affiliate.models import AffiliateAccount, Conversion
|
||||
from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription
|
||||
from subscription.services import credit_balance
|
||||
|
||||
from .models import Order, Transaction
|
||||
from .services import OrderStateError, apply_order_payment_result, cancel_pending_order, refund_order
|
||||
|
||||
|
||||
class _Base(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007401', is_guest=False)
|
||||
self.client_app = APIClient()
|
||||
self.client_app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
self.pack = Plan.objects.get(kind='pack', question_credits=20)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def paid_pack(self, ref='R1'):
|
||||
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
|
||||
order = Order.objects.get(pk=response.json()['orderId'])
|
||||
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref, new_status='paid', event='t')
|
||||
order.refresh_from_db()
|
||||
return order
|
||||
|
||||
def spend(self, n):
|
||||
for i in range(n):
|
||||
QuestionCredit.objects.create(user_id=self.user.id, delta=-1, reason='question', message_id=10_000 + i)
|
||||
|
||||
|
||||
class RefundServiceTests(_Base):
|
||||
def test_an_unused_pack_is_taken_back_whole_with_its_heart_days(self):
|
||||
order = self.paid_pack()
|
||||
summary = refund_order(order)
|
||||
self.assertEqual((summary['credits_granted'], summary['credits_taken']), (20, 20))
|
||||
self.assertEqual(credit_balance(self.user.id), 0)
|
||||
# ۷ روزِ همین بسته از کارتِ ۷روزه کم شد، پس قلبِ نامحدود همین الان تمام است.
|
||||
self.assertLessEqual(HeartsPass.objects.get(user_id=self.user.id).expires_at, timezone.now() + timedelta(seconds=5))
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'refunded')
|
||||
self.assertTrue(Transaction.objects.filter(order=order, status='refunded', gateway='admin').exists())
|
||||
|
||||
def test_what_was_already_spent_is_not_taken_twice_and_balance_never_goes_negative(self):
|
||||
order = self.paid_pack()
|
||||
self.spend(15)
|
||||
summary = refund_order(order)
|
||||
self.assertEqual(summary['credits_taken'], 5)
|
||||
self.assertEqual(credit_balance(self.user.id), 0)
|
||||
|
||||
def test_only_this_orders_credits_are_taken_not_a_second_packs(self):
|
||||
first = self.paid_pack(ref='R1')
|
||||
self.paid_pack(ref='R2')
|
||||
refund_order(first)
|
||||
self.assertEqual(credit_balance(self.user.id), 20)
|
||||
|
||||
def test_a_second_refund_of_the_same_order_is_refused(self):
|
||||
order = self.paid_pack()
|
||||
refund_order(order)
|
||||
with self.assertRaises(OrderStateError):
|
||||
refund_order(order)
|
||||
self.assertEqual(credit_balance(self.user.id), 0)
|
||||
self.assertEqual(QuestionCredit.objects.filter(reason='refund').count(), 1)
|
||||
|
||||
def test_an_unpaid_order_cannot_be_refunded(self):
|
||||
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='u1')
|
||||
with self.assertRaises(OrderStateError):
|
||||
refund_order(order)
|
||||
|
||||
def test_a_subscription_refund_takes_its_days_off(self):
|
||||
plan = Plan.objects.filter(kind='subscription').first()
|
||||
sub = Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
|
||||
expires_at=timezone.now() + timedelta(days=plan.duration_days + 10))
|
||||
order = Order.objects.create(user_id=self.user.id, amount=plan.price, subscription_id=sub.id,
|
||||
plan_id=plan.id, status='paid', idempotency_key='s1')
|
||||
refund_order(order)
|
||||
sub.refresh_from_db()
|
||||
self.assertAlmostEqual((sub.expires_at - timezone.now()).days, 9, delta=1)
|
||||
self.assertEqual(sub.status, 'active')
|
||||
|
||||
def test_a_subscription_with_nothing_left_is_canceled(self):
|
||||
plan = Plan.objects.filter(kind='subscription').first()
|
||||
sub = Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
|
||||
expires_at=timezone.now() + timedelta(days=3))
|
||||
order = Order.objects.create(user_id=self.user.id, amount=plan.price, subscription_id=sub.id,
|
||||
plan_id=plan.id, status='paid', idempotency_key='s2')
|
||||
refund_order(order)
|
||||
sub.refresh_from_db()
|
||||
self.assertEqual(sub.status, 'canceled')
|
||||
|
||||
def test_the_referrers_commission_for_this_order_is_undone(self):
|
||||
referrer = User.objects.create(phone='09120007402', is_guest=False)
|
||||
account = AffiliateAccount.objects.create(user_id=referrer.id, referral_code='REF1', commission_rate=0.1)
|
||||
Conversion.objects.create(affiliate_account=account, referred_user_id=self.user.id)
|
||||
order = self.paid_pack()
|
||||
self.assertEqual(Conversion.objects.get().status, 'paid')
|
||||
refund_order(order)
|
||||
conversion = Conversion.objects.get()
|
||||
self.assertEqual((conversion.status, conversion.commission_amount, conversion.order_id), ('pending', 0, None))
|
||||
|
||||
def test_the_invoice_shows_the_refund(self):
|
||||
refund_order(self.paid_pack())
|
||||
invoice = self.client_app.get('/api/payment/invoices').json()['invoices'][0]
|
||||
self.assertEqual(invoice['status'], 'refunded')
|
||||
|
||||
|
||||
class CancelServiceTests(_Base):
|
||||
def test_a_pending_order_is_canceled_and_a_paid_one_is_not(self):
|
||||
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='c1')
|
||||
cancel_pending_order(order)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'canceled')
|
||||
with self.assertRaises(OrderStateError):
|
||||
cancel_pending_order(self.paid_pack())
|
||||
|
||||
def test_if_the_user_did_pay_a_canceled_order_the_payment_still_counts(self):
|
||||
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
|
||||
order = Order.objects.get(pk=response.json()['orderId'])
|
||||
cancel_pending_order(order)
|
||||
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='LATE', new_status='paid', event='t')
|
||||
self.assertEqual(credit_balance(self.user.id), 20)
|
||||
|
||||
|
||||
class OrderAdminTests(_Base):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only')
|
||||
self.admin = self.client
|
||||
self.admin.force_login(staff)
|
||||
|
||||
def action(self, name, order, **extra):
|
||||
return self.admin.post('/admin/payment/order/', {'action': name, '_selected_action': [order.pk], **extra})
|
||||
|
||||
def test_refund_asks_for_confirmation_first(self):
|
||||
order = self.paid_pack()
|
||||
page = self.action('refund', order)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertContains(page, 'پول را برنمیگرداند')
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'paid')
|
||||
self.assertEqual(credit_balance(self.user.id), 20)
|
||||
|
||||
done = self.action('refund', order, confirm='yes')
|
||||
self.assertEqual(done.status_code, 302)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'refunded')
|
||||
self.assertEqual(credit_balance(self.user.id), 0)
|
||||
|
||||
def test_cancel_action(self):
|
||||
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='c2')
|
||||
self.assertEqual(self.action('cancel_pending', order).status_code, 302)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, 'canceled')
|
||||
|
||||
def test_status_cannot_be_edited_by_hand(self):
|
||||
order = self.paid_pack()
|
||||
page = self.admin.get(f'/admin/payment/order/{order.pk}/change/')
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertNotContains(page, 'name="status"')
|
||||
Reference in New Issue
Block a user