senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
23
payment/migrations/0009_order_invoice_snapshot.py
Normal file
23
payment/migrations/0009_order_invoice_snapshot.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-01 21:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0008_bazaar_credential'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='list_price',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='plan_title',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -19,7 +19,13 @@ class Order(models.Model):
|
||||
# بود و با شروعِ یک خریدِ دیگر عوض میشد: پرداختِ سفارشِ ماهانه بعد از
|
||||
# شروعِ خریدِ سالانه، اشتراکِ سالانه میداد (بازتولید شد، ۲۰۲۶-۰۹-۲۵).
|
||||
plan_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به subscription.Plan
|
||||
# مبلغی که باید پرداخت شود؛ کدِ تخفیف همین را کم میکند (discount/views.py).
|
||||
amount = models.PositiveIntegerField()
|
||||
# عکسِ لحظهی خرید برای فاکتور: قیمتِ پایه پیش از تخفیف و نامِ پلن. بدونِ این،
|
||||
# فاکتورِ ماهِ پیش با عوض شدنِ قیمت یا نامِ پلن در ادمین عوض میشد. سفارشهای
|
||||
# پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی).
|
||||
list_price = models.PositiveIntegerField(null=True, blank=True)
|
||||
plan_title = models.CharField(max_length=50, blank=True)
|
||||
currency = models.CharField(max_length=10, default='IRT')
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
|
||||
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
|
||||
|
||||
@@ -56,3 +56,23 @@ class OrderDetailResponseSerializer(serializers.Serializer):
|
||||
status = serializers.CharField()
|
||||
amount = serializers.IntegerField()
|
||||
transactions = TransactionSerializer(many=True)
|
||||
|
||||
|
||||
class InvoiceSerializer(serializers.Serializer):
|
||||
orderId = serializers.CharField()
|
||||
invoiceNumber = serializers.CharField(help_text='شمارهی فاکتور: «TD-» و شناسهی سفارش، شش رقمی.')
|
||||
status = serializers.ChoiceField(choices=['pending', 'paid', 'failed'])
|
||||
provider = serializers.CharField(help_text='gateway (زرینپال) یا bazaar')
|
||||
planTitle = serializers.CharField(allow_blank=True)
|
||||
listPrice = serializers.IntegerField(allow_null=True, help_text='قیمتِ پایه پیش از تخفیف؛ null برای سفارشهای پیش از ۱۴۰۵-۰۷-۰۹.')
|
||||
discountAmount = serializers.IntegerField(allow_null=True)
|
||||
discountCode = serializers.CharField(allow_blank=True)
|
||||
amount = serializers.IntegerField(help_text='مبلغِ پرداختی (پس از تخفیف).')
|
||||
currency = serializers.CharField()
|
||||
createdAt = serializers.DateTimeField()
|
||||
paidAt = serializers.DateTimeField(allow_null=True)
|
||||
refId = serializers.CharField(allow_blank=True, help_text='کدِ پیگیریِ پرداخت (شمارهی مرجعِ زرینپال/بازار).')
|
||||
|
||||
|
||||
class InvoiceListResponseSerializer(serializers.Serializer):
|
||||
invoices = InvoiceSerializer(many=True)
|
||||
|
||||
@@ -57,7 +57,7 @@ def call_payment_gateway(order: Order) -> str:
|
||||
def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
subscription_id: int | None = None, idempotency_key: str | None = None,
|
||||
provider: str = 'gateway', bazaar_product_sku: str = '',
|
||||
plan_id: int | None = None):
|
||||
plan_id: int | None = None, plan_title: str = ''):
|
||||
"""
|
||||
ساخت سفارش با پشتیبانی از Idempotency-Key: اگر سفارشی با همین کلید قبلاً
|
||||
ساخته شده باشد، همان رکورد برگردانده میشود (نه رکورد تکراری)، فقط اگر
|
||||
@@ -82,6 +82,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
subscription_id=subscription_id,
|
||||
plan_id=plan_id,
|
||||
amount=amount,
|
||||
list_price=amount,
|
||||
plan_title=plan_title[:50],
|
||||
currency=currency,
|
||||
status='pending',
|
||||
provider=provider,
|
||||
|
||||
92
payment/tests_invoices.py
Normal file
92
payment/tests_invoices.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
GET /api/payment/invoices — «پرداختها و فاکتورها»ی پروفایل (۲۰۲۶-۱۰-۰۱).
|
||||
|
||||
راههای شکستن که اینجا امتحان میشوند: دیدنِ فاکتورِ دیگری، عوض شدنِ فاکتورِ قدیمی
|
||||
با عوض شدنِ قیمتِ پلن، و نشان دادنِ refِ ساختگیِ کدِ ۱۰۰٪ بهجای کدِ پیگیریِ بانکی.
|
||||
"""
|
||||
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 discount.models import DiscountCode
|
||||
from payment.models import Order
|
||||
from payment.services import apply_order_payment_result
|
||||
from subscription.models import Plan
|
||||
|
||||
|
||||
def _client(user):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
return client
|
||||
|
||||
|
||||
class InvoiceTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007001', is_guest=False)
|
||||
self.client = _client(self.user)
|
||||
self.plan = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def buy(self):
|
||||
response = self.client.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
return Order.objects.get(pk=response.json()['orderId'])
|
||||
|
||||
def invoices(self, client=None):
|
||||
response = (client or self.client).get('/api/payment/invoices')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
return response.json()['invoices']
|
||||
|
||||
def test_paid_order_with_discount_reads_like_an_invoice(self):
|
||||
order = self.buy()
|
||||
DiscountCode.objects.create(code='MEHR20', type='percent', value=20, max_uses=5)
|
||||
redeem = self.client.post('/api/discount/redeem', {'code': 'MEHR20', 'orderId': str(order.id)}, format='json')
|
||||
self.assertEqual(redeem.status_code, 200, redeem.content)
|
||||
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='A00000123456',
|
||||
new_status='paid', event='test')
|
||||
|
||||
[invoice] = self.invoices()
|
||||
self.assertEqual(invoice['invoiceNumber'], f'TD-{order.id:06d}')
|
||||
self.assertEqual(invoice['status'], 'paid')
|
||||
self.assertEqual(invoice['planTitle'], 'سالانه')
|
||||
self.assertEqual((invoice['listPrice'], invoice['discountAmount'], invoice['amount']),
|
||||
(299000, 59800, 239200))
|
||||
self.assertEqual(invoice['discountCode'], 'MEHR20')
|
||||
self.assertEqual(invoice['refId'], 'A00000123456')
|
||||
self.assertIsNotNone(invoice['paidAt'])
|
||||
|
||||
def test_changing_the_plan_later_does_not_rewrite_an_old_invoice(self):
|
||||
self.buy()
|
||||
Plan.objects.filter(pk=self.plan.pk).update(title='سالانهی ویژه', price=399000)
|
||||
[invoice] = self.invoices()
|
||||
self.assertEqual((invoice['planTitle'], invoice['listPrice']), ('سالانه', 299000))
|
||||
|
||||
def test_full_discount_shows_no_fake_bank_reference(self):
|
||||
order = self.buy()
|
||||
DiscountCode.objects.create(code='FREE100', type='percent', value=100, max_uses=5)
|
||||
self.client.post('/api/discount/redeem', {'code': 'FREE100', 'orderId': str(order.id)}, format='json')
|
||||
[invoice] = self.invoices()
|
||||
self.assertEqual((invoice['status'], invoice['amount'], invoice['refId']), ('paid', 0, ''))
|
||||
|
||||
def test_unpaid_orders_are_listed_too(self):
|
||||
self.buy()
|
||||
[invoice] = self.invoices()
|
||||
self.assertEqual((invoice['status'], invoice['paidAt'], invoice['refId']), ('pending', None, ''))
|
||||
|
||||
def test_someone_else_sees_nothing(self):
|
||||
self.buy()
|
||||
stranger = User.objects.create(phone='09120007002', is_guest=False)
|
||||
self.assertEqual(self.invoices(_client(stranger)), [])
|
||||
|
||||
def test_orders_from_before_the_snapshot_have_no_list_price(self):
|
||||
Order.objects.create(user_id=self.user.id, amount=99000, idempotency_key='old-order')
|
||||
[invoice] = self.invoices()
|
||||
self.assertEqual((invoice['listPrice'], invoice['discountAmount'], invoice['planTitle']), (None, None, ''))
|
||||
|
||||
def test_requires_a_token(self):
|
||||
self.assertIn(APIClient().get('/api/payment/invoices').status_code, (401, 403))
|
||||
@@ -3,6 +3,7 @@ from django.urls import path
|
||||
from .views import (
|
||||
BazaarVerifyPurchaseView,
|
||||
CreateOrderView,
|
||||
InvoiceListView,
|
||||
OrderDetailView,
|
||||
PaymentRedirectView,
|
||||
ZarinpalCallbackView,
|
||||
@@ -12,6 +13,7 @@ urlpatterns = [
|
||||
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
||||
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
|
||||
path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
|
||||
path('payment/invoices', InvoiceListView.as_view(), name='payment-invoices'),
|
||||
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
||||
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),
|
||||
]
|
||||
|
||||
@@ -16,6 +16,7 @@ from . import bazaar, zarinpal
|
||||
from .models import Order
|
||||
from .permissions import HasInternalServiceKey
|
||||
from .serializers import (
|
||||
InvoiceListResponseSerializer,
|
||||
BazaarVerifyRequestSerializer,
|
||||
BazaarVerifyResponseSerializer,
|
||||
CreateOrderRequestSerializer,
|
||||
@@ -43,7 +44,7 @@ def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
|
||||
|
||||
|
||||
class GuestAwareBazaarThrottle(ScopedRateThrottle):
|
||||
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
||||
"""همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ
|
||||
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازهی bazaar_verify
|
||||
بده، چون هر درخواستِ پذیرفتهشده یه فراخوانیِ سرور-به-سرور به بازاره.
|
||||
برای مهمانها کلیدِ cache بر اساسِ IP هست، نه pk."""
|
||||
@@ -365,3 +366,53 @@ class OrderDetailView(APIView):
|
||||
],
|
||||
}
|
||||
return Response(OrderDetailResponseSerializer(data).data)
|
||||
|
||||
|
||||
INVOICE_LIMIT = 50
|
||||
|
||||
|
||||
def invoice_payload(order) -> dict:
|
||||
"""یک سفارش به شکلِ فاکتور. کدِ تخفیف و کدِ پیگیری از جدولهای خودشان خوانده میشوند."""
|
||||
from discount.models import Redemption
|
||||
|
||||
redemption = Redemption.objects.filter(order_id=order.id).select_related('discount_code').first()
|
||||
paid = next((t for t in order.transactions.all() if t.status == 'paid'), None)
|
||||
# کدِ تخفیفِ ۱۰۰٪ هم یک Transactionِ «paid» میسازد، با refی ساختگی (discount-…)؛
|
||||
# آن کدِ پیگیریِ بانکی نیست و به کاربر نشان داده نمیشود.
|
||||
ref = paid.gateway_ref_id if paid and paid.gateway != 'discount' else ''
|
||||
return {
|
||||
'orderId': str(order.id),
|
||||
'invoiceNumber': f'TD-{order.id:06d}',
|
||||
'status': order.status,
|
||||
'provider': order.provider,
|
||||
'planTitle': order.plan_title,
|
||||
'listPrice': order.list_price,
|
||||
'discountAmount': (order.list_price - order.amount) if order.list_price is not None else None,
|
||||
'discountCode': redemption.discount_code.code if redemption else '',
|
||||
'amount': order.amount,
|
||||
'currency': order.currency,
|
||||
'createdAt': order.created_at,
|
||||
'paidAt': paid.paid_at if paid else None,
|
||||
'refId': ref,
|
||||
}
|
||||
|
||||
|
||||
class InvoiceListView(APIView):
|
||||
"""
|
||||
GET /api/payment/invoices — پرداختها و فاکتورهای خودِ کاربر، تازهترین اول.
|
||||
|
||||
همهی سفارشها برمیگردند، نه فقط پرداختشدهها، تا کسی که پولش کم شده ولی
|
||||
اشتراکش فعال نشده سفارشِ «در انتظار» یا «ناموفق» خودش را ببیند و کدش را به
|
||||
پشتیبانی بدهد. اینجا از زرینپال بازسنجی نمیشود (برای ۵۰ سفارش ۵۰ فراخوانی
|
||||
میشد)؛ GET /api/payment/orders/{id} و وضعیتِ اشتراک این کار را میکنند.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
throttle_scope = 'read'
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
|
||||
@extend_schema(responses=InvoiceListResponseSerializer, tags=['Payment (payment)'])
|
||||
def get(self, request):
|
||||
orders = (Order.objects.filter(user_id=request.user.id)
|
||||
.prefetch_related('transactions').order_by('-created_at', '-id')[:INVOICE_LIMIT])
|
||||
return Response(InvoiceListResponseSerializer({'invoices': [invoice_payload(o) for o in orders]}).data)
|
||||
|
||||
Reference in New Issue
Block a user