This commit is contained in:
2026-08-31 15:11:52 +03:30
parent 236002a87c
commit 463b812e3e
63 changed files with 3254 additions and 1476 deletions
+412
View File
@@ -0,0 +1,412 @@
import csv
import io
import logging
import uuid
from django.db import transaction
from django.utils import timezone
from apps.account.models import User
from apps.banker.models import Contact
from apps.bizapps.models import (
Customer,
Invoice,
InvoiceItem,
LedgerEntry,
LedgerInstallment,
Product,
StockMovement,
Transaction,
)
logger = logging.getLogger(__name__)
# کدهای حساب — دفترکل ساده طبق سند فنی (بدون نیاز به جدول Chart of Accounts
# جدا، چون این اپ برای کسب‌وکارهای خرد است نه حسابداری شرکتی کامل).
ACCOUNT_CASH = "1000" # صندوق
ACCOUNT_RECEIVABLE = "1100" # مطالبات (فروش اعتباری — نسخه‌ی آتی)
ACCOUNT_SALES = "4000" # فروش
class InvoiceService:
@staticmethod
def _next_sequenced_number(user: User, model, field_name: str, prefix: str) -> str:
"""فرمت PREFIX-YYMM-NNNN با سکانسِ ماهانه‌ی جداگانه برای هر کاربر —
سطر آخرِ همان ماه را قفل می‌کند (select_for_update) تا دو درخواستِ
هم‌زمان شماره‌ی تکراری نگیرند."""
now = timezone.now()
month_prefix = f"{prefix}-{now:%y%m}-"
last = (
model.objects.select_for_update()
.filter(user=user, **{f"{field_name}__startswith": month_prefix})
.order_by(f"-{field_name}")
.first()
)
if last:
last_seq = int(getattr(last, field_name).rsplit("-", 1)[-1])
else:
last_seq = 0
return f"{month_prefix}{last_seq + 1:04d}"
@staticmethod
def _upsert_customer_contact(user: User, phone: str, name: str) -> Contact | None:
phone = (phone or "").strip()
if not phone:
return None
contact = Contact.objects.filter(user=user, phone=phone).first()
if contact:
return contact
return Contact.objects.create(id=str(uuid.uuid4()), user=user, name=name or phone, phone=phone)
@staticmethod
@transaction.atomic
def create_invoice(user: User, data: dict, send_sms: bool = False) -> Invoice:
items_data = data["items"]
invoice_number = InvoiceService._next_sequenced_number(user, Invoice, "invoice_number", "INV")
customer: Customer | None = data.get("customer")
payment_type = data.get("payment_type", Invoice.PaymentType.CASH)
order_status = (
Invoice.OrderStatus.PENDING_PAYMENT
if payment_type == Invoice.PaymentType.CREDIT
else Invoice.OrderStatus.READY_TO_SHIP
)
invoice = Invoice.objects.create(
id=str(uuid.uuid4()),
user=user,
invoice_number=invoice_number,
invoice_date=data.get("invoice_date") or timezone.now(),
customer=customer,
buyer_name=data.get("buyer_name", "") or (customer.name if customer else ""),
buyer_phone=data.get("buyer_phone", "") or (customer.phone if customer else ""),
buyer_address=data.get("buyer_address", "") or (customer.address if customer else ""),
seller_name=data.get("seller_name", ""),
seller_phone=data.get("seller_phone", ""),
seller_address=data.get("seller_address", ""),
discount_amount=data.get("discount_amount", 0),
tax_lines=data.get("tax_lines", []),
payment_type=payment_type,
shipping_method=data.get("shipping_method", Invoice.ShippingMethod.PICKUP),
order_status=order_status,
tracking_code=data.get("tracking_code", ""),
notes=data.get("notes", ""),
)
subtotal = 0.0
for item_data in items_data:
product: Product | None = item_data.get("product")
quantity = item_data["quantity"]
# طبق سند: قیمت واحد در زمان فروش از SellingPrice کالا کپی می‌شود
# (سمت کلاینت قابل‌دستکاری نیست) — فقط برای ردیف‌های آزاد (بدون
# کالا) قیمتِ ارسالی کلاینت به‌عنوان قیمت واحد پذیرفته می‌شود.
unit_price = product.unit_price if product else item_data["unit_price"]
description = item_data.get("description") or (product.name if product else "")
item = InvoiceItem(
id=str(uuid.uuid4()),
invoice=invoice,
product=product,
description=description,
quantity=quantity,
unit_price=unit_price,
discount_percent=item_data.get("discount_percent", 0),
)
item.compute_total()
item.save()
subtotal += item.total_price
if product is not None:
product = Product.objects.select_for_update().get(pk=product.pk)
product.quantity -= quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=-quantity,
quantity_after=product.quantity,
note=f"فروش فاکتور {invoice_number}",
)
tax_amount = sum(subtotal * float(line.get("percent", 0)) / 100 for line in invoice.tax_lines)
invoice.total_amount = subtotal
invoice.final_amount = subtotal - invoice.discount_amount + tax_amount
invoice.save(update_fields=["total_amount", "final_amount"])
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
is_credit = payment_type == Invoice.PaymentType.CREDIT
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=transaction_number,
transaction_date=invoice.invoice_date,
reference_invoice=invoice,
transaction_type=(
Transaction.TransactionType.CREDIT_SALE if is_credit else Transaction.TransactionType.CASH_SALE
),
debit_account=ACCOUNT_RECEIVABLE if is_credit else ACCOUNT_CASH,
credit_account=ACCOUNT_SALES,
amount=invoice.final_amount,
description=f"فروش فاکتور {invoice_number}",
)
if customer is not None:
customer = Customer.objects.select_for_update().get(pk=customer.pk)
customer.total_purchases_count += 1
customer.total_purchase_amount += invoice.final_amount
customer.last_purchase_date = invoice.invoice_date
customer.save(
update_fields=["total_purchases_count", "total_purchase_amount", "last_purchase_date", "updated_at"]
)
# طبق درخواست: فروشِ قسطی/نسیه باید خودکار به دفتر درآمد/هزینه به‌عنوان
# یک ردیفِ بدهی‌دار (نه صرفا یک عدد در لیست فاکتورها) وصل شود — همان
# مثالِ «۱۰۰ تومن فروش قسطی از نظر عادی درآمد، از نظر حرفه‌ای بدهکاریه».
if is_credit:
LedgerEntry.objects.create(
id=str(uuid.uuid4()),
user=user,
entry_type=LedgerEntry.EntryType.INCOME,
title=f"فاکتور {invoice_number}",
amount=invoice.final_amount,
category=LedgerEntry.Category.SALE_CREDIT,
entry_date=invoice.invoice_date.date(),
total_amount=invoice.final_amount,
counterparty_name=invoice.buyer_name,
counterparty_phone=invoice.buyer_phone,
linked_invoice=invoice,
)
if invoice.buyer_phone:
LedgerService.recompute_customer_balance(user, invoice.buyer_phone)
if send_sms and invoice.buyer_phone:
InvoiceService._send_invoice_sms(user, invoice)
logger.info(
"invoice created: user=%s invoice=%s final_amount=%s transaction=%s",
user.phone,
invoice_number,
invoice.final_amount,
transaction_number,
)
return invoice
@staticmethod
def _send_invoice_sms(user: User, invoice: Invoice) -> None:
# ایمپورت دیرهنگام برای جلوگیری از وابستگی چرخه‌ای در import-time
# بین اپ‌های bizapps و messaging.
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
if contact is None:
return
body = f"فاکتور {invoice.invoice_number} به مبلغ {invoice.final_amount:,.0f} تومان ثبت شد."
BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=body,
)
@staticmethod
@transaction.atomic
def cancel_invoice(user: User, invoice_id: str) -> Invoice:
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
if invoice.order_status == Invoice.OrderStatus.CANCELLED:
raise ValueError("این فاکتور قبلا لغو شده است.")
for item in invoice.items.select_related("product"):
if item.product is None:
continue
product = Product.objects.select_for_update().get(pk=item.product_id)
product.quantity += item.quantity
product.recompute_purchase_value()
product.save(update_fields=["quantity", "total_purchase_value", "updated_at"])
StockMovement.objects.create(
product=product,
delta=item.quantity,
quantity_after=product.quantity,
note=f"لغو فاکتور {invoice.invoice_number}",
)
# ردیفِ دفترِ بدهی‌دارِ متصل به این فاکتور: اگر هنوز هیچ قسطی روی آن
# ثبت نشده، با لغوِ فاکتور آن بدهی هم دیگر معنی ندارد — حذفش می‌کنیم.
# اگر قسطی ثبت شده (یعنی پولی واقعا رد و بدل شده)، تاریخچه‌ی مالی را
# پاک نمی‌کنیم؛ همان‌جا برای رسیدگیِ دستیِ کاربر می‌ماند.
linked_entry = invoice.ledger_entries.first()
if linked_entry is not None and not linked_entry.installments.exists():
phone = linked_entry.counterparty_phone
linked_entry.delete()
if phone:
LedgerService.recompute_customer_balance(user, phone)
original = invoice.transactions.order_by("created_at").first()
transaction_number = InvoiceService._next_sequenced_number(user, Transaction, "transaction_number", "TRX")
Transaction.objects.create(
id=str(uuid.uuid4()),
user=user,
transaction_number=transaction_number,
transaction_date=timezone.now(),
reference_invoice=invoice,
transaction_type=original.transaction_type if original else Transaction.TransactionType.CASH_SALE,
debit_account=original.credit_account if original else ACCOUNT_SALES,
credit_account=original.debit_account if original else ACCOUNT_CASH,
amount=-(original.amount if original else invoice.final_amount),
description=f"لغو فاکتور {invoice.invoice_number}",
)
invoice.order_status = Invoice.OrderStatus.CANCELLED
invoice.save(update_fields=["order_status", "updated_at"])
logger.info(
"invoice cancelled: user=%s invoice=%s reversal_transaction=%s",
user.phone,
invoice.invoice_number,
transaction_number,
)
return invoice
@staticmethod
@transaction.atomic
def update_status(
user: User,
invoice_id: str,
order_status: str | None = None,
payment_status: str | None = None,
tracking_code: str | None = None,
) -> Invoice:
invoice = Invoice.objects.select_for_update().get(pk=invoice_id, user=user)
update_fields = ["updated_at"]
status_changed = order_status is not None and order_status != invoice.order_status
if order_status is not None:
invoice.order_status = order_status
update_fields.append("order_status")
if payment_status is not None:
invoice.payment_status = payment_status
if payment_status == Invoice.PaymentStatus.PAID and invoice.paid_at is None:
invoice.paid_at = timezone.now()
update_fields.append("paid_at")
update_fields.append("payment_status")
if tracking_code is not None:
invoice.tracking_code = tracking_code
update_fields.append("tracking_code")
invoice.save(update_fields=update_fields)
logger.info(
"invoice status updated: user=%s invoice=%s order_status=%s payment_status=%s",
user.phone,
invoice.invoice_number,
invoice.order_status,
invoice.payment_status,
)
# اگر فاکتور به «پرداخت‌شده» رفت و به یک ردیفِ بدهی‌دار وصل بود، همان
# مانده را به‌عنوان یک قسطِ تسویه‌ی کامل ثبت می‌کنیم — کاربر مجبور
# نیست دستی برود توی دفتر و قسط بزند.
if payment_status == Invoice.PaymentStatus.PAID:
linked_entry = invoice.ledger_entries.first()
if linked_entry is not None and linked_entry.remaining_amount > 0:
LedgerInstallment.objects.create(
id=str(uuid.uuid4()),
ledger_entry=linked_entry,
amount=linked_entry.remaining_amount,
paid_date=timezone.now().date(),
reference=f"تسویه‌ی کامل فاکتور {invoice.invoice_number}",
)
if linked_entry.counterparty_phone:
LedgerService.recompute_customer_balance(user, linked_entry.counterparty_phone)
# طبق سند: «پس از هر تغییر وضعیت سفارش، یک پیامک ارسال شود» — برخلاف
# پیامکِ ثبت فاکتور (که opt-in است)، این یکی خودکار است، چون همان
# رفتاری‌ست که apps.orders از قبل برای مراحل پیگیری سفارش دارد و
# کاربر با آن آشناست.
if status_changed and invoice.buyer_phone:
InvoiceService._send_status_sms(user, invoice)
return invoice
@staticmethod
def _send_status_sms(user: User, invoice: Invoice) -> None:
from apps.messaging.api.services import BulkMessageService
from apps.messaging.models import Channel
contact = InvoiceService._upsert_customer_contact(user, invoice.buyer_phone, invoice.buyer_name)
if contact is None:
return
status_label = Invoice.OrderStatus(invoice.order_status).label
body = f"وضعیت سفارش فاکتور {invoice.invoice_number}: {status_label}"
BulkMessageService.create_and_dispatch(
user=user,
channels=[Channel.SMS],
contact_ids=[contact.id],
body_override=body,
)
class LedgerService:
"""هماهنگیِ لایه‌ی «عادی» (دسته‌بندی ساده) با لایه‌ی «حرفه‌ای» (بدهکاری/
بستانکاری واقعی) — طبق درخواست: کاربرِ معمولی فقط دسته را انتخاب می‌کند،
این سرویس تضمین می‌کند مانده‌ی بدهیِ هر مشتری (Customer.outstanding_balance)
همیشه با جمعِ واقعیِ ردیف‌های «فروش قسطی»ِ همان شماره‌تماس هم‌خوان بماند."""
@staticmethod
def recompute_customer_balance(user: User, phone: str) -> None:
phone = (phone or "").strip()
if not phone:
return
customer = Customer.objects.filter(user=user, phone=phone).first()
if customer is None:
return
receivables = LedgerEntry.objects.filter(
user=user, category=LedgerEntry.Category.SALE_CREDIT, counterparty_phone=phone
).prefetch_related("installments")
balance = sum(e.remaining_amount for e in receivables)
if customer.outstanding_balance != balance:
customer.outstanding_balance = balance
customer.save(update_fields=["outstanding_balance", "updated_at"])
@staticmethod
def export_csv(user: User, start=None, end=None) -> str:
"""خروجیِ CSV حرفه‌ای — UTF-8 BOM برای بازشدنِ درستِ فارسی در اکسل."""
qs = LedgerEntry.objects.filter(user=user).order_by("entry_date").prefetch_related("installments")
if start:
qs = qs.filter(entry_date__gte=start)
if end:
qs = qs.filter(entry_date__lte=end)
buf = io.StringIO()
buf.write("") # BOM
writer = csv.writer(buf)
writer.writerow(
[
"تاریخ",
"نوع",
"دسته",
"عنوان",
"مبلغ",
"طرف حساب",
"شماره طرف حساب",
"مبلغ کل تعهد",
"پرداخت‌شده",
"مانده",
"یادداشت",
]
)
for e in qs:
writer.writerow(
[
e.entry_date.isoformat(),
e.get_entry_type_display(),
e.get_category_display() if e.category else "",
e.title,
f"{e.amount:.0f}",
e.counterparty_name,
e.counterparty_phone,
f"{e.total_amount:.0f}" if e.is_debt else "",
f"{e.paid_amount:.0f}" if e.is_debt else "",
f"{e.remaining_amount:.0f}" if e.is_debt else "",
e.note,
]
)
return buf.getvalue()
-92
View File
@@ -1,92 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 11:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Appointment',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('customer_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
('customer_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
('title', models.CharField(max_length=150, verbose_name='عنوان/خدمت')),
('start_at', models.DateTimeField(verbose_name='زمان نوبت')),
('status', models.CharField(choices=[('scheduled', 'زمان\u200cبندی\u200cشده'), ('done', 'انجام\u200cشده'), ('cancelled', 'لغوشده')], default='scheduled', max_length=10)),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appointments', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'نوبت',
'verbose_name_plural': 'نوبت\u200cدهی',
'ordering': ['start_at'],
},
),
migrations.CreateModel(
name='Customer',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام')),
('phone', models.CharField(blank=True, max_length=20, verbose_name='شماره تماس')),
('email', models.CharField(blank=True, max_length=150, verbose_name='ایمیل')),
('address', models.CharField(blank=True, max_length=255, verbose_name='آدرس')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'مشتری',
'verbose_name_plural': 'مشتریان',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='LedgerEntry',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('entry_type', models.CharField(choices=[('income', 'درآمد'), ('expense', 'هزینه')], max_length=10, verbose_name='نوع')),
('title', models.CharField(max_length=150, verbose_name='عنوان')),
('amount', models.FloatField(verbose_name='مبلغ')),
('category', models.CharField(blank=True, max_length=100, verbose_name='دسته')),
('entry_date', models.DateField(verbose_name='تاریخ')),
('note', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ledger_entries', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'ردیف دفتر درآمد/هزینه',
'verbose_name_plural': 'دفتر درآمد و هزینه',
'ordering': ['-entry_date'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150, verbose_name='نام کالا')),
('sku', models.CharField(blank=True, max_length=100, verbose_name='کد کالا')),
('quantity', models.FloatField(default=0, verbose_name='موجودی')),
('unit_price', models.FloatField(default=0, verbose_name='قیمت واحد')),
('low_stock_threshold', models.FloatField(default=0, verbose_name='آستانه\u200cی هشدار موجودی کم')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'کالا',
'verbose_name_plural': 'انبار',
'ordering': ['name'],
},
),
]
@@ -1,38 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 12:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BrokerageTransaction',
fields=[
('id', models.CharField(max_length=36, primary_key=True, serialize=False)),
('client_name', models.CharField(max_length=150, verbose_name='نام مشتری')),
('client_phone', models.CharField(blank=True, max_length=20, verbose_name='شماره مشتری')),
('symbol', models.CharField(max_length=50, verbose_name='نماد')),
('side', models.CharField(choices=[('buy', 'خرید'), ('sell', 'فروش')], max_length=4, verbose_name='نوع معامله')),
('quantity', models.FloatField(verbose_name='تعداد')),
('price', models.FloatField(verbose_name='قیمت واحد')),
('commission_percent', models.FloatField(default=0, verbose_name='درصد کارمزد')),
('transaction_date', models.DateField(verbose_name='تاریخ')),
('notes', models.TextField(blank=True, verbose_name='یادداشت')),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='brokerage_transactions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'معامله\u200cی بورسی',
'verbose_name_plural': 'کارگزاری',
'ordering': ['-transaction_date'],
},
),
]
@@ -1,16 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 14:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0002_brokeragetransaction'),
]
operations = [
migrations.DeleteModel(
name='BrokerageTransaction',
),
]
@@ -1,35 +0,0 @@
# Generated by Django 5.1.15 on 2026-08-23 20:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bizapps', '0003_delete_brokeragetransaction'),
]
operations = [
migrations.AddField(
model_name='product',
name='category',
field=models.CharField(blank=True, max_length=100, verbose_name='دسته'),
),
migrations.CreateModel(
name='StockMovement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('delta', models.FloatField(verbose_name='تغییر')),
('quantity_after', models.FloatField(verbose_name='موجودی بعد از تغییر')),
('note', models.CharField(blank=True, max_length=255, verbose_name='یادداشت')),
('created_at', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='bizapps.product')),
],
options={
'verbose_name': 'تغییر موجودی',
'verbose_name_plural': 'تاریخچه\u200cی موجودی',
'ordering': ['-created_at'],
},
),
]
+296
View File
@@ -0,0 +1,296 @@
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"])