import io 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.accounting import ( ACCOUNT_CASH, ACCOUNT_RECEIVABLE, ACCOUNT_SALES, DEFAULT_CHART, ChartOfAccountsService, CustomerStatementService, ExcelExportService, LedgerBookService, SubsidiaryLedgerService, ) from apps.bizapps.api.services import InvoiceService, LedgerService from apps.bizapps.models import ( Account, Customer, Invoice, LedgerBook, LedgerEntry, LedgerInstallment, Product, Transaction, ) # این ماژول لایه‌ی «هماهنگیِ عادی/حرفه‌ای»ِ دفتر درآمد و هزینه را پوشش می‌دهد — # دقیقا همان بخشی که کاربر صریحا خواسته «تست‌نویسی برایش خیلی مهم است»، چون # باگ اینجا یعنی مانده‌ی بدهیِ نمایش‌داده‌شده به کاربر با واقعیت فرق کند. 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 _create_and_post(self, **kwargs): """معادلِ دستیِ کاری که ویوی API انجام می‌دهد: بعد از ساختِ ردیف، حساب را هماهنگ و سندِ دوطرفه‌اش را می‌زند — بدونِ این سند، ردیف در هیچ گزارشِ حرفه‌ای (تراز/دفتر معین/CSV) دیده نمی‌شود.""" entry = LedgerEntry.objects.create(id=str(uuid.uuid4()), user=self.user, **kwargs) LedgerService.sync_entry_account(self.user, entry) LedgerService.post_entry_transaction(self.user, entry) return entry def test_export_csv_filters_by_date_range(self): self._create_and_post( entry_type=LedgerEntry.EntryType.INCOME, title="داخل بازه", amount=100, category=LedgerEntry.Category.SALE_CASH, entry_date=date(2026, 1, 15), ) self._create_and_post( 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) def test_post_entry_transaction_covers_plain_income_and_expense(self): """قبلا فقط فاکتور/قسط سند می‌خورد؛ یک ثبتِ سادهِ «فروش نقدی» یا «اجاره» (که کارِ روزمرهِ اکثرِ کاربرهاست) اصلا در ترازِ حساب‌ها دیده نمی‌شد. این تست دقیقا همان رگرسیون را قفل می‌کند.""" self._create_and_post( entry_type=LedgerEntry.EntryType.INCOME, title="فروش نقدی امروز", amount=200, category=LedgerEntry.Category.SALE_CASH, entry_date=date.today(), ) self._create_and_post( entry_type=LedgerEntry.EntryType.EXPENSE, title="اجاره مغازه", amount=80, category=LedgerEntry.Category.RENT, entry_date=date.today(), ) rows = ChartOfAccountsService.trial_balance(self.user) by_code = {r["code"]: r for r in rows} self.assertIn(ACCOUNT_CASH, by_code) # فروشِ نقدی صندوق را بدهکار می‌کند، اجاره بستانکار — مانده‌ی خالص # صندوق باید ۲۰۰ منهای ۸۰ باشد. self.assertEqual(by_code[ACCOUNT_CASH]["balance"], 120) 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_defaults_to_xlsx(self): """پیش‌فرضِ خروجی، اکسلِ چندبرگی است — چون همان چیزی است که مستقیم به‌دردِ حسابدارِ کاربر می‌خورد، نه CSV خام.""" response = self.client.get(reverse("bizapps:ledger-export")) self.assertEqual(response.status_code, 200) self.assertIn("spreadsheetml.sheet", response["Content-Type"]) self.assertIn(".xlsx", response["Content-Disposition"]) # امضای فایلِ zip — یک xlsx واقعی، نه متنِ خالی با هدرِ اشتباه. self.assertEqual(response.content[:2], b"PK") def test_export_csv_still_available_via_query_param(self): response = self.client.get(reverse("bizapps:ledger-export"), {"type": "csv"}) self.assertEqual(response.status_code, 200) self.assertIn("text/csv", response["Content-Type"]) # --------------------------------------------------------------------------- # لایه‌ی حسابداریِ حرفه‌ای — درختِ حساب‌ها (کل/معین/تفصیلی)، ترازِ حساب‌ها، # صورت‌حسابِ مشتری، و خروجیِ اکسلِ چندبرگی. # --------------------------------------------------------------------------- class ChartOfAccountsTests(TestCase): def setUp(self): self.user = _make_user() def test_ensure_chart_creates_default_tree(self): ChartOfAccountsService.ensure_chart(self.user) accounts = Account.objects.filter(user=self.user) self.assertEqual(accounts.count(), len(DEFAULT_CHART)) self.assertTrue(accounts.filter(level=Account.Level.KOL).exists()) self.assertTrue(accounts.filter(level=Account.Level.MOIN).exists()) def test_ensure_chart_is_idempotent(self): ChartOfAccountsService.ensure_chart(self.user) first = Account.objects.filter(user=self.user).count() ChartOfAccountsService.ensure_chart(self.user) self.assertEqual(Account.objects.filter(user=self.user).count(), first) def test_moin_accounts_are_linked_to_their_kol_parent(self): ChartOfAccountsService.ensure_chart(self.user) cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH) self.assertEqual(cash.level, Account.Level.MOIN) self.assertIsNotNone(cash.parent) self.assertEqual(cash.parent.level, Account.Level.KOL) self.assertIn("دارایی", cash.full_path) def test_chart_is_per_user_not_shared(self): other = _make_user(phone="09129999999") ChartOfAccountsService.ensure_chart(self.user) ChartOfAccountsService.ensure_chart(other) self.assertEqual(Account.objects.filter(user=self.user).count(), len(DEFAULT_CHART)) self.assertEqual(Account.objects.filter(user=other).count(), len(DEFAULT_CHART)) def test_customer_gets_tafsili_account_under_receivables(self): customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) account = ChartOfAccountsService.ensure_customer_account(self.user, customer) self.assertEqual(account.level, Account.Level.TAFSILI) self.assertEqual(account.parent.code, ACCOUNT_RECEIVABLE) self.assertTrue(account.code.startswith(f"{ACCOUNT_RECEIVABLE}-")) customer.refresh_from_db() self.assertEqual(customer.account_id, account.id) def test_customer_account_is_not_duplicated(self): customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) first = ChartOfAccountsService.ensure_customer_account(self.user, customer) second = ChartOfAccountsService.ensure_customer_account(self.user, customer) self.assertEqual(first.id, second.id) def test_debit_nature_matches_account_type(self): ChartOfAccountsService.ensure_chart(self.user) self.assertTrue(Account.objects.get(user=self.user, code=ACCOUNT_CASH).is_debit_nature) self.assertFalse(Account.objects.get(user=self.user, code=ACCOUNT_SALES).is_debit_nature) class LedgerAccountMappingTests(TestCase): """هر دسته‌ی «عادی» باید به حسابِ معینِ درست نگاشت شود — این پلِ بینِ لایه‌ی ساده و لایه‌ی حرفه‌ای است و اگر بشکند، ترازِ حساب‌ها بی‌معنی می‌شود.""" def setUp(self): self.user = _make_user() def test_every_category_maps_to_an_existing_account(self): ChartOfAccountsService.ensure_chart(self.user) for category in LedgerEntry.Category.values: account = LedgerService.account_for_category(self.user, category) self.assertIsNotNone(account, f"دسته‌ی {category} به هیچ حسابی نگاشت نشد") self.assertEqual(account.level, Account.Level.MOIN) def test_income_categories_map_to_income_accounts(self): ChartOfAccountsService.ensure_chart(self.user) account = LedgerService.account_for_category(self.user, LedgerEntry.Category.SALE_CASH) self.assertEqual(account.account_type, Account.AccountType.INCOME) def test_expense_categories_map_to_expense_accounts(self): ChartOfAccountsService.ensure_chart(self.user) account = LedgerService.account_for_category(self.user, LedgerEntry.Category.RENT) self.assertEqual(account.account_type, Account.AccountType.EXPENSE) class TrialBalanceTests(TestCase): def setUp(self): self.user = _make_user() self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) self.product = Product.objects.create( id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60 ) def test_cash_sale_posts_to_cash_and_sales(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "payment_type": Invoice.PaymentType.CASH, }, ) rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} self.assertIn(ACCOUNT_CASH, rows) self.assertIn(ACCOUNT_SALES, rows) self.assertEqual(rows[ACCOUNT_CASH]["debit"], 100) self.assertEqual(rows[ACCOUNT_SALES]["credit"], 100) def test_credit_sale_posts_to_receivable_not_cash(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "payment_type": Invoice.PaymentType.CREDIT, }, ) rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} self.assertIn(ACCOUNT_RECEIVABLE, rows) self.assertEqual(rows[ACCOUNT_RECEIVABLE]["debit"], 100) self.assertNotIn(ACCOUNT_CASH, rows) def test_balance_respects_account_nature(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "payment_type": Invoice.PaymentType.CASH, }, ) rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} # صندوق (دارایی، ماهیت بدهکار) و فروش (درآمد، ماهیت بستانکار) هر دو # باید مانده‌ی مثبت نشان دهند، نه یکی منفی. self.assertEqual(rows[ACCOUNT_CASH]["balance"], 100) self.assertEqual(rows[ACCOUNT_SALES]["balance"], 100) def test_empty_accounts_are_omitted(self): rows = ChartOfAccountsService.trial_balance(self.user) self.assertEqual(rows, []) class CustomerStatementTests(TestCase): def setUp(self): self.user = _make_user() self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) self.product = Product.objects.create( id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60 ) def test_statement_gathers_invoices_and_open_debts(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "buyer_phone": "09121112222", "payment_type": Invoice.PaymentType.CREDIT, }, ) self.customer.refresh_from_db() statement = CustomerStatementService.build(self.user, self.customer) self.assertEqual(len(statement["invoices"]), 1) self.assertEqual(len(statement["debts"]), 1) self.assertEqual(statement["debts"][0]["remaining_amount"], 100) self.assertEqual(statement["customer"]["outstanding_balance"], 100) # حسابِ تفصیلیِ مشتری هم باید در صورت‌حساب بیاید. self.assertTrue(statement["customer"]["account_code"]) def test_statement_reflects_installment_payments(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "buyer_phone": "09121112222", "payment_type": Invoice.PaymentType.CREDIT, }, ) entry = LedgerEntry.objects.get(user=self.user, category=LedgerEntry.Category.SALE_CREDIT) LedgerInstallment.objects.create( id=str(uuid.uuid4()), ledger_entry=entry, amount=40, paid_date=date.today() ) LedgerService.recompute_customer_balance(self.user, "09121112222") self.customer.refresh_from_db() statement = CustomerStatementService.build(self.user, self.customer) self.assertEqual(statement["debts"][0]["paid_amount"], 40) self.assertEqual(statement["debts"][0]["remaining_amount"], 60) self.assertEqual(len(statement["debts"][0]["installments"]), 1) class ExcelExportTests(TestCase): def setUp(self): self.user = _make_user() self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) self.product = Product.objects.create( id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=10, unit_price=100, purchase_price=60, total_purchase_value=600, ) def _workbook(self): from openpyxl import load_workbook content = ExcelExportService.build(self.user) return load_workbook(io.BytesIO(content)) def test_workbook_has_all_expected_sheets(self): wb = self._workbook() self.assertEqual( wb.sheetnames, ["خلاصه", "دفتر روزنامه", "تراز حساب‌ها", "بدهکاران", "فاکتورها", "دفتر معین", "انبار"], ) def test_sheets_are_right_to_left(self): wb = self._workbook() for name in wb.sheetnames: self.assertTrue(wb[name].sheet_view.rightToLeft, f"برگه‌ی {name} راست‌چین نیست") def test_inventory_sheet_lists_products(self): wb = self._workbook() rows = list(wb["انبار"].iter_rows(min_row=2, values_only=True)) self.assertEqual(len(rows), 1) self.assertEqual(rows[0][0], "کالا") def test_summary_reports_inventory_value(self): wb = self._workbook() summary = {r[0]: r[1] for r in wb["خلاصه"].iter_rows(min_row=2, values_only=True)} self.assertEqual(summary["ارزش موجودی انبار"], 600) def test_debtors_sheet_lists_only_customers_with_balance(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "buyer_phone": "09121112222", "payment_type": Invoice.PaymentType.CREDIT, }, ) wb = self._workbook() rows = list(wb["بدهکاران"].iter_rows(min_row=2, values_only=True)) self.assertEqual(len(rows), 1) self.assertEqual(rows[0][0], "علی") self.assertEqual(rows[0][3], 100) def test_invoice_sheet_lists_invoices(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 2, "unit_price": 100}], "customer": self.customer, "payment_type": Invoice.PaymentType.CASH, }, ) wb = self._workbook() rows = list(wb["فاکتورها"].iter_rows(min_row=2, values_only=True)) self.assertEqual(len(rows), 1) self.assertEqual(rows[0][6], 200) # مبلغ نهایی class InstallmentPostsTransactionTests(TestCase): """هر قسط باید علاوه بر به‌روزکردنِ مانده، یک سندِ حسابداریِ واقعی هم بسازد — وگرنه ترازِ حساب‌ها بعد از دریافتِ قسط غلط می‌ماند.""" def setUp(self): self.user = _make_user() self.client = APIClient() self.client.force_authenticate(self.user) 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", ) def test_installment_creates_balanced_transaction(self): response = self.client.post( reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}), {"amount": 400, "paid_date": date.today().isoformat()}, format="json", ) self.assertEqual(response.status_code, 201) txn = Transaction.objects.get(user=self.user, transaction_type=Transaction.TransactionType.INSTALLMENT) self.assertEqual(txn.amount, 400) # دریافتِ قسط: صندوق بدهکار، حساب دریافتنی بستانکار. self.assertEqual(txn.debit_account, ACCOUNT_CASH) self.assertEqual(txn.credit_account, ACCOUNT_RECEIVABLE) self.assertIsNotNone(txn.debit_account_ref) self.assertIsNotNone(txn.credit_account_ref) def test_installment_shows_up_in_trial_balance(self): self.client.post( reverse("bizapps:ledger-installment-list", kwargs={"entry_id": self.entry.id}), {"amount": 400, "paid_date": date.today().isoformat()}, format="json", ) rows = {r["code"]: r for r in ChartOfAccountsService.trial_balance(self.user)} self.assertEqual(rows[ACCOUNT_CASH]["debit"], 400) class BusinessDashboardTests(TestCase): """داشبورد، همان «اتصالِ بخش‌ها» را در یک پاسخ جمع می‌کند.""" def setUp(self): self.user = _make_user() self.client = APIClient() self.client.force_authenticate(self.user) self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) self.product = Product.objects.create( id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=2, unit_price=100, purchase_price=60, total_purchase_value=120, low_stock_threshold=5, ) def test_dashboard_aggregates_across_modules(self): InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": 1, "unit_price": 100}], "customer": self.customer, "buyer_phone": "09121112222", "payment_type": Invoice.PaymentType.CREDIT, }, ) response = self.client.get(reverse("bizapps:dashboard")) self.assertEqual(response.status_code, 200) data = response.data self.assertEqual(data["open_receivable"], 100) self.assertEqual(data["invoice_count"], 1) self.assertEqual(data["customer_count"], 1) self.assertEqual(data["debtor_count"], 1) # موجودی از ۲ به ۱ رسیده و زیرِ آستانه‌ی ۵ است. self.assertEqual(data["low_stock_count"], 1) class NegativeStockGuardTests(TestCase): """سیاستِ صریحِ موجودی: فروشِ بیش از موجودی رد می‌شود و هیچ اثری (نه فاکتور، نه سند، نه کسرِ موجودی) باقی نمی‌گذارد.""" def setUp(self): self.user = _make_user() self.client = APIClient() self.client.force_authenticate(self.user) self.product = Product.objects.create( id=str(uuid.uuid4()), user=self.user, name="کالا", unit="عدد", quantity=2, unit_price=100, purchase_price=60, ) def test_selling_more_than_stock_is_rejected(self): from apps.bizapps.api.services import InsufficientStockError with self.assertRaises(InsufficientStockError): InvoiceService.create_invoice( self.user, {"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]}, ) def test_rejected_sale_leaves_no_partial_data(self): from apps.bizapps.api.services import InsufficientStockError with self.assertRaises(InsufficientStockError): InvoiceService.create_invoice( self.user, {"items": [{"product": self.product, "quantity": 5, "unit_price": 100}]}, ) self.product.refresh_from_db() self.assertEqual(self.product.quantity, 2) self.assertEqual(Invoice.objects.filter(user=self.user).count(), 0) self.assertEqual(Transaction.objects.filter(user=self.user).count(), 0) def test_selling_exactly_the_remaining_stock_is_allowed(self): InvoiceService.create_invoice( self.user, {"items": [{"product": self.product, "quantity": 2, "unit_price": 100}]}, ) self.product.refresh_from_db() self.assertEqual(self.product.quantity, 0) def test_api_returns_readable_400_not_500(self): response = self.client.post( reverse("bizapps:invoice-list"), {"items": [{"product": self.product.id, "quantity": 5, "unit_price": 100}]}, format="json", ) self.assertEqual(response.status_code, 400) self.assertIn("موجودی", str(response.data)) class CustomerSocialFieldTests(TestCase): """راهِ ارتباطیِ شبکه‌ی اجتماعی — برای مخاطبِ اصلیِ این اپ (کسب‌وکارهای اینستاگرامی/تلگرامی) کاربردی‌تر از ایمیل است.""" def setUp(self): self.user = _make_user() self.client = APIClient() self.client.force_authenticate(self.user) def test_customer_can_store_platform_and_handle(self): response = self.client.post( reverse("bizapps:customer-list"), { "id": str(uuid.uuid4()), "name": "فروشگاه گلها", "phone": "09121112222", "social_platform": Customer.SocialPlatform.INSTAGRAM, "social_handle": "@golha.shop", }, format="json", ) self.assertEqual(response.status_code, 201) customer = Customer.objects.get(user=self.user) self.assertEqual(customer.social_platform, "instagram") self.assertEqual(customer.social_handle, "@golha.shop") def test_social_fields_are_optional(self): response = self.client.post( reverse("bizapps:customer-list"), {"id": str(uuid.uuid4()), "name": "بدون شبکه اجتماعی"}, format="json", ) self.assertEqual(response.status_code, 201) def test_invalid_platform_is_rejected(self): response = self.client.post( reverse("bizapps:customer-list"), { "id": str(uuid.uuid4()), "name": "تست", "social_platform": "myspace", }, format="json", ) self.assertEqual(response.status_code, 400) class LedgerBookTests(TestCase): """«فایل» دفتر — باز کردن فایل تازه، بستن فایل قبلی، انتقال مانده.""" def setUp(self): self.user = _make_user() self.client = APIClient() self.client.force_authenticate(self.user) def _entry(self, amount, entry_type=LedgerEntry.EntryType.INCOME, book=None): return LedgerEntry.objects.create( id=str(uuid.uuid4()), user=self.user, book=book, entry_type=entry_type, title="ثبت", amount=amount, category=LedgerEntry.Category.SALE_CASH, entry_date=date.today(), ) def test_active_book_is_created_on_demand(self): book = LedgerBookService.ensure_active_book(self.user) self.assertTrue(book.is_active) self.assertFalse(book.is_closed) self.assertEqual(LedgerBook.objects.filter(user=self.user).count(), 1) def test_ensure_active_book_is_idempotent(self): first = LedgerBookService.ensure_active_book(self.user) second = LedgerBookService.ensure_active_book(self.user) self.assertEqual(first.id, second.id) def test_orphan_entries_are_adopted_by_the_first_book(self): orphan = self._entry(500) self.assertIsNone(orphan.book_id) book = LedgerBookService.ensure_active_book(self.user) orphan.refresh_from_db() self.assertEqual(orphan.book_id, book.id) def test_opening_a_new_book_deactivates_but_does_not_archive_the_previous_one(self): """بایگانی‌کردن یک اقدامِ دستیِ جداگانه است (LedgerBookService. archive_book) — باز کردنِ فایلِ تازه فقط فایلِ قبلی را از حالتِ «جاری» خارج می‌کند، نه این‌که خودکار بایگانی‌اش کند.""" old = LedgerBookService.ensure_active_book(self.user) new = LedgerBookService.open_new_book( self.user, name="سال ۱۴۰۶", start_date=date(2027, 3, 21) ) old.refresh_from_db() self.assertFalse(old.is_active) self.assertFalse(old.is_closed) self.assertIsNone(old.closed_at) self.assertTrue(new.is_active) # همیشه دقیقا یک فایل جاری self.assertEqual(LedgerBook.objects.filter(user=self.user, is_active=True).count(), 1) def test_balance_is_carried_into_the_new_book(self): old = LedgerBookService.ensure_active_book(self.user) self._entry(1000, book=old) self._entry(300, entry_type=LedgerEntry.EntryType.EXPENSE, book=old) new = LedgerBookService.open_new_book( self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=True ) self.assertEqual(new.opening_balance, 700) def test_balance_is_not_carried_when_not_requested(self): old = LedgerBookService.ensure_active_book(self.user) self._entry(1000, book=old) new = LedgerBookService.open_new_book( self.user, name="دوره بعد", start_date=date(2027, 3, 21), carry_balance=False ) self.assertEqual(new.opening_balance, 0) def test_summary_includes_opening_and_closing(self): book = LedgerBookService.ensure_active_book(self.user) book.opening_balance = 500 book.save() self._entry(1000, book=book) self._entry(200, entry_type=LedgerEntry.EntryType.EXPENSE, book=book) summary = LedgerBookService.summary(book) self.assertEqual(summary["opening_balance"], 500) self.assertEqual(summary["total_income"], 1000) self.assertEqual(summary["total_expense"], 200) self.assertEqual(summary["net"], 800) self.assertEqual(summary["closing_balance"], 1300) def test_new_entries_go_into_the_active_book(self): book = LedgerBookService.ensure_active_book(self.user) response = self.client.post( reverse("bizapps:ledger-list"), { "id": str(uuid.uuid4()), "entry_type": LedgerEntry.EntryType.INCOME, "title": "فروش", "amount": 400, "category": LedgerEntry.Category.SALE_CASH, "entry_date": date.today().isoformat(), }, format="json", ) self.assertEqual(response.status_code, 201) self.assertEqual(LedgerEntry.objects.get(user=self.user).book_id, book.id) def test_entry_list_can_be_scoped_to_one_book(self): """قراردادی که اپ به آن تکیه می‌کند: ?book= فقط ثبت‌های همان دوره را برمی‌گرداند. بدون این فیلتر، صفحه‌ی دفتر ثبت‌های همه‌ی دوره‌ها را با هم نشان می‌داد و جمعِ درآمد/هزینه‌ی بالای صفحه با مانده‌ی فایلِ جاری نمی‌خواند.""" old = LedgerBookService.ensure_active_book(self.user) self._entry(1000, book=old) new_book = LedgerBookService.open_new_book( self.user, name="دوره بعد", start_date=date(2027, 3, 21) ) self._entry(250, book=new_book) url = reverse("bizapps:ledger-list") scoped = self.client.get(url, {"book": new_book.id}) self.assertEqual(scoped.status_code, 200) self.assertEqual([e["amount"] for e in scoped.data], [250]) # بدون پارامتر، همه‌ی دوره‌ها — برای گزارش‌های چنددوره‌ای. everything = self.client.get(url) self.assertEqual(len(everything.data), 2) def test_scoped_summary_ignores_the_other_book(self): old = LedgerBookService.ensure_active_book(self.user) self._entry(1000, book=old) new_book = LedgerBookService.open_new_book( self.user, name="دوره بعد", start_date=date(2027, 3, 21) ) self._entry(250, book=new_book) response = self.client.get( reverse("bizapps:ledger-book-summary", kwargs={"pk": new_book.id}) ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["total_income"], 250) # مانده‌ی ابتدا از فایل قبل منتقل شده، پس مانده‌ی پایانی ۱۲۵۰ است. self.assertEqual(response.data["opening_balance"], 1000) self.assertEqual(response.data["closing_balance"], 1250) def test_open_book_endpoint_rejects_end_before_start(self): response = self.client.post( reverse("bizapps:ledger-book-open"), {"name": "بد", "start_date": "2027-03-21", "end_date": "2026-01-01"}, format="json", ) self.assertEqual(response.status_code, 400) def test_book_list_always_returns_at_least_one(self): response = self.client.get(reverse("bizapps:ledger-book-list")) self.assertEqual(response.status_code, 200) self.assertGreaterEqual(len(response.data), 1) class SubsidiaryLedgerTests(TestCase): """دفتر معین — گردشِ یک حساب با مانده‌ی در جریان، همان چیزی که حسابدار انتظار دارد ببیند.""" def setUp(self): self.user = _make_user() self.client = APIClient() self.client.force_authenticate(self.user) self.customer = Customer.objects.create( id=str(uuid.uuid4()), user=self.user, name="علی", phone="09121112222" ) self.product = Product.objects.create( id=str(uuid.uuid4()), user=self.user, name="کالا", quantity=100, unit_price=100, purchase_price=60, ) ChartOfAccountsService.ensure_chart(self.user) self.cash = Account.objects.get(user=self.user, code=ACCOUNT_CASH) self.sales = Account.objects.get(user=self.user, code=ACCOUNT_SALES) def _sale(self, qty=1, price=100, credit=False): return InvoiceService.create_invoice( self.user, { "items": [{"product": self.product, "quantity": qty, "unit_price": price}], "customer": self.customer, "buyer_phone": "09121112222", "payment_type": Invoice.PaymentType.CREDIT if credit else Invoice.PaymentType.CASH, }, ) def test_statement_lists_rows_with_running_balance(self): # قیمتِ واحد عمدا از خودِ کالا برداشته می‌شود (ضدِ دستکاری از سمت # کلاینت)، پس هر فروشِ یک‌عددی ۱۰۰ است — مانده باید تجمعی جلو برود. self._sale() self._sale() statement = SubsidiaryLedgerService.statement(self.user, self.cash) balances = [r["balance"] for r in statement["rows"]] self.assertEqual(len(statement["rows"]), 2) self.assertEqual(balances, [100, 200]) self.assertEqual(statement["closing_balance"], 200) def test_debit_and_credit_columns_are_separated(self): self._sale(price=100) cash_stmt = SubsidiaryLedgerService.statement(self.user, self.cash) sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales) self.assertEqual(cash_stmt["rows"][0]["debit"], 100) self.assertEqual(cash_stmt["rows"][0]["credit"], 0) self.assertEqual(sales_stmt["rows"][0]["credit"], 100) self.assertEqual(sales_stmt["rows"][0]["debit"], 0) def test_credit_nature_account_balance_grows_on_credit(self): self._sale(price=100) sales_stmt = SubsidiaryLedgerService.statement(self.user, self.sales) # فروش ماهیت بستانکار دارد؛ مانده باید مثبت باشد نه منفی. self.assertEqual(sales_stmt["closing_balance"], 100) self.assertFalse(sales_stmt["account"]["is_debit_nature"]) def test_totals_match_rows(self): self._sale() self._sale() statement = SubsidiaryLedgerService.statement(self.user, self.cash) self.assertEqual(statement["total_debit"], 200) self.assertEqual(statement["total_credit"], 0) self.assertEqual( statement["total_debit"], sum(r["debit"] for r in statement["rows"]) ) def test_empty_account_returns_empty_statement(self): statement = SubsidiaryLedgerService.statement(self.user, self.cash) self.assertEqual(statement["rows"], []) self.assertEqual(statement["closing_balance"], 0) def test_api_endpoint_returns_statement(self): self._sale(price=100) response = self.client.get( reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": self.cash.id}) ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["account"]["code"], ACCOUNT_CASH) self.assertEqual(len(response.data["rows"]), 1) def test_other_users_account_is_not_reachable(self): other = _make_user(phone="09129998888") ChartOfAccountsService.ensure_chart(other) foreign = Account.objects.get(user=other, code=ACCOUNT_CASH) response = self.client.get( reverse("bizapps:account-subsidiary-ledger", kwargs={"pk": foreign.id}) ) self.assertEqual(response.status_code, 404)