import uuid from datetime import date from django.test import TestCase from django.urls import reverse from rest_framework.test import APIClient from rest_framework_simplejwt.tokens import RefreshToken from apps.account.models import User from apps.bizapps.api.services import InvoiceService, LedgerService from apps.bizapps.models import Customer, LedgerEntry, LedgerInstallment # این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد — # دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون # باگ اینجا یعنی مانده‌ی بدهیِ نمایش‌داده‌شده به کاربر با واقعیت فرق کند. def _make_user(phone="09120000000") -> User: return User.objects.create_user(phone=phone, is_active=True, state=User.State.ACTIVE) class LedgerEntryModelTests(TestCase): """دسته‌بندیِ هر ردیف باید دقیقا به نوعِ حرفه‌ایِ درستش نگاشت شود — این همان «هماهنگی» است که کاربر مثال زد (فروش قسطی = طلب، نه درآمدِ صرف).""" def setUp(self): self.user = _make_user() def _entry(self, **kwargs): defaults = dict( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.INCOME, title="تست", amount=100, entry_date=date.today(), ) defaults.update(kwargs) return LedgerEntry.objects.create(**defaults) def test_sale_credit_is_receivable_debt(self): entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=100) self.assertTrue(entry.is_debt) self.assertEqual(entry.professional_type, "receivable") def test_purchase_credit_is_payable_debt(self): entry = self._entry( entry_type=LedgerEntry.EntryType.EXPENSE, category=LedgerEntry.Category.PURCHASE_CREDIT, total_amount=100, ) self.assertTrue(entry.is_debt) self.assertEqual(entry.professional_type, "payable") def test_simple_categories_are_not_debt(self): for category in [ LedgerEntry.Category.SALE_CASH, LedgerEntry.Category.SERVICE_INCOME, LedgerEntry.Category.OTHER_INCOME, ]: entry = self._entry(category=category) self.assertFalse(entry.is_debt, category) self.assertEqual(entry.professional_type, "simple", category) def test_remaining_amount_tracks_installments(self): entry = self._entry(category=LedgerEntry.Category.SALE_CREDIT, total_amount=1000) self.assertEqual(entry.remaining_amount, 1000) LedgerInstallment.objects.create( id=str(uuid.uuid4()), ledger_entry=entry, amount=400, paid_date=date.today() ) entry.refresh_from_db() self.assertEqual(entry.paid_amount, 400) self.assertEqual(entry.remaining_amount, 600) LedgerInstallment.objects.create( id=str(uuid.uuid4()), ledger_entry=entry, amount=600, paid_date=date.today() ) entry.refresh_from_db() self.assertEqual(entry.remaining_amount, 0) def test_remaining_amount_is_zero_for_non_debt_entries(self): entry = self._entry(category=LedgerEntry.Category.SALE_CASH) self.assertEqual(entry.remaining_amount, 0) class LedgerServiceTests(TestCase): def setUp(self): self.user = _make_user() self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="مشتری", phone="09121112222" ) def test_recompute_balance_sums_open_receivables(self): LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.INCOME, title="فاکتور ۱", amount=500, category=LedgerEntry.Category.SALE_CREDIT, entry_date=date.today(), total_amount=500, counterparty_phone="09121112222", ) LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.INCOME, title="فاکتور ۲", amount=300, category=LedgerEntry.Category.SALE_CREDIT, entry_date=date.today(), total_amount=300, counterparty_phone="09121112222", ) LedgerService.recompute_customer_balance(self.user, "09121112222") self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, 800) def test_balance_drops_as_installments_are_paid(self): entry = LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.INCOME, title="فاکتور", amount=1000, category=LedgerEntry.Category.SALE_CREDIT, entry_date=date.today(), total_amount=1000, counterparty_phone="09121112222", ) LedgerService.recompute_customer_balance(self.user, "09121112222") self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, 1000) LedgerInstallment.objects.create( id=str(uuid.uuid4()), ledger_entry=entry, amount=1000, paid_date=date.today() ) LedgerService.recompute_customer_balance(self.user, "09121112222") self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, 0) def test_ignores_other_users_entries(self): other = _make_user(phone="09129998888") LedgerEntry.objects.create( id=str(uuid.uuid4()), user=other, entry_type=LedgerEntry.EntryType.INCOME, title="فاکتور مالِ یکی دیگه", amount=999, category=LedgerEntry.Category.SALE_CREDIT, entry_date=date.today(), total_amount=999, counterparty_phone="09121112222", ) LedgerService.recompute_customer_balance(self.user, "09121112222") self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, 0) def test_export_csv_filters_by_date_range(self): LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.INCOME, title="داخل بازه", amount=100, category=LedgerEntry.Category.SALE_CASH, entry_date=date(2026, 1, 15), ) LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.EXPENSE, title="خارج از بازه", amount=50, category=LedgerEntry.Category.RENT, entry_date=date(2026, 3, 1), ) csv_text = LedgerService.export_csv(self.user, start=date(2026, 1, 1), end=date(2026, 1, 31)) self.assertIn("داخل بازه", csv_text) self.assertNotIn("خارج از بازه", csv_text) class InvoiceLedgerIntegrationTests(TestCase): """اتصالِ فاکتور <-> دفتر: طبق درخواست، فقط فروشِ قسطی باید خودکار یک ردیفِ بدهی‌دار در دفتر بسازد؛ فروشِ نقدی نباید دفتر را شلوغ کند.""" def setUp(self): self.user = _make_user() self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="مشتری قسطی", phone="09123334444" ) def _invoice_data(self, payment_type): return { "customer": self.customer, "buyer_name": self.customer.name, "buyer_phone": self.customer.phone, "buyer_address": "", "items": [ {"product": None, "description": "کالای آزاد", "quantity": 1, "unit_price": 100, "discount_percent": 0} ], "payment_type": payment_type, } def test_credit_invoice_creates_linked_receivable_entry(self): invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit")) entry = LedgerEntry.objects.get(linked_invoice=invoice) self.assertEqual(entry.category, LedgerEntry.Category.SALE_CREDIT) self.assertEqual(entry.total_amount, invoice.final_amount) self.assertEqual(entry.remaining_amount, invoice.final_amount) self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, invoice.final_amount) def test_cash_invoice_does_not_create_ledger_entry(self): invoice = InvoiceService.create_invoice(self.user, self._invoice_data("cash")) self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists()) def test_marking_invoice_paid_settles_the_linked_entry(self): invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit")) InvoiceService.update_status(self.user, invoice.id, payment_status="paid") entry = LedgerEntry.objects.get(linked_invoice=invoice) self.assertEqual(entry.remaining_amount, 0) self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, 0) def test_cancelling_unpaid_credit_invoice_removes_the_entry(self): invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit")) InvoiceService.cancel_invoice(self.user, invoice.id) self.assertFalse(LedgerEntry.objects.filter(linked_invoice=invoice).exists()) self.customer.refresh_from_db() self.assertEqual(self.customer.outstanding_balance, 0) def test_cancelling_partially_paid_credit_invoice_keeps_the_entry(self): invoice = InvoiceService.create_invoice(self.user, self._invoice_data("credit")) entry = LedgerEntry.objects.get(linked_invoice=invoice) LedgerInstallment.objects.create( id=str(uuid.uuid4()), ledger_entry=entry, amount=50, paid_date=date.today() ) invoice.order_status = invoice.OrderStatus.PENDING_PAYMENT invoice.save(update_fields=["order_status"]) InvoiceService.cancel_invoice(self.user, invoice.id) # قسطی که واقعا پرداخت شده نباید محو شود — تاریخچه‌ی مالی دست‌نخورده می‌ماند. self.assertTrue(LedgerEntry.objects.filter(linked_invoice=invoice).exists()) class LedgerApiTests(TestCase): """چند نقطه‌ی حیاتیِ سطح API — اعتبارسنجی اضافه‌شدنِ قسطِ بیش از حد، و این‌که خروجیِ CSV واقعا با content-type درست برمی‌گردد.""" def setUp(self): self.user = _make_user() self.client = APIClient() token = str(RefreshToken.for_user(self.user).access_token) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") def test_create_debt_entry_without_total_amount_is_rejected(self): response = self.client.post( reverse("bizapps:ledger-list"), { "entry_type": "income", "title": "فروش قسطی بدون مبلغ کل", "amount": 100, "category": "sale_credit", "entry_date": date.today().isoformat(), }, format="json", ) self.assertEqual(response.status_code, 400) def test_installment_exceeding_remaining_amount_is_rejected(self): entry = LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, entry_type=LedgerEntry.EntryType.INCOME, title="فروش قسطی", amount=1000, category=LedgerEntry.Category.SALE_CREDIT, entry_date=date.today(), total_amount=1000, ) response = self.client.post( reverse("bizapps:ledger-installment-list", kwargs={"entry_id": entry.id}), {"amount": 5000, "paid_date": date.today().isoformat()}, format="json", ) self.assertEqual(response.status_code, 400) def test_export_returns_csv(self): response = self.client.get(reverse("bizapps:ledger-export")) self.assertEqual(response.status_code, 200) self.assertIn("text/csv", response["Content-Type"])