diff --git a/src/pretix/base/migrations/0069_invoice_prefix.py b/src/pretix/base/migrations/0069_invoice_prefix.py new file mode 100644 index 0000000000..1a41639402 --- /dev/null +++ b/src/pretix/base/migrations/0069_invoice_prefix.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-07-17 19:29 +from __future__ import unicode_literals + +from django.db import migrations, models +from django.db.models import deletion + + +def fwd(app, schema_editor): + Event = app.get_model('pretixbase', 'Event') + for e in Event.objects.select_related('organizer').all(): + e.invoices.all().update(prefix=e.slug.upper() + '-', organizer=e.organizer) + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0068_subevent_frontpage_text'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='prefix', + field=models.CharField(db_index=True, default='', max_length=160), + preserve_default=False, + ), + migrations.AddField( + model_name='invoice', + name='organizer', + field=models.ForeignKey(null=True, + on_delete=deletion.PROTECT, + related_name='invoices', to='pretixbase.Organizer'), + preserve_default=False, + ), + migrations.RunPython( + fwd, migrations.RunPython.noop + ), + migrations.AlterUniqueTogether( + name='invoice', + unique_together=set([('organizer', 'prefix', 'invoice_no')]), + ), + migrations.AlterField( + model_name='invoice', + name='organizer', + field=models.ForeignKey(on_delete=deletion.PROTECT, related_name='invoices', to='pretixbase.Organizer'), + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 60d184436c..eb015a3661 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -29,6 +29,8 @@ class Invoice(models.Model): :type order: Order :param event: The event this belongs to (for convenience) :type event: Event + :param organizer: The organizer this belongs to (redundant, for enforcing uniqueness) + :type organizer: Organizer :param invoice_no: The human-readable, event-unique invoice number :type invoice_no: int :param is_cancellation: Whether or not this is a cancellation instead of an invoice @@ -55,7 +57,9 @@ class Invoice(models.Model): :type file: File """ order = models.ForeignKey('Order', related_name='invoices', db_index=True) + organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT) event = models.ForeignKey('Event', related_name='invoices', db_index=True) + prefix = models.CharField(max_length=160, 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) @@ -74,7 +78,10 @@ class Invoice(models.Model): return '{:05d}'.format(int(number)) def _get_numeric_invoice_number(self): - numeric_invoices = Invoice.objects.filter(event=self.event).exclude(invoice_no__contains='-') + numeric_invoices = Invoice.objects.filter( + event__organizer=self.event.organizer, + prefix=self.prefix, + ).exclude(invoice_no__contains='-') return self._to_numeric_invoice_number(numeric_invoices.count() + 1) def _get_invoice_number_from_order(self): @@ -88,6 +95,10 @@ class Invoice(models.Model): raise ValueError('Every invoice needs to be connected to an order') if not self.event: self.event = self.order.event + if not self.organizer: + self.organizer = self.order.event.organizer + if not self.prefix: + self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-') if not self.invoice_no: for i in range(10): if self.event.settings.get('invoice_numbers_consecutive'): @@ -116,8 +127,8 @@ class Invoice(models.Model): """ Returns the invoice number in a human-readable string with the event slug prepended. """ - return '{event}-{code}'.format( - event=self.event.slug.upper(), + return '{prefix}{code}'.format( + prefix=self.prefix, code=self.invoice_no ) @@ -126,7 +137,7 @@ class Invoice(models.Model): return self.refered.filter(is_cancellation=True).exists() class Meta: - unique_together = ('event', 'invoice_no') + unique_together = ('organizer', 'prefix', 'invoice_no') ordering = ('invoice_no',) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index b1683c0bc0..33c7f0da41 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -96,6 +96,7 @@ def generate_cancellation(invoice: Invoice): cancellation = copy.copy(invoice) cancellation.pk = None cancellation.invoice_no = None + cancellation.prefix = None cancellation.refers = invoice cancellation.is_cancellation = True cancellation.date = timezone.now().date() @@ -125,6 +126,7 @@ def generate_invoice(order: Order): invoice = Invoice( order=order, event=order.event, + organizer=order.event.organizer, date=timezone.now().date(), locale=locale ) @@ -171,7 +173,7 @@ def build_preview_invoice_pdf(event): expires=timezone.now(), code="PREVIEW", total=119) invoice = Invoice( order=order, event=event, invoice_no="PREVIEW", - date=timezone.now().date(), locale=locale + date=timezone.now().date(), locale=locale, organizer=event.organizer ) invoice.invoice_from = event.settings.get('invoice_address_from') diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 1c8270015b..ddf9b65812 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -61,6 +61,10 @@ DEFAULTS = { 'default': 'True', 'type': bool, }, + 'invoice_numbers_prefix': { + 'default': '', + 'type': str, + }, 'invoice_renderer': { 'default': 'classic', 'type': str, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index a7989693f0..7d2a83329c 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -453,6 +453,14 @@ class InvoiceSettingsForm(SettingsForm): help_text=_("If deactivated, the order code will be used in the invoice number."), required=False ) + invoice_numbers_prefix = forms.CharField( + label=_("Invoice number prefix"), + help_text=_("This will be prepended to invoice numbers. If you leave this field empty, your event slug will " + "be used followed by a dash. Attention: If multiple events within the same organization use the " + "same value in this field, they will share their number range, i.e. every full number will be " + "used at most once over all of your events. This setting only affects future invoices."), + required=False, + ) invoice_generate = forms.ChoiceField( label=_("Generate invoices"), required=False, @@ -511,6 +519,7 @@ class InvoiceSettingsForm(SettingsForm): self.fields['invoice_renderer'].choices = [ (r.identifier, r.verbose_name) for r in event.get_invoice_renderers().values() ] + self.fields['invoice_numbers_prefix'].widget.attrs['placeholder'] = event.slug.upper() + '-' class MailSettingsForm(SettingsForm): diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index b0cfd5c49e..66aa688b26 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -11,6 +11,7 @@ {% 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_numbers_prefix layout="horizontal" %} {% bootstrap_field form.invoice_generate layout="horizontal" %} {% bootstrap_field form.invoice_renderer layout="horizontal" %} {% bootstrap_field form.invoice_language layout="horizontal" %} diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index acd31e7d4e..c7ce6e8c04 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -2,6 +2,7 @@ from datetime import timedelta from decimal import Decimal import pytest +from django.db import DatabaseError from django.utils.timezone import now from pretix.base.models import ( @@ -219,3 +220,58 @@ def test_invoice_numbers(env): # test Invoice.number, too assert inv1.number == '{}-00001'.format(event.slug.upper()) assert inv3.number == '{}-{}-3'.format(event.slug.upper(), order.code) + + +@pytest.mark.django_db +def test_invoice_number_prefixes(env): + event, order = env + event2 = Event.objects.create( + organizer=event.organizer, name='Dummy', slug='dummy2', + date_from=now(), plugins='pretix.plugins.banktransfer' + ) + order2 = Order.objects.create( + event=event2, 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' + ) + event.settings.set('invoice_numbers_consecutive', False) + event2.settings.set('invoice_numbers_consecutive', False) + assert generate_invoice(order).number == 'DUMMY-{}-1'.format(order.code) + assert generate_invoice(order2).number == 'DUMMY2-{}-1'.format(order2.code) + + event.settings.set('invoice_numbers_consecutive', True) + event2.settings.set('invoice_numbers_consecutive', True) + event.settings.set('invoice_numbers_prefix', '') + event2.settings.set('invoice_numbers_prefix', '') + + assert generate_invoice(order).number == 'DUMMY-00001' + assert generate_invoice(order).number == 'DUMMY-00002' + assert generate_invoice(order2).number == 'DUMMY2-00001' + assert generate_invoice(order2).number == 'DUMMY2-00002' + + event.settings.set('invoice_numbers_prefix', 'shared_') + event2.settings.set('invoice_numbers_prefix', 'shared_') + + assert generate_invoice(order).number == 'shared_00001' + assert generate_invoice(order2).number == 'shared_00002' + assert generate_invoice(order).number == 'shared_00003' + assert generate_invoice(order2).number == 'shared_00004' + + event.settings.set('invoice_numbers_consecutive', False) + event2.settings.set('invoice_numbers_consecutive', False) + assert generate_invoice(order).number == 'shared_{}-6'.format(order.code) + assert generate_invoice(order2).number == 'shared_{}-6'.format(order2.code) + + # Test database uniqueness check + with pytest.raises(DatabaseError): + Invoice.objects.create( + order=order, + event=order.event, + organizer=order.event.organizer, + date=now().date(), + locale='en', + invoice_no='00001', + )