Files
MathApp/payment/tests_refund.py
Zareian d7d168e82f 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>
2026-10-02 22:37:30 +03:30

178 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
لغو و بازپرداختِ سفارش از پنلِ ادمین (۲۰۲۶-۱۰-۰۲).
راه‌های شکستن که امتحان می‌شوند: بازپرداختِ دوباره‌ی یک سفارش، منفی شدنِ موجودیِ کسی که بخشی از
بسته را خرج کرده، بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بی‌صفحه‌ی تأیید)، و
سفارشِ لغوشده‌ای که کاربرش واقعاً پول داده بود. خودِ برگرداندنِ پول در درگاه آزموده نمی‌شود چون این
کد آن را انجام نمی‌دهد.
"""
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"')