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