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

@@ -177,6 +177,26 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
مرحله‌ی ۳ را فقط یک پرداختِ واقعی ثابت می‌کند؛ تا آن انجام نشده، پرداخت را
«کار می‌کند» ننویسید.
## لغو و بازپرداختِ سفارش (از پنلِ ادمین)
از ۲۰۲۶-۱۰-۰۲. همه از `/admin/payment/order/` انجام می‌شود و SSH لازم نیست:
1. سفارش را با شماره‌اش یا شناسه‌ی کاربر (`user_id`) پیدا کنید. شماره‌ی فاکتورِ `TD-000123` یعنی سفارشِ ۱۲۳.
2. تیکش را بزنید و از فهرستِ «Action» یکی را انتخاب کنید:
- **«لغوِ سفارشِ در انتظار»**: برای سفارشی که هنوز پرداخت نشده. پولی جابه‌جا نشده است. اگر کاربر
همان لحظه در درگاه پرداخت کرده باشد، تأییدِ زرین‌پال سفارش را دوباره «پرداخت‌شده» می‌کند و او چیزش را می‌گیرد.
- **«بازپرداخت»**: برای سفارشِ پرداخت‌شده. اول یک صفحه‌ی تأیید می‌آید. بعد از تأیید، سفارش
«بازپرداخت‌شده» می‌شود و هر چه داده بود تا جایی که هنوز مانده پس گرفته می‌شود: سوال‌های
خرج‌نشده‌ی همان بسته (موجودی منفی نمی‌شود)، روزهای قلبِ نامحدودِ همان بسته، یا روزهای همان اشتراک.
پورسانتِ معرف هم برمی‌گردد. پیامِ بالای صفحه می‌گوید دقیقاً چه چیزی پس گرفته شد.
3. **خودِ پول را این دکمه برنمی‌گرداند.** پول را در پنلِ زرین‌پال برگردانید. خریدِ کافه‌بازار هم از
سمتِ خودِ بازار برگردانده می‌شود.
وضعیتِ سفارش در فرمِ ادمین فقط‌خواندنی است. اگر وضعیت را با دست از «پرداخت‌شده» به «ناموفق» عوض
می‌کردید، اعتبار و اشتراکی که سفارش داده بود سرِ جایش می‌ماند. کاربر بازپرداخت را در صفحه‌ی
«پرداخت‌ها و فاکتورها» می‌بیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
است و تست‌هایش در `payment/tests_refund.py`.
## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچ‌کس نمی‌تواند با شماره

View File

@@ -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)

View 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),
),
]

View File

@@ -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 جداست

View File

@@ -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 برای سفارش‌های پیش از ۱۴۰۵-۰۷-۰۹.')

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

177
payment/tests_refund.py Normal file
View 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"')

View 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 = [
('subscription', '0010_seed_question_packs'),
]
operations = [
migrations.AlterField(
model_name='questioncredit',
name='reason',
field=models.CharField(choices=[('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیه\u200cی ادمین'), ('refund', 'بازپرداخت')], max_length=10),
),
]

View File

@@ -86,7 +86,8 @@ class QuestionCredit(models.Model):
ردیفِ ۱− فقط وقتی پاک می‌شود که خودِ سوال پس گرفته شود: جوابِ ناموفق یا جوابی که از کش آمد و
به API نرسید. اعتبار منقضی نمی‌شود (تصمیمِ صاحبِ پروژه).
"""
REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیه‌ی ادمین')]
# «refund»: ردیفِ منفیِ بازپرداختِ سفارش (payment.services.refund_order)؛ فقط اعتبارِ خرج‌نشده را پس می‌گیرد.
REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیه‌ی ادمین'), ('refund', 'بازپرداخت')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
delta = models.IntegerField()

View File

@@ -0,0 +1,25 @@
{% extends "admin/base_site.html" %}
{% comment %}صفحه‌ی تأییدِ بازپرداخت (payment/admin.py → OrderAdmin.refund). بی‌این صفحه یک کلیکِ اشتباه اعتبارِ کاربر را پس می‌گرفت.{% endcomment %}
{% block content %}
<div dir="rtl" style="max-width:720px">
<h1>بازپرداختِ {{ orders|length }} سفارش</h1>
<p><b>این کار پول را برنمی‌گرداند.</b> پول را جدا از پنلِ زرین‌پال (یا کافه‌بازار) برگردانید. این‌جا فقط چیزی که
سفارش داده بود پس گرفته می‌شود: اعتبارِ خرج‌نشده‌ی بسته، روزهای قلبِ نامحدود، یا روزهای اشتراک.</p>
<table>
<thead><tr><th>سفارش</th><th>کاربر</th><th>پلن</th><th>مبلغ</th><th>درگاه</th><th>تاریخ</th></tr></thead>
<tbody>
{% for o in orders %}
<tr><td>{{ o.id }}</td><td>{{ o.user_id }}</td><td>{{ o.plan_title|default:"—" }}</td>
<td>{{ o.amount }} {{ o.currency }}</td><td>{{ o.get_provider_display }}</td><td>{{ o.created_at|date:"Y-m-d H:i" }}</td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" style="margin-top:16px">{% csrf_token %}
{% for o in orders %}<input type="hidden" name="_selected_action" value="{{ o.pk }}">{% endfor %}
<input type="hidden" name="action" value="refund">
<input type="hidden" name="confirm" value="yes">
<input type="submit" value="بله، بازپرداخت ثبت شود" class="default" style="background:#ba2121">
<a href="" class="button cancel-link" style="margin-right:8px">انصراف</a>
</form>
</div>
{% endblock %}