Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only

- 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>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -1,8 +1,8 @@
"""
لغو و بازپرداختِ سفارش از پنلِ ادمین (۲۰۲۶-۱۰-۰۲).
راه‌های شکستن که امتحان می‌شوند: بازپرداختِ دوباره‌ی یک سفارش، منفی شدنِ موجودیِ کسی که بخشی از
بسته را خرج کرده، بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بی‌صفحه‌ی تأیید)، و
راه‌های شکستن که امتحان می‌شوند: بازپرداختِ دوباره‌ی یک سفارش، کم شدنِ روزهای خریدِ دیگر،
بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بی‌صفحه‌ی تأیید)، و
سفارشِ لغوشده‌ای که کاربرش واقعاً پول داده بود. خودِ برگرداندنِ پول در درگاه آزموده نمی‌شود چون این
کد آن را انجام نمی‌دهد.
"""
@@ -17,8 +17,8 @@ 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 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
@@ -26,60 +26,53 @@ from .services import OrderStateError, apply_order_payment_result, cancel_pendin
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
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.pack = Plan.objects.get(kind='pack', question_credits=20)
self.plan = Plan.objects.get(on_sale=True, duration_days=90)
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')
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 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)
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_an_unused_pack_is_taken_back_whole_with_its_heart_days(self):
order = self.paid_pack()
def test_a_fresh_subscription_is_taken_back_whole(self):
order = self.paid_subscription()
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))
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_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')
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.assertEqual(credit_balance(self.user.id), 20)
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_pack()
order = self.paid_subscription()
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)
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')
@@ -87,7 +80,7 @@ class RefundServiceTests(_Base):
refund_order(order)
def test_a_subscription_refund_takes_its_days_off(self):
plan = Plan.objects.filter(kind='subscription').first()
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,
@@ -98,7 +91,7 @@ class RefundServiceTests(_Base):
self.assertEqual(sub.status, 'active')
def test_a_subscription_with_nothing_left_is_canceled(self):
plan = Plan.objects.filter(kind='subscription').first()
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,
@@ -111,14 +104,14 @@ class RefundServiceTests(_Base):
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_pack()
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_pack())
refund_order(self.paid_subscription())
invoice = self.client_app.get('/api/payment/invoices').json()['invoices'][0]
self.assertEqual(invoice['status'], 'refunded')
@@ -130,19 +123,18 @@ class CancelServiceTests(_Base):
order.refresh_from_db()
self.assertEqual(order.status, 'canceled')
with self.assertRaises(OrderStateError):
cancel_pending_order(self.paid_pack())
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.pack.id)}, format='json')
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.assertEqual(credit_balance(self.user.id), 20)
self.assertTrue(has_active_subscription(self.user.id))
class OrderAdminTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only')
self.admin = self.client
@@ -152,19 +144,19 @@ class OrderAdminTests(_Base):
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()
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.assertEqual(credit_balance(self.user.id), 20)
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.assertEqual(credit_balance(self.user.id), 0)
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')
@@ -173,7 +165,7 @@ class OrderAdminTests(_Base):
self.assertEqual(order.status, 'canceled')
def test_status_cannot_be_edited_by_hand(self):
order = self.paid_pack()
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"')