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

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

View File

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

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)