From ae5c0a5537c2fdf07d658de1036fbac0a781b69d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 8 Sep 2025 10:57:57 +0200 Subject: [PATCH] Invoicing: Configurable service date --- doc/api/resources/invoices.rst | 21 +++--- src/pretix/api/serializers/event.py | 1 + src/pretix/api/serializers/order.py | 6 +- .../migrations/0289_invoiceline_period.py | 65 +++++++++++++++++++ src/pretix/base/models/invoices.py | 45 +++++++++++-- src/pretix/base/services/invoices.py | 16 ++--- src/pretix/base/settings.py | 26 ++++++++ src/pretix/control/forms/event.py | 1 + .../pretixcontrol/event/invoicing.html | 1 + src/tests/api/test_invoices.py | 4 ++ src/tests/api/test_order_change.py | 4 ++ 11 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 src/pretix/base/migrations/0289_invoiceline_period.py diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index bc5723b4ca..37c6754749 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -80,17 +80,12 @@ lines list of objects The actual invo for all invoice lines created before this field was introduced as well as for all lines not created by a fee (e.g. a product). -├ event_date_from datetime Start date of the (sub)event this line was created for as it - was set during invoice creation. Can be ``null`` for all invoice - lines created before this was introduced as well as for lines in - an event series not created by a product (e.g. shipping or - cancellation fees). -├ event_date_to datetime End date of the (sub)event this line was created for as it - was set during invoice creation. Can be ``null`` for all invoice - lines created before this was introduced as well as for lines in - an event series not created by a product (e.g. shipping or - cancellation fees) as well as whenever the respective (sub)event - has no end date set. +├ period_start datetime Start date of the service or delivery period of the invoice line. + Can be ``null`` if not known. +├ period_end datetime End date of the service or delivery period of the invoice line. + Can be ``null`` if not known. +├ event_date_from datetime Deprecated alias of ``period_start``. +├ event_date_to datetime Deprecated alias of ``period_end``. ├ event_location string Location of the (sub)event this line was created for as it was set during invoice creation. Can be ``null`` for all invoice lines created before this was introduced as well as for lines in @@ -274,6 +269,8 @@ List of all invoices "fee_internal_type": null, "event_date_from": "2017-12-27T10:00:00Z", "event_date_to": null, + "period_start": "2017-12-27T10:00:00Z", + "period_end": "2017-12-27T10:00:00Z", "event_location": "Heidelberg", "attendee_name": null, "gross_value": "23.00", @@ -420,6 +417,8 @@ Fetching individual invoices "fee_internal_type": null, "event_date_from": "2017-12-27T10:00:00Z", "event_date_to": null, + "period_start": "2017-12-27T10:00:00Z", + "period_end": "2017-12-27T10:00:00Z", "event_location": "Heidelberg", "attendee_name": null, "gross_value": "23.00", diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index e271ecc9c9..98e011db7b 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -805,6 +805,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_reissue_after_modify', 'invoice_include_free', 'invoice_generate', + 'invoice_period', 'invoice_numbers_consecutive', 'invoice_numbers_prefix', 'invoice_numbers_prefix_cancellations', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 68c7f7e9fe..470c99490d 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1757,12 +1757,14 @@ class LinePositionField(serializers.IntegerField): class InlineInvoiceLineSerializer(I18nAwareModelSerializer): position = LinePositionField(read_only=True) + event_date_from = serializers.DateTimeField(read_only=True, source="period_start") + event_date_to = serializers.DateTimeField(read_only=True, source="period_end") class Meta: model = InvoiceLine fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from', - 'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type', - 'fee_internal_type', 'event_location') + 'event_date_to', 'period_start', 'period_end', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', + 'tax_name', 'fee_type', 'fee_internal_type', 'event_location') class InvoiceSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/base/migrations/0289_invoiceline_period.py b/src/pretix/base/migrations/0289_invoiceline_period.py new file mode 100644 index 0000000000..79781801f4 --- /dev/null +++ b/src/pretix/base/migrations/0289_invoiceline_period.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.17 on 2025-09-08 08:14 + +from django.db import migrations + + +def set_default_cardtypes(apps, schema_editor): + EventSettingsStore = apps.get_model("pretixbase", "Event_SettingsStore") + ev_seen = set() + insert_queue = [] + + # Existing events that use pretix-zugferd and have explicitly disabled delivery dates + for ev in EventSettingsStore.objects.filter(key="zugferd_include_delivery_date", value="False"): + insert_queue.append( + EventSettingsStore( + object_id=ev.object_id, + key="invoice_period", + value="invoice_date", + ) + ) + ev_seen.add(ev.object_id) + + if len(insert_queue) > 1000: + EventSettingsStore.objects.bulk_create(insert_queue, ignore_conflicts=True) + insert_queue.clear() + + # Existing events that previously hid their date on invoices + # Ignore series as it doesn't make sense for them + for ev in EventSettingsStore.objects.filter(key="show_dates_on_frontpage", value="False", + object__has_subevents=False): + if ev.object_id in ev_seen: + continue + + insert_queue.append( + EventSettingsStore( + object_id=ev.object_id, + key="invoice_period", + value="auto_no_event", + ) + ) + ev_seen.add(ev.object_id) + + if len(insert_queue) > 1000: + EventSettingsStore.objects.bulk_create(insert_queue, ignore_conflicts=True) + insert_queue.clear() + + EventSettingsStore.objects.bulk_create(insert_queue, ignore_conflicts=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("pretixbase", "0288_invoice_transmission"), + ] + + operations = [ + migrations.RenameField( + model_name="invoiceline", + old_name="event_date_to", + new_name="period_end", + ), + migrations.RenameField( + model_name="invoiceline", + old_name="event_date_from", + new_name="period_start", + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 5f4de4b8b7..60e58c2ee5 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -33,6 +33,7 @@ # License for the specific language governing permissions and limitations under the License. import string +import warnings from decimal import Decimal import pycountry @@ -387,10 +388,10 @@ class InvoiceLine(models.Model): :type tax_name: str :param subevent: The subevent this line refers to :type subevent: SubEvent - :param event_date_from: Event date of the (sub)event at the time the invoice was created - :type event_date_from: datetime - :param event_date_to: Event end date of the (sub)event at the time the invoice was created - :type event_date_to: datetime + :param period_start: Start if service period invoiced + :type period_start: datetime + :param period_end: End of service period invoiced + :type period_end: datetime :param event_location: Event location of the (sub)event at the time the invoice was created :type event_location: str :param item: The item this line refers to @@ -409,8 +410,8 @@ class InvoiceLine(models.Model): tax_name = models.CharField(max_length=190) tax_code = models.CharField(max_length=190, null=True, blank=True) subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT) - event_date_from = models.DateTimeField(null=True) - event_date_to = models.DateTimeField(null=True) + period_start = models.DateTimeField(null=True) + period_end = models.DateTimeField(null=True) event_location = models.TextField(null=True, blank=True) item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT) variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT) @@ -427,3 +428,35 @@ class InvoiceLine(models.Model): def __str__(self): return 'Line {} of invoice {}'.format(self.position, self.invoice) + + @property + def event_date_from(self): + warnings.warn( + 'InvoiceLine.event_date_from is deprecated, use period_start instead,', + category=DeprecationWarning, + ) + return self.period_start + + @event_date_from.setter + def event_date_from(self, value): + warnings.warn( + 'InvoiceLine.event_date_from is deprecated, use period_start instead,', + category=DeprecationWarning, + ) + self.period_start = value + + @property + def event_date_to(self): + warnings.warn( + 'InvoiceLine.event_date_to is deprecated, use period_end instead,', + category=DeprecationWarning, + ) + return self.period_end + + @event_date_to.setter + def event_date_to(self, value): + warnings.warn( + 'InvoiceLine.event_date_to is deprecated, use period_end instead,', + category=DeprecationWarning, + ) + self.period_to = value diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 85a69e9e73..307b6d1bce 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -277,8 +277,8 @@ def build_invoice(invoice: Invoice) -> Invoice: item=p.item, variation=p.variation, attendee_name=p.attendee_name if invoice.event.settings.invoice_attendee_name else None, - event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from, - event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to, + period_start=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from, + period_end=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to, event_location=location if invoice.event.settings.invoice_event_location else None, tax_rate=p.tax_rate, tax_code=p.tax_code, @@ -306,8 +306,8 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice=invoice, description=fee_title, gross_value=fee.value, - event_date_from=None if invoice.event.has_subevents else invoice.event.date_from, - event_date_to=None if invoice.event.has_subevents else invoice.event.date_to, + period_start=None if invoice.event.has_subevents else invoice.event.date_from, + period_end=None if invoice.event.has_subevents else invoice.event.date_to, event_location=( None if invoice.event.has_subevents else (str(invoice.event.location) @@ -506,8 +506,8 @@ def build_preview_invoice_pdf(event): invoice=invoice, description=_("Sample product {}").format(i + 1), gross_value=tax.gross, tax_value=tax.tax, tax_rate=tax.rate, tax_name=tax.name, tax_code=tax.code, - event_date_from=event.date_from, - event_date_to=event.date_to, + period_start=event.date_from, + period_end=event.date_to, event_location=event.settings.invoice_event_location, ) else: @@ -515,8 +515,8 @@ def build_preview_invoice_pdf(event): InvoiceLine.objects.create( invoice=invoice, description=_("Sample product A"), gross_value=100, tax_value=0, tax_rate=0, tax_code=None, - event_date_from=event.date_from, - event_date_to=event.date_to, + period_start=event.date_from, + period_end=event.date_to, event_location=event.settings.invoice_event_location, ) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index fbc52b31a9..15e37ebb53 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1098,6 +1098,32 @@ DEFAULTS = { help_text=_("Invoices will never be automatically generated for free orders.") ) }, + 'invoice_period': { + 'default': 'auto', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('auto', _('Ticket-specific validity or event series date or event date')), + ('auto_no_event', _('Ticket-specific validity or event series date or invoice date')), + ('order_date', _('Order date')), + ('invoice_date', _('Invoice date')), + ), + ), + 'form_kwargs': dict( + label=_("Date of service"), + widget=forms.RadioSelect, + choices=( + ('auto', _('Ticket-specific validity or event series date or event date')), + ('auto_no_event', _('Ticket-specific validity or event series date or invoice date')), + ('order_date', _('Order date')), + ('invoice_date', _('Invoice date')), + ), + help_text=_("This controls what dates are shown on the invoice, but is especially important for " + "electronic invoicing."), + ) + }, 'invoice_reissue_after_modify': { 'default': 'False', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 1b4f6cdc99..171c94d17b 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -857,6 +857,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_show_payments', 'invoice_reissue_after_modify', 'invoice_generate', + 'invoice_period', 'invoice_attendee_name', 'invoice_event_location', 'invoice_include_expire_date', diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index d2b21e70ec..c3cfa58675 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -15,6 +15,7 @@ {% bootstrap_field form.invoice_email_attachment layout="control" %} {% bootstrap_field form.invoice_email_organizer layout="control" %} {% bootstrap_field form.invoice_language layout="control" %} + {% bootstrap_field form.invoice_period layout="control" %} {% bootstrap_field form.invoice_include_free layout="control" %} {% bootstrap_field form.invoice_show_payments layout="control" %} {% bootstrap_field form.invoice_reissue_after_modify layout="control" %} diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py index 829008226e..3eb33f1ff2 100644 --- a/src/tests/api/test_invoices.py +++ b/src/tests/api/test_invoices.py @@ -232,6 +232,8 @@ TEST_INVOICE_RES = { 'subevent': None, 'event_date_from': '2017-12-27T10:00:00Z', 'event_date_to': None, + 'period_start': '2017-12-27T10:00:00Z', + 'period_end': None, 'event_location': None, 'attendee_name': 'Peter', 'item': None, @@ -250,6 +252,8 @@ TEST_INVOICE_RES = { 'subevent': None, 'event_date_from': '2017-12-27T10:00:00Z', 'event_date_to': None, + 'period_start': '2017-12-27T10:00:00Z', + 'period_end': None, 'event_location': None, 'attendee_name': None, 'fee_type': "payment", diff --git a/src/tests/api/test_order_change.py b/src/tests/api/test_order_change.py index f137e352b0..c8d724022c 100644 --- a/src/tests/api/test_order_change.py +++ b/src/tests/api/test_order_change.py @@ -609,6 +609,8 @@ def test_order_create_invoice(token_client, organizer, event, order): 'subevent': None, 'event_date_from': '2017-12-27T10:00:00Z', 'event_date_to': None, + 'period_start': '2017-12-27T10:00:00Z', + 'period_end': None, 'event_location': None, 'fee_type': None, 'fee_internal_type': None, @@ -627,6 +629,8 @@ def test_order_create_invoice(token_client, organizer, event, order): 'subevent': None, 'event_date_from': '2017-12-27T10:00:00Z', 'event_date_to': None, + 'period_start': '2017-12-27T10:00:00Z', + 'period_end': None, 'event_location': None, 'fee_type': "payment", 'fee_internal_type': None,