diff --git a/src/db.sqlite3.bak b/src/db.sqlite3.bak deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/pretix/base/migrations/0028_invoice_invoice_no_charfield.py b/src/pretix/base/migrations/0028_invoice_invoice_no_charfield.py new file mode 100644 index 0000000000..2fb546d468 --- /dev/null +++ b/src/pretix/base/migrations/0028_invoice_invoice_no_charfield.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-16 06:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0028_auto_20160816_1242'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='invoice_no_charfield', + field=models.CharField(db_index=True, default='', max_length=19), + preserve_default=False, + ), + ] diff --git a/src/pretix/base/migrations/0029_invoice_no_data.py b/src/pretix/base/migrations/0029_invoice_no_data.py new file mode 100644 index 0000000000..80e1b25424 --- /dev/null +++ b/src/pretix/base/migrations/0029_invoice_no_data.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-16 06:37 +from __future__ import unicode_literals + +from django.db import migrations + + +def forwards(apps, schema_editor): + Invoice = apps.get_model('pretixbase', 'Invoice') + for invoice in Invoice.objects.all(): + invoice.invoice_no_charfield = '{:05d}'.format(invoice.invoice_no) + invoice.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0028_invoice_invoice_no_charfield'), + ] + + operations = [ + migrations.RunPython(forwards, migrations.RunPython.noop), + ] diff --git a/src/pretix/base/migrations/0030_auto_20160816_0646.py b/src/pretix/base/migrations/0030_auto_20160816_0646.py new file mode 100644 index 0000000000..f1fe8e21fa --- /dev/null +++ b/src/pretix/base/migrations/0030_auto_20160816_0646.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-16 06:46 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0029_invoice_no_data'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='invoice', + unique_together=set([('event', 'invoice_no_charfield')]), + ), + migrations.RemoveField( + model_name='invoice', + name='invoice_no', + ), + ] diff --git a/src/pretix/base/migrations/0031_auto_20160816_0648.py b/src/pretix/base/migrations/0031_auto_20160816_0648.py new file mode 100644 index 0000000000..e44d25e8e1 --- /dev/null +++ b/src/pretix/base/migrations/0031_auto_20160816_0648.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-16 06:48 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0030_auto_20160816_0646'), + ] + + operations = [ + migrations.RenameField( + model_name='invoice', + old_name='invoice_no_charfield', + new_name='invoice_no', + ), + migrations.AlterUniqueTogether( + name='invoice', + unique_together=set([('event', 'invoice_no')]), + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index edb762bd5d..c5f6867d94 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -10,10 +10,9 @@ from django.utils.functional import cached_property def invoice_filename(instance, filename: str) -> str: secret = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(16)) - return 'invoices/{org}/{ev}/{ev}-{no:05d}-{code}-{secret}.pdf'.format( + return 'invoices/{org}/{ev}/{no}-{code}-{secret}.pdf'.format( org=instance.event.organizer.slug, ev=instance.event.slug, - no=instance.invoice_no, code=instance.order.code, - secret=secret + no=instance.number, code=instance.order.code, secret=secret ) @@ -47,7 +46,7 @@ class Invoice(models.Model): """ order = models.ForeignKey('Order', related_name='invoices', db_index=True) event = models.ForeignKey('Event', related_name='invoices', db_index=True) - invoice_no = models.PositiveIntegerField(db_index=True) + invoice_no = models.CharField(max_length=19, db_index=True) is_cancellation = models.BooleanField(default=False) refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True) invoice_from = models.TextField() @@ -57,6 +56,20 @@ class Invoice(models.Model): additional_text = models.TextField(blank=True) file = models.FileField(null=True, blank=True, upload_to=invoice_filename) + @staticmethod + def _to_numeric_invoice_number(number): + return '{:05d}'.format(int(number)) + + def _get_numeric_invoice_number(self): + numeric_invoices = Invoice.objects.filter(event=self.event).exclude(invoice_no__contains='-') + return self._to_numeric_invoice_number(numeric_invoices.count() + 1) + + def _get_invoice_number_from_order(self): + return '{order}-{count}'.format( + order=self.order.code, + count=Invoice.objects.filter(event=self.event, order=self.order).count() + 1, + ) + def save(self, *args, **kwargs): if not self.order: raise ValueError('Every invoice needs to be connected to an order') @@ -64,8 +77,10 @@ class Invoice(models.Model): self.event = self.order.event if not self.invoice_no: for i in range(10): - self.invoice_no = (Invoice.objects.filter( - event=self.event).aggregate(m=Max('invoice_no'))['m'] or 0) + 1 + if self.event.settings.get('invoice_numbers_consecutive'): + self.invoice_no = self._get_numeric_invoice_number() + else: + self.invoice_no = self._get_invoice_number_from_order() try: return super().save(*args, **kwargs) except DatabaseError: @@ -74,12 +89,23 @@ class Invoice(models.Model): raise return super().save(*args, **kwargs) + def delete(self, *args, **kwargs): + """ + Deleting an Invoice would allow for the creation of another Invoice object + with the same invoice_no as the deleted one. For various reasons, invoice_no + should be reliably unique for an event. + """ + raise Exception('Invoices cannot be deleted, to guarantee uniqueness of Invoice.invoice_no in any event.') + @property def number(self): """ Returns the invoice number in a human-readable string with the event slug prepended. """ - return '%s-%05d' % (self.event.slug.upper(), self.invoice_no) + return '{event}-{code}'.format( + event=self.event.slug.upper(), + code=self.invoice_no + ) @cached_property def canceled(self): diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d0e5ad14b0..d6ce2ec785 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -175,7 +175,7 @@ class Order(LoggedModel): An order code which is unique among all events of a single organizer, built by contatenating the event slug and the order code. """ - return self.event.slug.upper() + self.code + return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code) def save(self, *args, **kwargs): if not self.code: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index b8d22b5c96..0cd3ef1517 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -37,6 +37,10 @@ DEFAULTS = { 'default': 'False', 'type': bool, }, + 'invoice_numbers_consecutive': { + 'default': 'True', + 'type': bool, + }, 'reservation_time': { 'default': '30', 'type': int diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index d388d4e1ad..c0ba3e6f6d 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -252,6 +252,11 @@ class InvoiceSettingsForm(SettingsForm): help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."), required=False ) + invoice_numbers_consecutive = forms.BooleanField( + label=_("Generate invoices with consecutive numbers"), + help_text=_("If deactivated, the order code will be used in the invoice number."), + required=False + ) invoice_generate = forms.ChoiceField( label=_("Generate invoices"), required=False, diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index 745e2738b9..2a190db307 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -10,6 +10,7 @@ {% bootstrap_field form.invoice_address_asked layout="horizontal" %} {% bootstrap_field form.invoice_address_required layout="horizontal" %} {% bootstrap_field form.invoice_address_vatid layout="horizontal" %} + {% bootstrap_field form.invoice_numbers_consecutive layout="horizontal" %} {% bootstrap_field form.invoice_generate layout="horizontal" %} {% bootstrap_field form.invoice_language layout="horizontal" %} {% bootstrap_field form.invoice_address_from layout="horizontal" %} diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 8e30547d2c..17c0227871 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -91,3 +91,45 @@ def test_positions(env): assert last.tax_rate == order.payment_fee_tax_rate assert last.tax_value == order.payment_fee_tax_value assert inv.invoice_to == "" + + +@pytest.mark.django_db +def test_invoice_numbers(env): + event, order = env + order2 = Order.objects.create( + code='BAR', event=event, email='dummy2@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=0, payment_provider='banktransfer', + payment_fee=Decimal('0.25'), payment_fee_tax_rate=0, + payment_fee_tax_value=0, locale='en' + ) + inv1 = generate_invoice(order) + inv2 = generate_invoice(order) + + event.settings.set('invoice_numbers_consecutive', False) + inv3 = generate_invoice(order) + inv4 = generate_invoice(order) + inv21 = generate_invoice(order2) + inv22 = generate_invoice(order2) + + event.settings.set('invoice_numbers_consecutive', True) + inv5 = generate_invoice(order) + inv23 = generate_invoice(order2) + + # expected behaviour for switching between numbering formats + assert inv1.invoice_no == '00001' + assert inv2.invoice_no == '00002' + assert inv3.invoice_no == '{}-3'.format(order.code) + assert inv4.invoice_no == '{}-4'.format(order.code) + assert inv5.invoice_no == '00003' + + # test that separate orders are counted separately in this mode + assert inv21.invoice_no == '{}-1'.format(order2.code) + assert inv22.invoice_no == '{}-2'.format(order2.code) + # but consecutively in this mode + assert inv23.invoice_no == '00004' + + # test Invoice.number, too + assert inv1.number == '{}-00001'.format(event.slug.upper()) + assert inv3.number == '{}-{}-3'.format(event.slug.upper(), order.code)