- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind / question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the company owner's test account (user 26). - Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4). - Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType, verify refuses an order without a subscription. A paid order with no subscription now logs ERROR. - Removed POST /api/payment/orders (internal; no caller). Its permission tests stay. - Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted. - docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current. 949 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
9.4 KiB
Python
193 lines
9.4 KiB
Python
"""
|
||
لغو و بازپرداختِ سفارش از پنلِ ادمین (۲۰۲۶-۱۰-۰۲).
|
||
|
||
راههای شکستن که امتحان میشوند: بازپرداختِ دوبارهی یک سفارش، کم شدنِ روزهای خریدِ دیگر،
|
||
بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بیصفحهی تأیید)، و
|
||
سفارشِ لغوشدهای که کاربرش واقعاً پول داده بود. خودِ برگرداندنِ پول در درگاه آزموده نمیشود چون این
|
||
کد آن را انجام نمیدهد.
|
||
"""
|
||
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 Plan, Subscription
|
||
from subscription.services import has_active_subscription
|
||
|
||
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')
|
||
self.client_app = APIClient()
|
||
self.client_app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||
self.plan = Plan.objects.get(on_sale=True, duration_days=90)
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def paid_subscription(self, ref='R1'):
|
||
response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json',
|
||
HTTP_IDEMPOTENCY_KEY=f'key-{ref}')
|
||
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 days_left(self):
|
||
sub = Subscription.objects.get(user_id=self.user.id)
|
||
return (sub.expires_at - timezone.now()).days
|
||
|
||
|
||
class RefundServiceTests(_Base):
|
||
def test_a_fresh_subscription_is_taken_back_whole(self):
|
||
order = self.paid_subscription()
|
||
summary = refund_order(order)
|
||
self.assertEqual(summary['subscription_days'], 90)
|
||
self.assertFalse(has_active_subscription(self.user.id))
|
||
self.assertEqual(Subscription.objects.get(user_id=self.user.id).status, 'canceled')
|
||
order.refresh_from_db()
|
||
self.assertEqual(order.status, 'refunded')
|
||
self.assertTrue(Transaction.objects.filter(order=order, status='refunded', gateway='admin').exists())
|
||
|
||
def test_only_this_orders_days_are_taken_not_a_second_purchases(self):
|
||
first = self.paid_subscription(ref='R1')
|
||
self.paid_subscription(ref='R2')
|
||
self.assertAlmostEqual(self.days_left(), 179, delta=1)
|
||
refund_order(first)
|
||
self.assertAlmostEqual(self.days_left(), 89, delta=1)
|
||
self.assertTrue(has_active_subscription(self.user.id))
|
||
|
||
def test_a_second_refund_of_the_same_order_is_refused(self):
|
||
order = self.paid_subscription()
|
||
refund_order(order)
|
||
with self.assertRaises(OrderStateError):
|
||
refund_order(order)
|
||
self.assertEqual(Transaction.objects.filter(order=order, status='refunded').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().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().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')
|
||
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_subscription()
|
||
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_subscription())
|
||
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_subscription())
|
||
|
||
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.plan.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.assertTrue(has_active_subscription(self.user.id))
|
||
|
||
|
||
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_subscription()
|
||
page = self.action('refund', order)
|
||
self.assertEqual(page.status_code, 200)
|
||
self.assertContains(page, 'پول را برنمیگرداند')
|
||
order.refresh_from_db()
|
||
self.assertEqual(order.status, 'paid')
|
||
self.assertTrue(has_active_subscription(self.user.id))
|
||
|
||
done = self.action('refund', order, confirm='yes')
|
||
self.assertEqual(done.status_code, 302)
|
||
order.refresh_from_db()
|
||
self.assertEqual(order.status, 'refunded')
|
||
self.assertFalse(has_active_subscription(self.user.id))
|
||
|
||
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_subscription()
|
||
page = self.admin.get(f'/admin/payment/order/{order.pk}/change/')
|
||
self.assertEqual(page.status_code, 200)
|
||
self.assertNotContains(page, 'name="status"')
|
||
|
||
|
||
class ForgedFailureCallbackTests(TestCase):
|
||
"""
|
||
کالبکِ زرینپال بیاحراز است. Status=NOKِ جعلی نباید سفارشِ بازپرداختشده یا لغوشده را
|
||
«ناموفق» کند (تا ۲۰۲۶-۱۰-۰۳ میکرد و ردِ بازپرداخت از وضعیتِ سفارش میرفت).
|
||
"""
|
||
|
||
def test_nok_does_not_overwrite_refunded_or_canceled(self):
|
||
for status in ('refunded', 'canceled'):
|
||
order = Order.objects.create(user_id=1, amount=1000, status=status,
|
||
gateway_authority=f'A-{status}', idempotency_key=f'k-{status}')
|
||
APIClient().get('/api/payment/zarinpal/callback', {'Authority': f'A-{status}', 'Status': 'NOK'})
|
||
order.refresh_from_db()
|
||
self.assertEqual(order.status, status)
|
||
|
||
def test_nok_still_fails_a_pending_order(self):
|
||
order = Order.objects.create(user_id=1, amount=1000, gateway_authority='A-p', idempotency_key='k-p')
|
||
APIClient().get('/api/payment/zarinpal/callback', {'Authority': 'A-p', 'Status': 'NOK'})
|
||
order.refresh_from_db()
|
||
self.assertEqual(order.status, 'failed')
|