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:
2026-10-02 02:38:43 +03:30
parent d5dbd1b23d
commit 39247f6d53
67 changed files with 1619 additions and 1140 deletions

View 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),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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