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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
56
subscription/tests_security_review.py
Normal file
56
subscription/tests_security_review.py
Normal 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)
|
||||
Reference in New Issue
Block a user