From 81d346aa3c64bd037d1fa854de627679e9f14ef7 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Sat, 11 Jul 2026 16:53:05 +0330 Subject: [PATCH] Add invoice cancelled status, treasury event name, and voucher number backfill. EOF Co-authored-by: Cursor --- .../0007_backfill_voucher_numbers.py | 31 +++++++++++++++++++ .../0008_invoice_cancelled_status.py | 25 +++++++++++++++ .../migrations/0009_treasury_event_name.py | 30 ++++++++++++++++++ accounting/models.py | 4 +++ accounting/serializers.py | 31 +++++++++++++++++-- 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 accounting/migrations/0007_backfill_voucher_numbers.py create mode 100644 accounting/migrations/0008_invoice_cancelled_status.py create mode 100644 accounting/migrations/0009_treasury_event_name.py diff --git a/accounting/migrations/0007_backfill_voucher_numbers.py b/accounting/migrations/0007_backfill_voucher_numbers.py new file mode 100644 index 0000000..8a8b5a2 --- /dev/null +++ b/accounting/migrations/0007_backfill_voucher_numbers.py @@ -0,0 +1,31 @@ +from django.db import migrations + + +def backfill_voucher_numbers(apps, schema_editor): + Voucher = apps.get_model('accounting', 'Voucher') + + max_number = ( + Voucher.objects.exclude(number__lte=0) + .order_by('-number') + .values_list('number', flat=True) + .first() + or 0 + ) + + missing_number_vouchers = Voucher.objects.filter(number__lte=0).order_by('created_at', 'id') + + next_number = max_number + 1 + for voucher in missing_number_vouchers: + voucher.number = next_number + voucher.save(update_fields=['number']) + next_number += 1 + + +class Migration(migrations.Migration): + dependencies = [ + ('accounting', '0006_treasury_fee_fields'), + ] + + operations = [ + migrations.RunPython(backfill_voucher_numbers, migrations.RunPython.noop), + ] diff --git a/accounting/migrations/0008_invoice_cancelled_status.py b/accounting/migrations/0008_invoice_cancelled_status.py new file mode 100644 index 0000000..9671c1a --- /dev/null +++ b/accounting/migrations/0008_invoice_cancelled_status.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounting', '0007_backfill_voucher_numbers'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='status', + field=models.CharField( + choices=[ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('paid', 'Paid'), + ('cancelled', 'Cancelled'), + ], + default='draft', + max_length=20, + ), + ), + ] diff --git a/accounting/migrations/0009_treasury_event_name.py b/accounting/migrations/0009_treasury_event_name.py new file mode 100644 index 0000000..6a178a0 --- /dev/null +++ b/accounting/migrations/0009_treasury_event_name.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """ + DB already has event_name (NOT NULL) from an earlier manual/out-of-band change. + Sync Django state and ensure NULLs become empty string so inserts never omit the column. + """ + + dependencies = [ + ('accounting', '0008_invoice_cancelled_status'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.AddField( + model_name='treasurytxn', + name='event_name', + field=models.CharField(blank=True, default='', max_length=200), + ), + ], + database_operations=[ + migrations.RunSQL( + sql="UPDATE accounting_treasury_txns SET event_name = '' WHERE event_name IS NULL;", + reverse_sql=migrations.RunSQL.noop, + ), + ], + ), + ] diff --git a/accounting/models.py b/accounting/models.py index c76f5ef..b8bedfa 100644 --- a/accounting/models.py +++ b/accounting/models.py @@ -117,6 +117,7 @@ class Voucher(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) number = models.PositiveIntegerField(default=0) date = models.DateField() + regarding = models.CharField(max_length=255, blank=True, default='') description = models.TextField(blank=True, default='') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') source = models.CharField(max_length=120, blank=True, default='', db_index=True) @@ -208,6 +209,7 @@ class Invoice(models.Model): ('draft', 'Draft'), ('confirmed', 'Confirmed'), ('paid', 'Paid'), + ('cancelled', 'Cancelled'), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -250,6 +252,7 @@ class InvoiceLine(models.Model): unit_price = models.FloatField(default=0) discount = models.FloatField(default=0) tax_rate = models.FloatField(default=0) + event_name = models.CharField(max_length=255, blank=True, default='') class Meta: db_table = 'accounting_invoice_lines' @@ -285,6 +288,7 @@ class TreasuryTxn(models.Model): owner_net_amount = models.FloatField(default=0) reference = models.CharField(max_length=120, blank=True, default='') description = models.TextField(blank=True, default='') + event_name = models.CharField(max_length=200, blank=True, default='') source = models.CharField(max_length=120, blank=True, default='') created_at = models.DateTimeField(auto_now_add=True) diff --git a/accounting/serializers.py b/accounting/serializers.py index 1ac1808..45bd839 100644 --- a/accounting/serializers.py +++ b/accounting/serializers.py @@ -73,14 +73,29 @@ class VoucherLineSerializer(serializers.ModelSerializer): class VoucherSerializer(serializers.ModelSerializer): lines = VoucherLineSerializer(many=True) + regarding = serializers.CharField(required=False, allow_blank=True) class Meta: model = Voucher - fields = ['id', 'number', 'date', 'description', 'status', 'source', 'lines'] + fields = ['id', 'number', 'date', 'regarding', 'description', 'status', 'source', 'lines'] + read_only_fields = ['number'] + + @staticmethod + def _next_number() -> int: + last_number = ( + Voucher.objects.select_for_update() + .order_by('-number') + .values_list('number', flat=True) + .first() + or 0 + ) + return last_number + 1 @transaction.atomic def create(self, validated_data): lines = validated_data.pop('lines', []) + validated_data.setdefault('regarding', '') + validated_data['number'] = self._next_number() voucher = Voucher.objects.create(**validated_data) for line in lines: line.pop('id', None) @@ -90,6 +105,12 @@ class VoucherSerializer(serializers.ModelSerializer): @transaction.atomic def update(self, instance, validated_data): lines = validated_data.pop('lines', None) + # Keep voucher numbers immutable after creation. + validated_data.pop('number', None) + if 'regarding' in validated_data and validated_data['regarding'] is None: + validated_data['regarding'] = '' + if instance.number <= 0: + instance.number = self._next_number() for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() @@ -123,10 +144,11 @@ class InvoiceLineSerializer(serializers.ModelSerializer): ) unitPrice = serializers.FloatField(source='unit_price', required=False) taxRate = serializers.FloatField(source='tax_rate', required=False) + eventName = serializers.CharField(source='event_name', required=False, allow_blank=True) class Meta: model = InvoiceLine - fields = ['id', 'productId', 'quantity', 'unitPrice', 'discount', 'taxRate'] + fields = ['id', 'productId', 'quantity', 'unitPrice', 'discount', 'taxRate', 'eventName'] class InvoiceSerializer(serializers.ModelSerializer): @@ -155,6 +177,7 @@ class InvoiceSerializer(serializers.ModelSerializer): invoice = Invoice.objects.create(**validated_data) for line in lines: line.pop('id', None) + line.setdefault('event_name', '') InvoiceLine.objects.create(invoice=invoice, **line) return invoice @@ -168,6 +191,7 @@ class InvoiceSerializer(serializers.ModelSerializer): instance.lines.all().delete() for line in lines: line.pop('id', None) + line.setdefault('event_name', '') InvoiceLine.objects.create(invoice=instance, **line) return instance @@ -181,13 +205,14 @@ class TreasuryTxnSerializer(serializers.ModelSerializer): 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) + eventName = serializers.CharField(source='event_name', required=False, allow_blank=True) class Meta: model = TreasuryTxn fields = [ 'id', 'number', 'kind', 'method', 'date', 'partyId', 'amount', 'taxAmount', 'platformProfitAmount', 'ownerNetAmount', - 'reference', 'description', 'source', + 'reference', 'description', 'eventName', 'source', ]