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

@@ -13,8 +13,10 @@ from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from discount.models import DiscountCode
from subscription.models import Plan
from subscription.services import credit_balance
from django.utils import timezone
from subscription.models import Plan, Subscription
from subscription.services import has_active_subscription
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
@@ -29,25 +31,27 @@ def client_for(user):
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='09120009901')
self.app = client_for(self.user)
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 pack_order(self, **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json')
def sub_order(self, key='sec-k', **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id), **extra}, format='json',
HTTP_IDEMPOTENCY_KEY=key)
return Order.objects.get(pk=r.json()['orderId'])
def days_left(self):
return (Subscription.objects.get(user_id=self.user.id).expires_at - timezone.now()).days
class SupervisorCannotRunMoneyActionsTests(_Base):
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
call_command('setup_supervisor_group', verbosity=0)
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
@@ -55,12 +59,12 @@ class SupervisorCannotRunMoneyActionsTests(_Base):
self.client.force_login(staff)
def test_refund_is_refused(self):
order = self.pack_order()
order = self.sub_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t')
self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'})
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))
def test_cancel_is_refused(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1')
@@ -72,7 +76,7 @@ class SupervisorCannotRunMoneyActionsTests(_Base):
class CallbackWithoutAuthorityTests(_Base):
def test_missing_authority_is_400_and_touches_no_order(self):
DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10)
order = self.pack_order()
order = self.sub_order()
self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)},
format='json').status_code, 200)
Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده
@@ -85,23 +89,21 @@ class CallbackWithoutAuthorityTests(_Base):
class PlanEditedBetweenOrderAndPaymentTests(_Base):
def test_buyer_gets_what_the_order_sold(self):
order = self.pack_order()
Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60)
order = self.sub_order()
Plan.objects.filter(pk=self.plan.pk).update(price=order.amount * 5, duration_days=365)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertEqual(order.question_credits, 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
self.assertEqual(order.duration_days, 90)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
def test_second_token_is_409_and_stays_usable(self):
order = self.pack_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True, purchase_state=0)
order = self.sub_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True)
with patch('payment.bazaar.verify_purchase', return_value=valid):
first = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
@@ -114,13 +116,13 @@ class BazaarSecondTokenOnPaidOrderTests(_Base):
self.assertEqual(second.status_code, 409)
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists())
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'},
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='sec-new')
with patch('payment.bazaar.verify_purchase', return_value=valid):
fresh = self.app.post('/api/payment/bazaar/verify',
{'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(fresh.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 40)
self.assertAlmostEqual(self.days_left(), 179, delta=1)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
@@ -128,11 +130,8 @@ class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت می‌کرد و قیدِ یکتا همان خرید را برای همیشه می‌سوزاند."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-p20')
self.big = Plan.objects.filter(kind='pack').exclude(pk=self.pack.pk).order_by('-price').first()
Plan.objects.filter(pk=self.big.pk).update(bazaar_sku='sec-pbig')
self.big = Plan.objects.get(on_sale=True, duration_days=180)
def verify(self, order_id, token, result):
with patch('payment.bazaar.verify_purchase', return_value=result):
@@ -140,39 +139,38 @@ class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
format='json')
def test_same_order_after_a_rejection(self):
order = self.pack_order(paymentMethod='bazaar')
order = self.sub_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=False, raw={'error': 'not_found'}))
r = self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=True, purchase_state=0))
r = self.verify(order.id, 'TOK-A', BazaarPurchaseResult(is_valid=True))
self.assertEqual(r.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
def test_token_sent_with_the_wrong_order_still_pays_the_right_one(self):
small = self.pack_order(paymentMethod='bazaar')
small = self.sub_order(paymentMethod='bazaar')
big = self.app.post('/api/subscription/purchase', {'planId': str(self.big.id), 'paymentMethod': 'bazaar'},
format='json').json()['orderId']
format='json', HTTP_IDEMPOTENCY_KEY='sec-big').json()['orderId']
self.verify(big, 'TOK-S', BazaarPurchaseResult(is_valid=False, raw={'error': 'not_found'}))
r = self.verify(small.id, 'TOK-S', BazaarPurchaseResult(is_valid=True, purchase_state=0))
r = self.verify(small.id, 'TOK-S', BazaarPurchaseResult(is_valid=True))
self.assertEqual(r.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertAlmostEqual(self.days_left(), 89, delta=1)
self.assertEqual(Order.objects.get(pk=big).status, 'failed')
def test_refunded_order_refuses_a_new_token(self):
from .services import refund_order
order = self.pack_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-1', BazaarPurchaseResult(is_valid=True, purchase_state=0))
order = self.sub_order(paymentMethod='bazaar')
self.verify(order.id, 'TOK-1', BazaarPurchaseResult(is_valid=True))
refund_order(Order.objects.get(pk=order.pk))
r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True, purchase_state=0))
r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True))
self.assertEqual(r.status_code, 409)
self.assertEqual(Order.objects.get(pk=order.pk).status, 'refunded')
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOK-2').exists())
class OrderAdminMoneyFieldsReadOnlyTests(_Base):
"""اجازه‌ی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و تعدادِ سوالِ سفارش ویرایش‌شدنی بود."""
"""اجازه‌ی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و مدتِ سفارش ویرایش‌شدنی بود."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
from django.contrib.auth.models import Permission
@@ -185,19 +183,19 @@ class OrderAdminMoneyFieldsReadOnlyTests(_Base):
staff.groups.add(group)
self.client.force_login(staff)
def test_amount_and_credits_cannot_be_edited(self):
order = self.pack_order()
data = {'amount': 1000, 'question_credits': 100000, 'user_id': order.user_id,
def test_amount_and_duration_cannot_be_edited(self):
order = self.sub_order()
data = {'amount': 1000, 'duration_days': 100000, 'user_id': order.user_id,
'transactions-TOTAL_FORMS': 0, 'transactions-INITIAL_FORMS': 0,
'transactions-MIN_NUM_FORMS': 0, 'transactions-MAX_NUM_FORMS': 1000}
self.client.post(f'/admin/payment/order/{order.pk}/change/', data)
order.refresh_from_db()
self.assertEqual((order.amount, order.question_credits), (self.pack.price, 20))
self.assertEqual((order.amount, order.duration_days), (self.plan.price, 90))
page = self.client.get(f'/admin/payment/order/{order.pk}/change/')
self.assertNotContains(page, 'name="amount"')
def test_orders_and_transactions_are_not_deleted_or_added_from_the_panel(self):
order = self.pack_order()
order = self.sub_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='DEL-1', new_status='paid', event='t')
self.client.post(f'/admin/payment/order/{order.pk}/delete/', {'post': 'yes'})
txn = Transaction.objects.get(gateway_ref_id='DEL-1')
@@ -215,7 +213,7 @@ class DiscountDuringPaymentRequestTests(_Base):
from .zarinpal import ZarinpalRequestResult
DiscountCode.objects.create(code='SECHALF', type='percent', value=50, max_uses=5)
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
r = self.app.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
order_id, pay_url = r.json()['orderId'], r.json()['paymentUrl']
requested = []
@@ -229,8 +227,8 @@ class DiscountDuringPaymentRequestTests(_Base):
resp = APIClient().get('/api/' + pay_url.split('/api/', 1)[1])
order = Order.objects.get(pk=order_id)
self.assertEqual(resp.status_code, 302)
self.assertEqual(order.amount, self.pack.price // 2)
self.assertEqual(requested, [self.pack.price, self.pack.price // 2])
self.assertEqual(order.amount, self.plan.price // 2)
self.assertEqual(requested, [self.plan.price, self.plan.price // 2])
self.assertEqual(order.gateway_authority, 'A-2')