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 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:26:26 +03:30
parent eb4e97753b
commit fedd10ff22
7 changed files with 268 additions and 22 deletions

View File

@@ -8,6 +8,13 @@ from .models import BazaarCredential, Order, Transaction
class TransactionInline(admin.TabularInline): class TransactionInline(admin.TabularInline):
model = Transaction model = Transaction
extra = 0 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) @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_display = ('id', 'user_id', 'plan_title', 'amount', 'currency', 'status', 'provider', 'created_at')
list_filter = ('status', 'provider', 'currency') list_filter = ('status', 'provider', 'currency')
search_fields = ('=id', '=user_id') search_fields = ('=id', '=user_id')
readonly_fields = ('status',)
inlines = [TransactionInline] inlines = [TransactionInline]
actions = ['cancel_pending', 'refund'] 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']) @admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change'])
def cancel_pending(self, request, queryset): def cancel_pending(self, request, queryset):
from .services import OrderStateError, cancel_pending_order from .services import OrderStateError, cancel_pending_order
@@ -69,9 +89,21 @@ class OrderAdmin(admin.ModelAdmin):
@admin.register(Transaction) @admin.register(Transaction)
class TransactionAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin):
"""فقط برای دیدن؛ تراکنش را فقط تأییدِ درگاه و actionهای سفارش می‌سازند (قیدِ یکتای gateway_ref_id
جلوی تکرارِ توکنِ خرید است و ویرایش یا حذفِ دستیِ ردیف آن را برمی‌داشت)."""
list_display = ('id', 'order', 'gateway', 'status', 'paid_at') list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
list_filter = ('gateway', 'status') 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) @admin.register(BazaarCredential)
class BazaarCredentialAdmin(admin.ModelAdmin): class BazaarCredentialAdmin(admin.ModelAdmin):

View File

