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

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