297 lines
12 KiB
Python
297 lines
12 KiB
Python
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"])
|