@@ -4,6 +4,8 @@
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون. پیاده‌سازی شده، نه یک HTTP call به خودمون.
""" """
import logging
from django.conf import settings from django.conf import settings
from django.core import signing from django.core import signing
from django.db import IntegrityError from django.db import IntegrityError
@@ -16,6 +18,8 @@ from statuslog.signals import status_changed
from .models import Order, Transaction from .models import Order, Transaction
logger = logging.getLogger(__name__)
class IdempotencyKeyConflict(APIException): class IdempotencyKeyConflict(APIException):
"""اگر Idempotency-Key موجود متعلق به کاربر یا پارامترهای دیگری باشد، 409 برمی‌گرداند.""" """اگر Idempotency-Key موجود متعلق به کاربر یا پارامترهای دیگری باشد، 409 برمی‌گرداند."""
@@ -255,7 +259,10 @@ def reconcile_pending_order(order: Order) -> bool:
result = zarinpal.verify_payment( result = zarinpal.verify_payment(
authority=order.gateway_authority, amount=order.amount, currency=order.currency, 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 return False
if not result.is_valid: if not result.is_valid:
return False return False

View File

@@ -118,3 +118,112 @@ class BazaarSecondTokenOnPaidOrderTests(_Base):
{'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json') {'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(fresh.json()['status'], 'paid') self.assertEqual(fresh.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 40) 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')

View File

@@ -1,3 +1,4 @@
import logging
from urllib.parse import urlsplit from urllib.parse import urlsplit
from django.conf import settings 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 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: def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
""" """
@@ -178,24 +181,32 @@ class PaymentRedirectView(APIView):
raise Http404 raise Http404
order = get_object_or_404(Order, pk=order_id, provider='gateway') 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': if order.status != 'pending':
# سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست. # سفارش از قبل نتیجه گرفته (paid/failed، یا کدِ ۱۰۰٪ همین الان)؛ چیزی برای پرداختِ تازه نیست.
# کاربر به اپ برمی‌گردد با همان نتیجه، نه به ریشه‌ی دامنه‌ی API. # کاربر به اپ برمی‌گردد با همان نتیجه، نه به ریشه‌ی دامنه‌ی API.
if settings.ZARINPAL_REDIRECT_URL: if settings.ZARINPAL_REDIRECT_URL:
return _redirect_to_configured_url( return _redirect_to_configured_url(
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}' f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
) )
return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/') return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/')
if not order.gateway_authority: if not order.gateway_authority:
result = zarinpal.request_payment(order) logger.error('pay link: amount of order %s kept changing during PaymentRequest', order.id)
# ⚠️ محافظت در برابرِ race: اگه بینِ خواندنِ بالا و اینجا یک return Response({'detail': 'مبلغِ سفارش در حینِ رفتن به درگاه عوض شد؛ دوباره «پرداخت» را بزن.'},
# درخواستِ هم‌زمانِ دیگر authority ساخته باشد، همان مقدارِ اول را status=status.HTTP_409_CONFLICT)
# نگه می‌داریم، نه این result تازه را (فقط وقتی هنوز خالی است آپدیت می‌شود).
Order.objects.filter(pk=order.pk, gateway_authority='').update(
gateway_authority=result.authority,
)
order.refresh_from_db(fields=['gateway_authority'])
return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}') 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']): if self._second_purchase_on_paid_order(order, v['purchaseToken']):
return self._second_purchase_response() return self._second_purchase_response()
new_status = 'paid' if result.is_valid else 'failed' new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken'] # توکن فقط با پاسخِ «معتبر» روی سفارش و تراکنش می‌نشیند. قیدِ یکتای (gateway, ref) همه‌ی
order.save(update_fields=['bazaar_purchase_token']) # وضعیت‌ها را می‌گیرد؛ وقتی ردِ بازار (not_found، یا توکن با سفارشِ اشتباه) هم توکن را ثبت
# می‌کرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمی‌شد (بازتولید شد).
if result.is_valid:
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
apply_order_payment_result( apply_order_payment_result(
order=order, order=order,
gateway='bazaar', gateway='bazaar',
gateway_ref_id=v['purchaseToken'], gateway_ref_id=v['purchaseToken'] if result.is_valid else '',
new_status=new_status, new_status=new_status,
event='bazaar_verify', event='bazaar_verify',
) )
@@ -382,7 +397,9 @@ class BazaarVerifyPurchaseView(APIView):
خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «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) and order.bazaar_purchase_token != purchase_token)
@staticmethod @staticmethod

View File

@@ -1,10 +1,26 @@
from django import forms
from django.contrib import admin from django.contrib import admin
from .models import Plan, Subscription 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) @admin.register(Plan)
class PlanAdmin(admin.ModelAdmin): class PlanAdmin(admin.ModelAdmin):
form = PlanAdminForm
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular', list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular',
'bazaar_sku') 'bazaar_sku')
list_editable = ('ai_daily_quota',) list_editable = ('ai_daily_quota',)

View File

@@ -73,17 +73,24 @@ def grant_pack(order) -> bool:
from .models import Plan from .models import Plan
plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first() plan = Plan.objects.filter(pk=order.plan_id).first()
if plan is None: 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 return False
# عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بی‌این ستون‌ها (پیش از آن‌ها، یا ساخته‌شده بیرون
# از create_order) عددهای فعلیِ پلن را می‌گیرد، همان رفتارِ پیشین.
credits = order.question_credits if order.question_credits is not None else plan.question_credits 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: try:
with transaction.atomic(): with transaction.atomic():
QuestionCredit.objects.create(user_id=order.user_id, delta=credits, 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: if hearts_days:
now = timezone.now() now = timezone.now()
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() 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) until = start + timezone.timedelta(days=hearts_days)
HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until}) HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until})
except IntegrityError: except IntegrityError:
# اعتبارِ این سفارش از قبل داده شده؛ apply_order_payment_result نباید دو بار «تازه paid» بگوید.
logger.error('grant_pack: اعتبارِ سفارشِ %s از قبل داده شده بود؛ دوباره داده نشد', order.id)
return False return False
return True return True

View File

@@ -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)