From 5fb79fb7f06bbbbd6f9d6b0d55390c76dc4ccac5 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Fri, 3 Jul 2026 03:15:52 +0330 Subject: [PATCH] Add sales types, party adjustments, FunZone party fields, and treasury fee support Co-authored-by: Cursor --- accounting/migrations/0003_sales_module.py | 78 ++++++++++++++++ .../migrations/0004_party_funzone_fields.py | 84 +++++++++++++++++ accounting/migrations/0005_treasury_source.py | 16 ++++ .../migrations/0006_treasury_fee_fields.py | 26 ++++++ accounting/models.py | 90 +++++++++++++++++++ accounting/serializers.py | 53 ++++++++++- accounting/urls.py | 4 + accounting/views.py | 18 ++++ 8 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 accounting/migrations/0003_sales_module.py create mode 100644 accounting/migrations/0004_party_funzone_fields.py create mode 100644 accounting/migrations/0005_treasury_source.py create mode 100644 accounting/migrations/0006_treasury_fee_fields.py diff --git a/accounting/migrations/0003_sales_module.py b/accounting/migrations/0003_sales_module.py new file mode 100644 index 0000000..edcbe39 --- /dev/null +++ b/accounting/migrations/0003_sales_module.py @@ -0,0 +1,78 @@ +# Generated manually for sales module extensions + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0002_seed_chart_of_accounts'), + ] + + operations = [ + migrations.CreateModel( + name='SalesType', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(db_index=True, max_length=50)), + ('name', models.CharField(max_length=150)), + ('description', models.TextField(blank=True, default='')), + ('active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'accounting_sales_types', + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='PartyAdjustment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('number', models.PositiveIntegerField(default=0)), + ('kind', models.CharField(choices=[('debit', 'Debit'), ('credit', 'Credit')], max_length=20)), + ('date', models.DateField()), + ('amount', models.FloatField(default=0)), + ('description', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('party', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adjustments', to='accounting.party')), + ], + options={ + 'db_table': 'accounting_party_adjustments', + 'ordering': ['-date', '-number'], + }, + ), + migrations.AddField( + model_name='invoice', + name='document_type', + field=models.CharField( + choices=[('proforma', 'Proforma'), ('invoice', 'Invoice'), ('return', 'Return')], + default='invoice', + max_length=20, + ), + ), + migrations.AddField( + model_name='invoice', + name='related_invoice', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='derived_docs', + to='accounting.invoice', + ), + ), + migrations.AddField( + model_name='invoice', + name='sales_type', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='invoices', + to='accounting.salestype', + ), + ), + ] diff --git a/accounting/migrations/0004_party_funzone_fields.py b/accounting/migrations/0004_party_funzone_fields.py new file mode 100644 index 0000000..02c3772 --- /dev/null +++ b/accounting/migrations/0004_party_funzone_fields.py @@ -0,0 +1,84 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0003_sales_module'), + ] + + operations = [ + migrations.AddField( + model_name='party', + name='external_id', + field=models.UUIDField(blank=True, db_index=True, null=True), + ), + migrations.AddField( + model_name='party', + name='external_source', + field=models.CharField(blank=True, default='', max_length=30), + ), + migrations.AddField( + model_name='party', + name='first_name', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='party', + name='last_name', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='party', + name='username', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='party', + name='email', + field=models.EmailField(blank=True, default='', max_length=254), + ), + migrations.AddField( + model_name='party', + name='national_code', + field=models.CharField(blank=True, default='', max_length=20), + ), + migrations.AddField( + model_name='party', + name='iban', + field=models.CharField(blank=True, default='', max_length=34), + ), + migrations.AddField( + model_name='party', + name='wallet_balance', + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name='party', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='party', + name='is_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='party', + name='birthday', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='party', + name='venues', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddConstraint( + model_name='party', + constraint=models.UniqueConstraint( + condition=models.Q(external_id__isnull=False), + fields=('external_id', 'external_source'), + name='unique_external_party', + ), + ), + ] diff --git a/accounting/migrations/0005_treasury_source.py b/accounting/migrations/0005_treasury_source.py new file mode 100644 index 0000000..e176cc6 --- /dev/null +++ b/accounting/migrations/0005_treasury_source.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0004_party_funzone_fields'), + ] + + operations = [ + migrations.AddField( + model_name='treasurytxn', + name='source', + field=models.CharField(blank=True, default='', max_length=120), + ), + ] diff --git a/accounting/migrations/0006_treasury_fee_fields.py b/accounting/migrations/0006_treasury_fee_fields.py new file mode 100644 index 0000000..489f852 --- /dev/null +++ b/accounting/migrations/0006_treasury_fee_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0005_treasury_source'), + ] + + operations = [ + migrations.AddField( + model_name='treasurytxn', + name='tax_amount', + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name='treasurytxn', + name='platform_profit_amount', + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name='treasurytxn', + name='owner_net_amount', + field=models.FloatField(default=0), + ), + ] diff --git a/accounting/models.py b/accounting/models.py index 7a7fcf8..c76f5ef 100644 --- a/accounting/models.py +++ b/accounting/models.py @@ -40,6 +40,11 @@ class Party(models.Model): ('customer', 'Customer'), ('supplier', 'Supplier'), ] + EXTERNAL_SOURCES = [ + ('', 'Manual'), + ('funzone_customer', 'FunZone Customer'), + ('funzone_owner', 'FunZone Owner'), + ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) kind = models.CharField(max_length=20, choices=PARTY_KINDS) @@ -48,11 +53,33 @@ class Party(models.Model): economic_code = models.CharField(max_length=50, blank=True, default='') address = models.TextField(blank=True, default='') opening_balance = models.FloatField(default=0) + external_id = models.UUIDField(null=True, blank=True, db_index=True) + external_source = models.CharField( + max_length=30, choices=EXTERNAL_SOURCES, blank=True, default='' + ) + first_name = models.CharField(max_length=100, blank=True, default='') + last_name = models.CharField(max_length=100, blank=True, default='') + username = models.CharField(max_length=100, blank=True, default='') + email = models.EmailField(blank=True, default='') + national_code = models.CharField(max_length=20, blank=True, default='') + iban = models.CharField(max_length=34, blank=True, default='') + wallet_balance = models.FloatField(default=0) + is_active = models.BooleanField(default=True) + is_verified = models.BooleanField(default=False) + birthday = models.DateField(null=True, blank=True) + venues = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'accounting_parties' ordering = ['name'] + constraints = [ + models.UniqueConstraint( + fields=['external_id', 'external_source'], + condition=models.Q(external_id__isnull=False), + name='unique_external_party', + ), + ] def __str__(self): return self.name @@ -120,6 +147,51 @@ class VoucherLine(models.Model): return f"Line {self.account_id} D:{self.debit} C:{self.credit}" +class SalesType(models.Model): + """A sales category (نوع فروش) referenced by sales documents.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + code = models.CharField(max_length=50, db_index=True) + name = models.CharField(max_length=150) + description = models.TextField(blank=True, default='') + active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_sales_types' + ordering = ['code'] + + def __str__(self): + return f"{self.code} - {self.name}" + + +class PartyAdjustment(models.Model): + """Debit/credit notice adjusting a customer receivable (اعلامیه بدهکار/بستانکار).""" + + ADJUSTMENT_KINDS = [ + ('debit', 'Debit'), + ('credit', 'Credit'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + number = models.PositiveIntegerField(default=0) + party = models.ForeignKey( + Party, on_delete=models.CASCADE, related_name='adjustments' + ) + kind = models.CharField(max_length=20, choices=ADJUSTMENT_KINDS) + date = models.DateField() + amount = models.FloatField(default=0) + description = models.TextField(blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'accounting_party_adjustments' + ordering = ['-date', '-number'] + + def __str__(self): + return f"{self.kind} notice #{self.number}" + + class Invoice(models.Model): """A sales or purchase invoice (فاکتور).""" @@ -127,6 +199,11 @@ class Invoice(models.Model): ('sale', 'Sale'), ('purchase', 'Purchase'), ] + DOCUMENT_TYPES = [ + ('proforma', 'Proforma'), + ('invoice', 'Invoice'), + ('return', 'Return'), + ] STATUS_CHOICES = [ ('draft', 'Draft'), ('confirmed', 'Confirmed'), @@ -135,10 +212,19 @@ class Invoice(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) kind = models.CharField(max_length=20, choices=INVOICE_KINDS) + document_type = models.CharField( + max_length=20, choices=DOCUMENT_TYPES, default='invoice' + ) number = models.PositiveIntegerField(default=0) party = models.ForeignKey( Party, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices' ) + sales_type = models.ForeignKey( + SalesType, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices' + ) + related_invoice = models.ForeignKey( + 'self', on_delete=models.SET_NULL, null=True, blank=True, related_name='derived_docs' + ) date = models.DateField() status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') note = models.TextField(blank=True, default='') @@ -194,8 +280,12 @@ class TreasuryTxn(models.Model): Party, on_delete=models.SET_NULL, null=True, blank=True, related_name='treasury_txns' ) amount = models.FloatField(default=0) + tax_amount = models.FloatField(default=0) + platform_profit_amount = models.FloatField(default=0) + owner_net_amount = models.FloatField(default=0) reference = models.CharField(max_length=120, blank=True, default='') description = models.TextField(blank=True, default='') + source = models.CharField(max_length=120, blank=True, default='') created_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/accounting/serializers.py b/accounting/serializers.py index 11e59f8..1ac1808 100644 --- a/accounting/serializers.py +++ b/accounting/serializers.py @@ -8,8 +8,10 @@ from .models import ( Invoice, InvoiceLine, Party, + PartyAdjustment, Payslip, Product, + SalesType, TreasuryTxn, Voucher, VoucherLine, @@ -31,10 +33,22 @@ class AccountSerializer(serializers.ModelSerializer): class PartySerializer(serializers.ModelSerializer): economicCode = serializers.CharField(source='economic_code', required=False, allow_blank=True) openingBalance = serializers.FloatField(source='opening_balance', required=False) + externalId = serializers.UUIDField(source='external_id', required=False, allow_null=True) + externalSource = serializers.CharField(source='external_source', required=False, allow_blank=True) + firstName = serializers.CharField(source='first_name', required=False, allow_blank=True) + lastName = serializers.CharField(source='last_name', required=False, allow_blank=True) + nationalCode = serializers.CharField(source='national_code', required=False, allow_blank=True) + walletBalance = serializers.FloatField(source='wallet_balance', required=False) + isActive = serializers.BooleanField(source='is_active', required=False) + isVerified = serializers.BooleanField(source='is_verified', required=False) class Meta: model = Party - fields = ['id', 'kind', 'name', 'phone', 'economicCode', 'address', 'openingBalance'] + fields = [ + 'id', 'kind', 'name', 'phone', 'economicCode', 'address', 'openingBalance', + 'externalId', 'externalSource', 'firstName', 'lastName', 'username', 'email', + 'nationalCode', 'iban', 'walletBalance', 'isActive', 'isVerified', 'birthday', 'venues', + ] class ProductSerializer(serializers.ModelSerializer): @@ -87,6 +101,20 @@ class VoucherSerializer(serializers.ModelSerializer): return instance +class SalesTypeSerializer(serializers.ModelSerializer): + class Meta: + model = SalesType + fields = ['id', 'code', 'name', 'description', 'active'] + + +class PartyAdjustmentSerializer(serializers.ModelSerializer): + partyId = serializers.PrimaryKeyRelatedField(source='party', queryset=Party.objects.all()) + + class Meta: + model = PartyAdjustment + fields = ['id', 'number', 'partyId', 'kind', 'date', 'amount', 'description'] + + class InvoiceLineSerializer(serializers.ModelSerializer): # Lenient: accepts client-generated ids (ignored on write, recreated server-side). id = serializers.CharField(required=False) @@ -105,11 +133,21 @@ class InvoiceSerializer(serializers.ModelSerializer): partyId = serializers.PrimaryKeyRelatedField( source='party', queryset=Party.objects.all(), allow_null=True, required=False ) + documentType = serializers.CharField(source='document_type', required=False) + salesTypeId = serializers.PrimaryKeyRelatedField( + source='sales_type', queryset=SalesType.objects.all(), allow_null=True, required=False + ) + relatedInvoiceId = serializers.PrimaryKeyRelatedField( + source='related_invoice', queryset=Invoice.objects.all(), allow_null=True, required=False + ) lines = InvoiceLineSerializer(many=True) class Meta: model = Invoice - fields = ['id', 'kind', 'number', 'partyId', 'date', 'status', 'note', 'lines'] + fields = [ + 'id', 'kind', 'documentType', 'number', 'partyId', 'salesTypeId', + 'relatedInvoiceId', 'date', 'status', 'note', 'lines', + ] @transaction.atomic def create(self, validated_data): @@ -139,9 +177,18 @@ class TreasuryTxnSerializer(serializers.ModelSerializer): source='party', queryset=Party.objects.all(), allow_null=True, required=False ) + amount = serializers.FloatField(required=False) + taxAmount = serializers.FloatField(source='tax_amount', required=False) + platformProfitAmount = serializers.FloatField(source='platform_profit_amount', required=False) + ownerNetAmount = serializers.FloatField(source='owner_net_amount', required=False) + class Meta: model = TreasuryTxn - fields = ['id', 'number', 'kind', 'method', 'date', 'partyId', 'amount', 'reference', 'description'] + fields = [ + 'id', 'number', 'kind', 'method', 'date', 'partyId', 'amount', + 'taxAmount', 'platformProfitAmount', 'ownerNetAmount', + 'reference', 'description', 'source', + ] class EmployeeSerializer(serializers.ModelSerializer): diff --git a/accounting/urls.py b/accounting/urls.py index 0a74378..6dcc3c4 100644 --- a/accounting/urls.py +++ b/accounting/urls.py @@ -6,9 +6,11 @@ from .views import ( CompanySettingsView, EmployeeViewSet, InvoiceViewSet, + PartyAdjustmentViewSet, PartyViewSet, PayslipViewSet, ProductViewSet, + SalesTypeViewSet, TreasuryTxnViewSet, VoucherViewSet, reset_accounting_data, @@ -20,6 +22,8 @@ router.register(r'parties', PartyViewSet, basename='accounting-party') router.register(r'products', ProductViewSet, basename='accounting-product') router.register(r'vouchers', VoucherViewSet, basename='accounting-voucher') router.register(r'invoices', InvoiceViewSet, basename='accounting-invoice') +router.register(r'sales-types', SalesTypeViewSet, basename='accounting-sales-type') +router.register(r'party-adjustments', PartyAdjustmentViewSet, basename='accounting-party-adjustment') router.register(r'treasury', TreasuryTxnViewSet, basename='accounting-treasury') router.register(r'employees', EmployeeViewSet, basename='accounting-employee') router.register(r'payslips', PayslipViewSet, basename='accounting-payslip') diff --git a/accounting/views.py b/accounting/views.py index 868f676..9428e82 100644 --- a/accounting/views.py +++ b/accounting/views.py @@ -10,8 +10,10 @@ from .models import ( Employee, Invoice, Party, + PartyAdjustment, Payslip, Product, + SalesType, TreasuryTxn, Voucher, ) @@ -22,9 +24,11 @@ from .serializers import ( CompanySettingsSerializer, EmployeeSerializer, InvoiceSerializer, + PartyAdjustmentSerializer, PartySerializer, PayslipSerializer, ProductSerializer, + SalesTypeSerializer, TreasuryTxnSerializer, VoucherSerializer, ) @@ -66,6 +70,18 @@ class InvoiceViewSet(_AdminModelViewSet): pagination_class = None +class SalesTypeViewSet(_AdminModelViewSet): + queryset = SalesType.objects.all() + serializer_class = SalesTypeSerializer + pagination_class = None + + +class PartyAdjustmentViewSet(_AdminModelViewSet): + queryset = PartyAdjustment.objects.all() + serializer_class = PartyAdjustmentSerializer + pagination_class = None + + class TreasuryTxnViewSet(_AdminModelViewSet): queryset = TreasuryTxn.objects.all() serializer_class = TreasuryTxnSerializer @@ -117,6 +133,8 @@ def reset_accounting_data(request): """Wipes all accounting data and recreates a clean, zeroed starting point.""" Voucher.objects.all().delete() Invoice.objects.all().delete() + PartyAdjustment.objects.all().delete() + SalesType.objects.all().delete() TreasuryTxn.objects.all().delete() Payslip.objects.all().delete() Employee.objects.all().delete()