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:
@@ -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"')
|
||||
|
||||
Reference in New Issue
Block a user