From fedd10ff22239820aaf6858ef67c92f1666cdc59 Mon Sep 17 00:00:00 2001 From: Zareian Date: Mon, 5 Oct 2026 09:26:26 +0330 Subject: [PATCH] Money review: Bazaar rejection no longer burns the purchase token, read-only order admin, pack grant from order snapshot - Bazaar verify: a rejected token is not stored on the order or the failed transaction, so the same purchase can still pay the right order; a refunded order refuses a new token (409). - OrderAdmin: every field read-only, no add/delete; Transaction admin read-only. - grant_pack uses the order's snapshot when the plan was deleted, logs ERROR when nothing is granted. - Pay link: authority is stored only if the order amount did not change during PaymentRequest. - PlanAdmin accepts only IRT (the app always renders toman). - reconcile_pending_order logs unexpected exceptions. Co-Authored-By: Claude Opus 5.5 --- payment/admin.py | 34 +++++++- payment/services.py | 9 ++- payment/tests_security_review.py | 109 ++++++++++++++++++++++++++ payment/views.py | 45 +++++++---- subscription/admin.py | 16 ++++ subscription/services.py | 21 +++-- subscription/tests_security_review.py | 56 +++++++++++++ 7 files changed, 268 insertions(+), 22 deletions(-) create mode 100644 subscription/tests_security_review.py diff --git a/payment/admin.py b/payment/admin.py index 3908f19..fcfec5a 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -8,6 +8,13 @@ from .models import BazaarCredential, Order, Transaction class TransactionInline(admin.TabularInline): model = Transaction extra = 0 + can_delete = False + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False @admin.register(Order) @@ -21,10 +28,23 @@ class OrderAdmin(admin.ModelAdmin): list_display = ('id', 'user_id', 'plan_title', 'amount', 'currency', 'status', 'provider', 'created_at') list_filter = ('status', 'provider', 'currency') search_fields = ('=id', '=user_id') - readonly_fields = ('status',) inlines = [TransactionInline] actions = ['cancel_pending', 'refund'] + # همه‌ی ستون‌ها فقط‌خواندنی‌اند، نه فقط وضعیت: اجازه‌ی change برای دو action لازم است، و تا امروز + # همان اجازه فرمِ سفارش را هم باز می‌کرد. کارمندی که فقط باید لغو/بازپرداخت کند، روی سفارشِ در + # انتظارِ خودش amount را ۱۰۰۰ و question_credits را ۱۰۰٬۰۰۰ کرد و با ۱۰۰۰ تومان ۱۰۰٬۰۰۰ سوال گرفت + # (بازتولید شد). سفارش سندِ مالی است: از پنل ساخته یا پاک هم نمی‌شود؛ پاک شدنِ سفارشِ پرداخت‌شده + # تراکنش‌هایش را می‌برد و توکنِ خریدش برای سفارشِ دیگری دوباره پذیرفته می‌شد. + def get_readonly_fields(self, request, obj=None): + return [f.name for f in self.model._meta.fields] + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + @admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change']) def cancel_pending(self, request, queryset): from .services import OrderStateError, cancel_pending_order @@ -69,9 +89,21 @@ class OrderAdmin(admin.ModelAdmin): @admin.register(Transaction) class TransactionAdmin(admin.ModelAdmin): + """فقط برای دیدن؛ تراکنش را فقط تأییدِ درگاه و actionهای سفارش می‌سازند (قیدِ یکتای gateway_ref_id + جلوی تکرارِ توکنِ خرید است و ویرایش یا حذفِ دستیِ ردیف آن را برمی‌داشت).""" + list_display = ('id', 'order', 'gateway', 'status', 'paid_at') list_filter = ('gateway', 'status') + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + @admin.register(BazaarCredential) class BazaarCredentialAdmin(admin.ModelAdmin): diff --git a/payment/services.py b/payment/services.py index 25aba1d..01b8af9 100644 --- a/payment/services.py +++ b/payment/services.py @@ -4,6 +4,8 @@ جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم پیاده‌سازی شده، نه یک HTTP call به خودمون. """ +import logging + from django.conf import settings from django.core import signing from django.db import IntegrityError @@ -16,6 +18,8 @@ from statuslog.signals import status_changed from .models import Order, Transaction +logger = logging.getLogger(__name__) + class IdempotencyKeyConflict(APIException): """اگر Idempotency-Key موجود متعلق به کاربر یا پارامترهای دیگری باشد، 409 برمی‌گرداند.""" @@ -255,7 +259,10 @@ def reconcile_pending_order(order: Order) -> bool: result = zarinpal.verify_payment( authority=order.gateway_authority, amount=order.amount, currency=order.currency, ) - except Exception: # noqa: BLE001 — درگاه در دسترس نیست؛ دفعه‌ی بعد + except zarinpal.ZarinpalAPIError: # درگاه در دسترس نیست (خودِ zarinpal لاگ کرده)؛ دفعه‌ی بعد + return False + except Exception: # noqa: BLE001 — صفحه‌ی وضعیت نباید با خطای بازسنجی ۵۰۰ بدهد؛ ولی دیده شود + logger.exception('reconcile: بازسنجیِ سفارشِ %s شکست خورد', order.id) return False if not result.is_valid: return False diff --git a/payment/tests_security_review.py b/payment/tests_security_review.py index 0407ea2..48ca4d9 100644 --- a/payment/tests_security_review.py +++ b/payment/tests_security_review.py @@ -118,3 +118,112 @@ class BazaarSecondTokenOnPaidOrderTests(_Base): {'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json') self.assertEqual(fresh.json()['status'], 'paid') self.assertEqual(credit_balance(self.user.id), 40) + + +@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example') +class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base): + """ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت می‌کرد و قیدِ یکتا همان خرید را برای همیشه می‌سوزاند.""" + + def setUp(self): + 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') + + def verify(self, order_id, token, result): + with patch('payment.bazaar.verify_purchase', return_value=result): + return self.app.post('/api/payment/bazaar/verify', {'orderId': str(order_id), 'purchaseToken': token}, + format='json') + + def test_same_order_after_a_rejection(self): + order = self.pack_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)) + self.assertEqual(r.json()['status'], 'paid') + self.assertEqual(credit_balance(self.user.id), 20) + + def test_token_sent_with_the_wrong_order_still_pays_the_right_one(self): + small = self.pack_order(paymentMethod='bazaar') + big = self.app.post('/api/subscription/purchase', {'planId': str(self.big.id), 'paymentMethod': 'bazaar'}, + format='json').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)) + self.assertEqual(r.json()['status'], 'paid') + self.assertEqual(credit_balance(self.user.id), 20) + 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)) + refund_order(Order.objects.get(pk=order.pk)) + r = self.verify(order.id, 'TOK-2', BazaarPurchaseResult(is_valid=True, purchase_state=0)) + 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 (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و تعدادِ سوالِ سفارش ویرایش‌شدنی بود.""" + + def setUp(self): + super().setUp() + from django.contrib.auth.models import Permission + + staff = get_user_model().objects.create_user('ops', 'ops@example.com', 'pw-for-tests-only', is_staff=True) + group = Group.objects.create(name='ops-sec') + group.permissions.set(Permission.objects.filter( + content_type__app_label='payment', + codename__in=['view_order', 'change_order', 'delete_order', 'add_order', + 'view_transaction', 'change_transaction', 'delete_transaction', 'add_transaction'])) + 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, + '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)) + 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() + 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') + self.client.post(f'/admin/payment/transaction/{txn.pk}/delete/', {'post': 'yes'}) + self.assertTrue(Order.objects.filter(pk=order.pk).exists()) + self.assertTrue(Transaction.objects.filter(pk=txn.pk).exists()) + self.assertEqual(self.client.get('/admin/payment/order/add/').status_code, 403) + self.assertEqual(self.client.get('/admin/payment/transaction/add/').status_code, 403) + + +class DiscountDuringPaymentRequestTests(_Base): + """کدِ تخفیفی که وسطِ PaymentRequest ثبت شود، authority را با مبلغِ قبلی روی سفارشِ ارزان‌تر می‌نشاند.""" + + def test_authority_matches_the_final_amount(self): + 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') + order_id, pay_url = r.json()['orderId'], r.json()['paymentUrl'] + requested = [] + + def fake_request(order): + requested.append(order.amount) + if len(requested) == 1: # کاربر در تبِ دیگر همان لحظه کد را می‌زند + self.app.post('/api/discount/redeem', {'code': 'SECHALF', 'orderId': order_id}, format='json') + return ZarinpalRequestResult(authority=f'A-{len(requested)}', payment_url='x') + + with patch('payment.zarinpal.request_payment', side_effect=fake_request): + 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.gateway_authority, 'A-2') diff --git a/payment/views.py b/payment/views.py index 383ab59..93c5741 100644 --- a/payment/views.py +++ b/payment/views.py @@ -1,3 +1,4 @@ +import logging from urllib.parse import urlsplit from django.conf import settings @@ -28,6 +29,8 @@ from .serializers import ( ) from .services import apply_order_payment_result, create_order, pay_link_is_valid, reconcile_pending_order +logger = logging.getLogger(__name__) + def _redirect_to_configured_url(url: str) -> HttpResponseRedirect: """ @@ -178,24 +181,32 @@ class PaymentRedirectView(APIView): raise Http404 order = get_object_or_404(Order, pk=order_id, provider='gateway') + # authority فقط وقتی ذخیره می‌شود که هنوز خالی است (درخواستِ هم‌زمانِ دیگری زودتر نساخته باشد) + # و مبلغِ سفارش همانی است که به زرین‌پال گفته شد. کدِ تخفیفی که وسطِ PaymentRequest ثبت شود + # مبلغ را کم می‌کرد و authority با مبلغِ قبلی می‌نشست: کاربر مبلغِ کامل را می‌پرداخت و verify با + # مبلغِ تازه رد می‌شد (بازتولید شد). در آن حالت یک بار دیگر با مبلغِ تازه درخواست می‌شود. + for _attempt in range(2): + if order.status != 'pending' or order.gateway_authority: + break + requested_amount = order.amount + result = zarinpal.request_payment(order) + Order.objects.filter(pk=order.pk, gateway_authority='', amount=requested_amount).update( + gateway_authority=result.authority, + ) + order.refresh_from_db(fields=['gateway_authority', 'amount', 'status']) + if order.status != 'pending': - # سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست. + # سفارش از قبل نتیجه گرفته (paid/failed، یا کدِ ۱۰۰٪ همین الان)؛ چیزی برای پرداختِ تازه نیست. # کاربر به اپ برمی‌گردد با همان نتیجه، نه به ریشه‌ی دامنه‌ی API. if settings.ZARINPAL_REDIRECT_URL: return _redirect_to_configured_url( f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}' ) return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/') - if not order.gateway_authority: - result = zarinpal.request_payment(order) - # ⚠️ محافظت در برابرِ race: اگه بینِ خواندنِ بالا و اینجا یک - # درخواستِ هم‌زمانِ دیگر authority ساخته باشد، همان مقدارِ اول را - # نگه می‌داریم، نه این result تازه را (فقط وقتی هنوز خالی است آپدیت می‌شود). - Order.objects.filter(pk=order.pk, gateway_authority='').update( - gateway_authority=result.authority, - ) - order.refresh_from_db(fields=['gateway_authority']) + logger.error('pay link: amount of order %s kept changing during PaymentRequest', order.id) + return Response({'detail': 'مبلغِ سفارش در حینِ رفتن به درگاه عوض شد؛ دوباره «پرداخت» را بزن.'}, + status=status.HTTP_409_CONFLICT) return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}') @@ -359,13 +370,17 @@ class BazaarVerifyPurchaseView(APIView): if self._second_purchase_on_paid_order(order, v['purchaseToken']): return self._second_purchase_response() new_status = 'paid' if result.is_valid else 'failed' - order.bazaar_purchase_token = v['purchaseToken'] - order.save(update_fields=['bazaar_purchase_token']) + # توکن فقط با پاسخِ «معتبر» روی سفارش و تراکنش می‌نشیند. قیدِ یکتای (gateway, ref) همه‌ی + # وضعیت‌ها را می‌گیرد؛ وقتی ردِ بازار (not_found، یا توکن با سفارشِ اشتباه) هم توکن را ثبت + # می‌کرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمی‌شد (بازتولید شد). + if result.is_valid: + order.bazaar_purchase_token = v['purchaseToken'] + order.save(update_fields=['bazaar_purchase_token']) apply_order_payment_result( order=order, gateway='bazaar', - gateway_ref_id=v['purchaseToken'], + gateway_ref_id=v['purchaseToken'] if result.is_valid else '', new_status=new_status, event='bazaar_verify', ) @@ -382,7 +397,9 @@ class BazaarVerifyPurchaseView(APIView): خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده نمی‌شد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود. """ - return (order.status == 'paid' and bool(order.bazaar_purchase_token) + # «refunded» هم: سفارشِ بازپرداخت‌شده با توکنِ تازه دوباره «paid» می‌شد ولی اعتبارش (که یک بار + # برای همین سفارش داده شده بود) دیگر داده نمی‌شد؛ کاربر پولِ خریدِ دوم را بی‌هیچ چیزی می‌داد. + return (order.status in ('paid', 'refunded') and bool(order.bazaar_purchase_token) and order.bazaar_purchase_token != purchase_token) @staticmethod diff --git a/subscription/admin.py b/subscription/admin.py index 4184278..40cc5c1 100644 --- a/subscription/admin.py +++ b/subscription/admin.py @@ -1,10 +1,26 @@ +from django import forms from django.contrib import admin from .models import Plan, Subscription +class PlanAdminForm(forms.ModelForm): + class Meta: + model = Plan + fields = '__all__' + + def clean_currency(self): + # فرانت هر قیمتی را «تومان» نشان می‌دهد (src/lib/format.ts) و currency بی‌تغییر به زرین‌پال می‌رود؛ + # پلنی با «IRR» در اپ ۴۵٬۰۰۰ تومان دیده می‌شد و درگاه ۴۵٬۰۰۰ ریال (یک‌دهم) می‌گرفت (بازتولید شد). + value = self.cleaned_data['currency'] + if value != 'IRT': + raise forms.ValidationError('فقط IRT (تومان)؛ اپ قیمت را تومان نشان می‌دهد.') + return value + + @admin.register(Plan) class PlanAdmin(admin.ModelAdmin): + form = PlanAdminForm list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular', 'bazaar_sku') list_editable = ('ai_daily_quota',) diff --git a/subscription/services.py b/subscription/services.py index 045a026..2277088 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -73,17 +73,24 @@ def grant_pack(order) -> bool: from .models import Plan - plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first() - if plan is None: + plan = Plan.objects.filter(pk=order.plan_id).first() + if plan is not None and plan.kind != 'pack': + logger.error('grant_pack: پلنِ %s سفارشِ %s بسته نیست؛ چیزی داده نشد', order.plan_id, order.id) + return False + # عددها از خودِ سفارش (همان که فروخته شد)، پس پلنِ پاک‌شده جلوی دادنِ آن‌ها را نمی‌گیرد؛ تا امروز + # پرداختِ سفارشی که پلنش پاک شده بود «paid» می‌شد بی‌هیچ اعتبار و بی‌هیچ لاگی (بازتولید شد). + # سفارشِ بی‌این ستون‌ها (پیش از payment.0011) عددهای فعلیِ پلن را می‌گیرد. + if order.question_credits is None and plan is None: + logger.error('grant_pack: سفارشِ %s نه عددِ ثبت‌شده دارد نه پلنِ %s؛ پرداخت شده ولی چیزی داده نشد', + order.id, order.plan_id) return False - # عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بی‌این ستون‌ها (پیش از آن‌ها، یا ساخته‌شده بیرون - # از create_order) عددهای فعلیِ پلن را می‌گیرد، همان رفتارِ پیشین. credits = order.question_credits if order.question_credits is not None else plan.question_credits - hearts_days = order.hearts_days if order.hearts_days is not None else plan.hearts_days + hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0) + note = (order.plan_title or (plan.title if plan else ''))[:100] try: with transaction.atomic(): QuestionCredit.objects.create(user_id=order.user_id, delta=credits, - reason='purchase', order_id=order.id, note=plan.title[:100]) + reason='purchase', order_id=order.id, note=note) if hearts_days: now = timezone.now() hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() @@ -91,6 +98,8 @@ def grant_pack(order) -> bool: until = start + timezone.timedelta(days=hearts_days) HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until}) except IntegrityError: + # اعتبارِ این سفارش از قبل داده شده؛ apply_order_payment_result نباید دو بار «تازه paid» بگوید. + logger.error('grant_pack: اعتبارِ سفارشِ %s از قبل داده شده بود؛ دوباره داده نشد', order.id) return False return True diff --git a/subscription/tests_security_review.py b/subscription/tests_security_review.py new file mode 100644 index 0000000..93cbd42 --- /dev/null +++ b/subscription/tests_security_review.py @@ -0,0 +1,56 @@ +""" +حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (بسته‌ها و پلن). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود. +""" +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from payment.models import Order +from payment.services import apply_order_payment_result + +from .models import Plan +from .services import credit_balance + + +class PaidPackWithDeletedPlanTests(TestCase): + """پرداختِ سفارشی که پلنش بعد از ساخت پاک شده بود «paid» می‌شد بی‌هیچ اعتبار و بی‌هیچ لاگی.""" + + def test_order_snapshot_is_granted_even_if_plan_is_gone(self): + cache.clear() + user = User.objects.create(phone='09120009902', is_guest=False) + app = APIClient() + app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + pack = Plan.objects.get(kind='pack', question_credits=20) + order = Order.objects.get(pk=app.post('/api/subscription/purchase', {'planId': str(pack.id)}, + format='json').json()['orderId']) + pack.delete() + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='GONE-1', new_status='paid', event='t') + self.assertEqual(credit_balance(user.id), 20) + + def test_nothing_to_grant_is_logged_as_error(self): + order = Order.objects.create(user_id=1, amount=1000, plan_id=999999, idempotency_key='sec-noplan') + with self.assertLogs('subscription.services', level='ERROR'): + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='GONE-2', new_status='paid', + event='t') + + +class PlanCurrencyIsTomanOnlyTests(TestCase): + """اپ هر قیمتی را «تومان» نشان می‌دهد و currencyِ پلن بی‌تغییر به زرین‌پال می‌رود؛ IRR یعنی یک‌دهمِ قیمت.""" + + def test_admin_refuses_irr(self): + admin = get_user_model().objects.create_superuser('root', 'r@example.com', 'pw-for-tests-only') + self.client.force_login(admin) + pack = Plan.objects.get(kind='pack', question_credits=20) + data = {f: getattr(pack, f) for f in ('title', 'price', 'discount_percent', 'duration_days', 'badge', + 'ai_daily_quota', 'kind', 'question_credits', 'hearts_days', + 'bazaar_sku')} + data.update(currency='IRR', features='[]', on_sale='on') + page = self.client.post(f'/admin/subscription/plan/{pack.pk}/change/', data) + self.assertEqual(page.status_code, 200) # فرم با خطا برگشت + pack.refresh_from_db() + self.assertEqual(pack.currency, 'IRT') + data['currency'] = 'IRT' + self.assertEqual(self.client.post(f'/admin/subscription/plan/{pack.pk}/change/', data).status_code, 302)