diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 5877dc2d74..b1683c0bc0 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -3,6 +3,7 @@ from decimal import Decimal from django.core.files.base import ContentFile from django.db import transaction +from django.db.models import Count from django.utils import timezone from django.utils.translation import pgettext, ugettext as _ from i18nfield.strings import LazyI18nString @@ -47,9 +48,16 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice.save() invoice.lines.all().delete() - positions = list(invoice.order.positions.select_related('addon_to', 'item', 'variation')) + positions = list( + invoice.order.positions.select_related('addon_to', 'item', 'variation').annotate( + addon_c=Count('addons') + ) + ) positions.sort(key=lambda p: p.sort_key) for p in positions: + if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c: + continue + desc = str(p.item.name) if p.variation: desc += " - " + str(p.variation.value) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 452d94c72a..f92cd71876 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -545,6 +545,7 @@ class OrderChangeManager: self._totaldiff = 0 self._quotadiff = Counter() self._operations = [] + self._invoice_dirty = False def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]): if (not variation and item.has_variations) or (variation and variation.item_id != item.pk): @@ -560,6 +561,9 @@ class OrderChangeManager: if not new_quotas: raise OrderError(self.error_messages['quota_missing']) + if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'): + self._invoice_dirty = True + self._totaldiff = price - position.price self._quotadiff.update(new_quotas) self._quotadiff.subtract(position.quotas) @@ -576,6 +580,9 @@ class OrderChangeManager: if not new_quotas: raise OrderError(self.error_messages['quota_missing']) + if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'): + self._invoice_dirty = True + self._totaldiff = price - position.price self._quotadiff.update(new_quotas) self._quotadiff.subtract(position.quotas) @@ -583,6 +590,10 @@ class OrderChangeManager: def change_price(self, position: OrderPosition, price: Decimal): self._totaldiff = price - position.price + + if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'): + self._invoice_dirty = True + self._operations.append(self.PriceOperation(position, price)) def cancel(self, position: OrderPosition): @@ -590,6 +601,9 @@ class OrderChangeManager: self._quotadiff.subtract(position.quotas) self._operations.append(self.CancelOperation(position)) + if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): + self._invoice_dirty = True + def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, subevent: SubEvent = None): if price is None: @@ -609,6 +623,9 @@ class OrderChangeManager: if not new_quotas: raise OrderError(self.error_messages['quota_missing']) + if self.order.event.settings.invoice_include_free or price != Decimal('0.00'): + self._invoice_dirty = True + self._totaldiff = price self._quotadiff.update(new_quotas) self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent)) @@ -730,7 +747,7 @@ class OrderChangeManager: def _reissue_invoice(self): i = self.order.invoices.filter(is_cancellation=False).last() - if i: + if i and self._invoice_dirty: generate_cancellation(i) generate_invoice(self.order) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index dd9fa1e424..1c8270015b 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -53,6 +53,10 @@ DEFAULTS = { 'default': 'False', 'type': bool, }, + 'invoice_include_free': { + 'default': 'True', + 'type': bool, + }, 'invoice_numbers_consecutive': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index c98ebe04b2..1425c0c4ac 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -440,6 +440,12 @@ class InvoiceSettingsForm(SettingsForm): widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), required=False ) + invoice_include_free = forms.BooleanField( + label=_("Show free products on invoices"), + help_text=_("Note that invoices will never be generated for orders that contain only free " + "products."), + 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."), diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index f8b5b49dca..b0cfd5c49e 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -14,6 +14,7 @@ {% bootstrap_field form.invoice_generate layout="horizontal" %} {% bootstrap_field form.invoice_renderer layout="horizontal" %} {% bootstrap_field form.invoice_language layout="horizontal" %} + {% bootstrap_field form.invoice_include_free layout="horizontal" %} {% bootstrap_field form.invoice_address_from layout="horizontal" %} {% bootstrap_field form.invoice_introductory_text layout="horizontal" %} {% bootstrap_field form.invoice_additional_text layout="horizontal" %} diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index f945fc5da9..acd31e7d4e 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -92,6 +92,17 @@ def test_address_vat_id(env): assert inv.invoice_to == "Acme Company\nSherlock Holmes\n221B Baker Street\n12345 London\nUK\nVAT-ID: UK1234567" +@pytest.mark.django_db +def test_positions_skip_free(env): + event, order = env + event.settings.invoice_include_free = False + op1 = order.positions.first() + op1.price = Decimal('0.00') + op1.save() + inv = generate_invoice(order) + assert inv.lines.count() == 2 + + @pytest.mark.django_db def test_positions(env): event, order = env diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 5e7cbde62b..ff6bf34994 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -13,6 +13,7 @@ from pretix.base.models import ( from pretix.base.models.items import SubEventItem from pretix.base.payment import FreeOrderProvider from pretix.base.reldate import RelativeDate, RelativeDateWrapper +from pretix.base.services.invoices import generate_invoice from pretix.base.services.orders import ( OrderChangeManager, OrderError, _create_order, expire_orders, ) @@ -184,7 +185,7 @@ class OrderChangeManagerTests(TestCase): self.event = Event.objects.create(organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer') self.order = Order.objects.create( code='FOO', event=self.event, email='dummy@dummy.test', - status=Order.STATUS_PENDING, + status=Order.STATUS_PENDING, locale='en', datetime=now(), expires=now() + timedelta(days=10), total=Decimal('46.00'), payment_provider='banktransfer' ) @@ -510,3 +511,18 @@ class OrderChangeManagerTests(TestCase): assert nop.item == self.ticket assert nop.price == Decimal('12.00') assert nop.subevent == se1 + + def test_reissue_invoice(self): + generate_invoice(self.order) + assert self.order.invoices.count() == 1 + self.ocm.add_position(self.ticket, None, Decimal('0.00')) + self.ocm.commit() + assert self.order.invoices.count() == 3 + + def test_dont_reissue_invoice_on_free_product_changes(self): + self.event.settings.invoice_include_free = False + generate_invoice(self.order) + assert self.order.invoices.count() == 1 + self.ocm.add_position(self.ticket, None, Decimal('0.00')) + self.ocm.commit() + assert self.order.invoices.count() == 1