Configurable invoice prefixes

This commit is contained in:
Raphael Michel
2017-07-17 22:57:37 +02:00
parent 670bfa18de
commit 7bb12ff0ec
7 changed files with 135 additions and 5 deletions

View File

@@ -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'),
),
]

View File

@@ -29,6 +29,8 @@ class Invoice(models.Model):
:type order: Order :type order: Order
:param event: The event this belongs to (for convenience) :param event: The event this belongs to (for convenience)
:type event: Event :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 :param invoice_no: The human-readable, event-unique invoice number
:type invoice_no: int :type invoice_no: int
:param is_cancellation: Whether or not this is a cancellation instead of an invoice :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 :type file: File
""" """
order = models.ForeignKey('Order', related_name='invoices', db_index=True) 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) 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) invoice_no = models.CharField(max_length=19, db_index=True)
is_cancellation = models.BooleanField(default=False) is_cancellation = models.BooleanField(default=False)
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True) refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
@@ -74,7 +78,10 @@ class Invoice(models.Model):
return '{:05d}'.format(int(number)) return '{:05d}'.format(int(number))
def _get_numeric_invoice_number(self): 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) return self._to_numeric_invoice_number(numeric_invoices.count() + 1)
def _get_invoice_number_from_order(self): 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') raise ValueError('Every invoice needs to be connected to an order')
if not self.event: if not self.event:
self.event = self.order.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: if not self.invoice_no:
for i in range(10): for i in range(10):
if self.event.settings.get('invoice_numbers_consecutive'): 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. Returns the invoice number in a human-readable string with the event slug prepended.
""" """
return '{event}-{code}'.format( return '{prefix}{code}'.format(
event=self.event.slug.upper(), prefix=self.prefix,
code=self.invoice_no code=self.invoice_no
) )
@@ -126,7 +137,7 @@ class Invoice(models.Model):
return self.refered.filter(is_cancellation=True).exists() return self.refered.filter(is_cancellation=True).exists()
class Meta: class Meta:
unique_together = ('event', 'invoice_no') unique_together = ('organizer', 'prefix', 'invoice_no')
ordering = ('invoice_no',) ordering = ('invoice_no',)

View File

@@ -96,6 +96,7 @@ def generate_cancellation(invoice: Invoice):
cancellation = copy.copy(invoice) cancellation = copy.copy(invoice)
cancellation.pk = None cancellation.pk = None
cancellation.invoice_no = None cancellation.invoice_no = None
cancellation.prefix = None
cancellation.refers = invoice cancellation.refers = invoice
cancellation.is_cancellation = True cancellation.is_cancellation = True
cancellation.date = timezone.now().date() cancellation.date = timezone.now().date()
@@ -125,6 +126,7 @@ def generate_invoice(order: Order):
invoice = Invoice( invoice = Invoice(
order=order, order=order,
event=order.event, event=order.event,
organizer=order.event.organizer,
date=timezone.now().date(), date=timezone.now().date(),
locale=locale locale=locale
) )
@@ -171,7 +173,7 @@ def build_preview_invoice_pdf(event):
expires=timezone.now(), code="PREVIEW", total=119) expires=timezone.now(), code="PREVIEW", total=119)
invoice = Invoice( invoice = Invoice(
order=order, event=event, invoice_no="PREVIEW", 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') invoice.invoice_from = event.settings.get('invoice_address_from')

View File

@@ -61,6 +61,10 @@ DEFAULTS = {
'default': 'True', 'default': 'True',
'type': bool, 'type': bool,
}, },
'invoice_numbers_prefix': {
'default': '',
'type': str,
},
'invoice_renderer': { 'invoice_renderer': {
'default': 'classic', 'default': 'classic',
'type': str, 'type': str,

View File

@@ -453,6 +453,14 @@ class InvoiceSettingsForm(SettingsForm):
help_text=_("If deactivated, the order code will be used in the invoice number."), help_text=_("If deactivated, the order code will be used in the invoice number."),
required=False 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( invoice_generate = forms.ChoiceField(
label=_("Generate invoices"), label=_("Generate invoices"),
required=False, required=False,
@@ -511,6 +519,7 @@ class InvoiceSettingsForm(SettingsForm):
self.fields['invoice_renderer'].choices = [ self.fields['invoice_renderer'].choices = [
(r.identifier, r.verbose_name) for r in event.get_invoice_renderers().values() (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): class MailSettingsForm(SettingsForm):

View File

@@ -11,6 +11,7 @@
{% bootstrap_field form.invoice_address_required layout="horizontal" %} {% bootstrap_field form.invoice_address_required layout="horizontal" %}
{% bootstrap_field form.invoice_address_vatid layout="horizontal" %} {% bootstrap_field form.invoice_address_vatid layout="horizontal" %}
{% bootstrap_field form.invoice_numbers_consecutive 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_generate layout="horizontal" %}
{% bootstrap_field form.invoice_renderer layout="horizontal" %} {% bootstrap_field form.invoice_renderer layout="horizontal" %}
{% bootstrap_field form.invoice_language layout="horizontal" %} {% bootstrap_field form.invoice_language layout="horizontal" %}

View File

@@ -2,6 +2,7 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from django.db import DatabaseError
from django.utils.timezone import now from django.utils.timezone import now
from pretix.base.models import ( from pretix.base.models import (
@@ -219,3 +220,58 @@ def test_invoice_numbers(env):
# test Invoice.number, too # test Invoice.number, too
assert inv1.number == '{}-00001'.format(event.slug.upper()) assert inv1.number == '{}-00001'.format(event.slug.upper())
assert inv3.number == '{}-{}-3'.format(event.slug.upper(), order.code) 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',
)