diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst
index 9188b73c24..de9245941f 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/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py
index a6cbea7ea6..1c0443a440 100644
--- a/src/pretix/base/invoicing/pdf.py
+++ b/src/pretix/base/invoicing/pdf.py
@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+import datetime
import logging
import math
import re
@@ -526,6 +527,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event'))))
canvas.drawText(textobject)
+ def _date_range_in_header(self):
+ if self.invoice.event.has_subevents or not self.invoice.event.settings.show_dates_on_frontpage:
+ return None, None
+ tz = self.invoice.event.timezone
+ show_end_date = (
+ self.invoice.event.settings.show_date_to and
+ self.invoice.event.date_to and
+ self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
+ )
+ if show_end_date:
+ return self.invoice.event.date_from.astimezone(tz).date(), self.invoice.event.date_to.astimezone(tz).date()
+ else:
+ return self.invoice.event.date_from.astimezone(tz).date(), None
+
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
@@ -539,25 +554,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_size = p.wrap(self.event_width, self.event_height)
return txt
- if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
- tz = self.invoice.event.timezone
- show_end_date = (
- self.invoice.event.settings.show_date_to and
- self.invoice.event.date_to and
- self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
+ d_from, d_to = self._date_range_in_header()
+ if d_from and d_to:
+ p_str = (
+ shorten(self.invoice.event.name) + '\n' +
+ pgettext('invoice', '{from_date}\nuntil {to_date}').format(
+ from_date=date_format(d_from, "DATE_FORMAT"),
+ to_date=date_format(d_to, "DATE_FORMAT"),
+ )
)
- if show_end_date:
- p_str = (
- shorten(self.invoice.event.name) + '\n' +
- pgettext('invoice', '{from_date}\nuntil {to_date}').format(
- from_date=self.invoice.event.get_date_from_display(show_times=False),
- to_date=self.invoice.event.get_date_to_display(show_times=False)
- )
- )
- else:
- p_str = (
- shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
- )
+ elif d_from:
+ p_str = shorten(self.invoice.event.name) + '\n' + date_format(d_from, "DATE_FORMAT")
else:
p_str = shorten(self.invoice.event.name)
@@ -685,7 +692,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return story
def _get_story(self, doc):
- has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
+ all_lines = list(self.invoice.lines.all())
+ has_taxes = any(il.tax_value for il in all_lines) or self.invoice.reverse_charge
+ header_dates = self._date_range_in_header()
+ tz = self.invoice.event.timezone
+ has_multiple_service_dates = len(set(
+ (il.period_start, il.period_end) for il in all_lines
+ )) > 1
+ request_show_service_date = False
story = [
NextPageTemplate('FirstPage'),
@@ -729,15 +743,75 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)]
def _group_key(line):
- return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id,
- line.event_date_from, line.event_date_to)
+ return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent,
+ line.period_start, line.period_end)
+
+ def day(dt: datetime.datetime) -> datetime.date:
+ if dt is None:
+ return None
+ return dt.astimezone(tz).date()
total = Decimal('0.00')
- for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby(
- self.invoice.lines.all(),
+ for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
+ all_lines,
key=_group_key,
is_addon=lambda l: l.description.startswith(" +"),
):
+ # Try to be clever and figure out when organizers would want to show the period. This heuristic is
+ # not perfect and the only "fully correct" way would be to include the period on every line always,
+ # however this will cause confusion (a) due to useless repetition of the same date all over the invoice
+ # (b) due to not respecting the show_date_to setting of events in cases where we could have respected it.
+ # Still, we want to show the date explicitly if its different to the event or invoice date.
+ period_start_day = day(period_start)
+ period_end_day = day(period_end)
+ if period_start and period_end and period_end_day != period_start_day:
+ # It's a multi-day period, such as the validity of the ticket or an event date period
+
+ if period_start_day == header_dates[0] and period_end_day == header_dates[1]:
+ # This is the exact event period we already printed in the header, no need to repeat it.
+ period_line = ""
+
+ elif (self.event.has_subevents and subevent and day(subevent.date_from) == period_start_day and
+ day(subevent.date_to) == period_end_day):
+ # For subevents, build_invoice already includes the date in the description in the event-default format.
+ period_line = ""
+
+ else:
+ period_line = f"{date_format(period_start_day, 'SHORT_DATE_FORMAT')} – {date_format(period_end_day, 'SHORT_DATE_FORMAT')}"
+
+ elif period_start or period_end:
+ # It's a single-day period
+
+ delivery_day = period_end_day or period_start_day
+ if delivery_day in header_dates:
+ # This is the event date we already printed in the header, no need to repeat it.
+ period_line = ""
+
+ elif self.event.has_subevents and subevent and delivery_day in (day(subevent.date_from), day(subevent.date_to)):
+ # For subevents, build_invoice already includes the date in the description in the event-default format.
+ period_line = ""
+
+ elif (delivery_day == self.invoice.date) and header_dates[0] is None:
+ # This is a shop that doesn't show the date of the event in the header, and the period is the invoice
+ # date. We assume that this is an 'everything is executed immediately' situation and do not want to
+ # confuse with showing additional dates on the invoice. This is the case that is not guaranteed to be
+ # correct in all cases and might need to change in the future. If customers have legal concerns, a
+ # quick fix is including a sentence like "Delivery date is the invoice date unless otherwise indicated:"
+ # in a custom text on the invoice.
+ period_line = ""
+
+ else:
+ period_line = date_format(delivery_day, 'SHORT_DATE_FORMAT')
+ else:
+ # No period known
+ period_line = ""
+
+ if not has_multiple_service_dates and period_line:
+ # Group together at the end of the invoice
+ request_show_service_date = period_line
+ elif period_line:
+ description += "\n" + period_line
+
lines = list(lines)
if has_taxes:
if len(lines) > 1:
@@ -746,6 +820,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
+
tdata.append((
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
@@ -850,6 +925,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm))
+ if request_show_service_date:
+ story.append(FontFallbackParagraph(
+ self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
+ self.stylesheet['Normal']
+ ))
+
if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph(
self._normalize(self.invoice.payment_provider_text),
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..56f68b8b3d
--- /dev/null
+++ b/src/pretix/base/migrations/0289_invoiceline_period.py
@@ -0,0 +1,78 @@
+# Generated by Django 4.2.17 on 2025-09-08 08:14
+from django.core.cache import cache
+from django.db import migrations
+
+
+def set_invoice_period(apps, schema_editor):
+ EventSettingsStore = apps.get_model("pretixbase", "Event_SettingsStore")
+ ev_seen = set()
+ insert_queue = []
+ flush_queue = []
+
+ def store():
+ EventSettingsStore.objects.bulk_create(
+ insert_queue,
+ update_conflicts=True,
+ update_fields=["value"],
+ unique_fields=["object", "key"],
+ )
+ cache.delete_many(flush_queue)
+ flush_queue.clear()
+ insert_queue.clear()
+
+ # Existing events that use pretix-zugferd and have explicitly disabled delivery dates
+ for setting in EventSettingsStore.objects.filter(key="zugferd_include_delivery_date", value="False"):
+ flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
+ insert_queue.append(
+ EventSettingsStore(
+ object_id=setting.object_id,
+ key="invoice_period",
+ value="invoice_date",
+ )
+ )
+ ev_seen.add(setting.object_id)
+
+ if len(insert_queue) > 1000:
+ store()
+
+ # Existing events that previously hid their date on invoices
+ for setting in EventSettingsStore.objects.filter(key="show_dates_on_frontpage", value="False"):
+ if setting.object_id in ev_seen:
+ continue
+
+ flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
+ insert_queue.append(
+ EventSettingsStore(
+ object_id=setting.object_id,
+ key="invoice_period",
+ value="auto_no_event",
+ )
+ )
+
+ if len(insert_queue) > 1000:
+ store()
+
+ store()
+
+
+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",
+ ),
+ migrations.RunPython(
+ set_invoice_period,
+ migrations.RunPython.noop,
+ )
+ ]
diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py
index 0dfde5d570..d9401f8578 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
@@ -404,10 +405,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
@@ -426,8 +427,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)
@@ -444,3 +445,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 bbc7438dde..a47be9fef2 100644
--- a/src/pretix/base/services/invoices.py
+++ b/src/pretix/base/services/invoices.py
@@ -82,6 +82,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
lp = invoice.order.payments.last()
+ min_period_start = None
+ max_period_end = None
+ now_dt = now()
+
with (language(invoice.locale, invoice.event.settings.region)):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
@@ -208,7 +212,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
addon_c=Count('addons')
- ).prefetch_related('answers', 'answers__options', 'answers__question').order_by('positionid', 'id')
+ ).prefetch_related(
+ 'answers', 'answers__options', 'answers__question', 'granted_memberships',
+ ).order_by('positionid', 'id')
)
reverse_charge = False
@@ -267,6 +273,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
location=_location_oneliner(location)
)
+ period_start, period_end = _service_period_for_position(invoice, p, now_dt)
+ min_period_start = min(min_period_start or period_start, period_start)
+ max_period_end = min(max_period_end or period_end, period_end)
+
InvoiceLine.objects.create(
position=i,
invoice=invoice,
@@ -277,8 +287,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=period_start,
+ period_end=period_end,
event_location=location if invoice.event.settings.invoice_event_location else None,
tax_rate=p.tax_rate,
tax_code=p.tax_code,
@@ -301,13 +311,29 @@ def build_invoice(invoice: Invoice) -> Invoice:
fee_title = _(fee.get_fee_type_display())
if fee.description:
fee_title += " - " + fee.description
+
+ if min_period_start and max_period_end:
+ # Consider fees to have the same service period as the products sold
+ period_start = min_period_start
+ period_end = max_period_end
+ else:
+ # Usually can only happen if everything except a cancellation fee is removed
+ if invoice.event.settings.invoice_period in ("auto", "auto_no_event", "event_date") and not invoice.event.has_subevents:
+ # Non-series event, let's be backwards-compatible and tag everything with the event period
+ period_start = invoice.event.date_from
+ period_end = invoice.event.date_to
+ else:
+ # We could try to work from the canceled positions, but it doesn't really make sense. A cancellation
+ # fee is not "delivered" at the event date, it is rather effective right now.
+ period_start = period_end = now()
+
InvoiceLine.objects.create(
position=i + offset,
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=period_start,
+ period_end=period_end,
event_location=(
None if invoice.event.has_subevents
else (str(invoice.event.location)
@@ -351,6 +377,55 @@ def build_cancellation(invoice: Invoice):
return invoice
+def _service_period_for_position(invoice, position, invoice_dt):
+ if invoice.event.settings.invoice_period in ("auto", "auto_no_event"):
+ if position.valid_from and position.valid_until:
+ period_start = position.valid_from
+ period_end = position.valid_until
+ elif position.valid_from:
+ period_start = position.valid_from
+ period_end = position.valid_from # weird, but we have nothing else to base this on
+ elif position.valid_until:
+ period_start = min(invoice.order.datetime, position.valid_until)
+ period_end = position.valid_until
+ elif memberships := list(position.granted_memberships.all()):
+ period_start = min(m.date_start for m in memberships)
+ period_end = max(m.date_end for m in memberships)
+ elif invoice.event.has_subevents:
+ if position.subevent:
+ period_start = position.subevent.date_from
+ period_end = position.subevent.date_to
+ else:
+ # Currently impossible case, but might not be in the future and never makes
+ # sense to use the event date here
+ period_start = invoice_dt
+ period_end = invoice_dt
+ elif invoice.event.settings.invoice_period == "auto_no_event":
+ period_start = invoice_dt
+ period_end = invoice_dt
+ else:
+ period_start = invoice.event.date_from
+ period_end = invoice.event.date_to
+ elif invoice.event.settings.invoice_period == "order_date":
+ period_start = invoice.order.datetime
+ period_end = invoice.order.datetime
+ elif invoice.event.settings.invoice_period == "event_date":
+ if position.subevent:
+ period_start = position.subevent.date_from
+ period_end = position.subevent.date_to
+ else:
+ period_start = invoice.event.date_from
+ period_end = invoice.event.date_to
+ elif invoice.event.settings.invoice_period == "invoice_date":
+ period_start = period_end = invoice_dt
+ else:
+ raise ValueError(f"Invalid invoice period setting '{invoice.event.settings.invoice_period}'")
+
+ if not period_end:
+ period_end = period_start
+ return period_start, period_end
+
+
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
if invoice.canceled:
raise ValueError("Invoice should not be canceled twice.")
@@ -456,6 +531,12 @@ def build_preview_invoice_pdf(event):
if not locale or locale == '__user__':
locale = event.settings.locale
+ if event.settings.invoice_period in ("auto", "auto_no_event", "event_date"):
+ period_start = event.date_from
+ period_end = event.date_to or event.date_from
+ else:
+ period_start = period_end = timezone.now()
+
with rolledback_transaction(), language(locale, event.settings.region):
order = event.orders.create(
status=Order.STATUS_PENDING, datetime=timezone.now(),
@@ -506,8 +587,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=period_start,
+ period_end=period_end,
event_location=event.settings.invoice_event_location,
)
else:
@@ -515,8 +596,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=period_start,
+ period_end=period_end,
event_location=event.settings.invoice_event_location,
)
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index fbc52b31a9..2b87f00bb9 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -1098,6 +1098,35 @@ 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', _('Automatic based on ticket-specific validity, membership validity, event series date, or event date)')),
+ ('auto_no_event', _('Automatic, but prefer invoice date over event date')),
+ ('event_date', _('Event date')),
+ ('order_date', _('Order date')),
+ ('invoice_date', _('Invoice date')),
+ ),
+ ),
+ 'form_kwargs': dict(
+ label=_("Date of service"),
+ widget=forms.RadioSelect,
+ choices=(
+ ('auto', _('Automatic based on ticket-specific validity, membership validity, event series date, or event date)')),
+ ('auto_no_event', _('Automatic, but prefer invoice date over event date')),
+ ('event_date', _('Event 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."),
+ required=True,
+ )
+ },
'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..52d9a7e9bb 100644
--- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html
+++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html
@@ -15,6 +15,19 @@
{% 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" %}
+
+ {% if not request.event.settings.show_dates_on_frontpage %}
+
+
+ {% blocktrans trimmed %}
+ You configured that your shop is not an event and the event date should not be shown.
+ Therefore, we recommend that you set the date of service to a different option.
+ {% endblocktrans %}
+
+
+ {% endif %}
+
{% 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..10cf0fb1a4 100644
--- a/src/tests/api/test_invoices.py
+++ b/src/tests/api/test_invoices.py
@@ -231,7 +231,9 @@ TEST_INVOICE_RES = {
"description": "Budget Ticket
Attendee: Peter",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
- 'event_date_to': None,
+ 'event_date_to': '2017-12-27T10:00:00Z',
+ 'period_start': '2017-12-27T10:00:00Z',
+ 'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'attendee_name': 'Peter',
'item': None,
@@ -249,7 +251,9 @@ TEST_INVOICE_RES = {
"description": "Payment fee",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
- 'event_date_to': None,
+ 'event_date_to': '2017-12-27T10:00:00Z',
+ 'period_start': '2017-12-27T10:00:00Z',
+ 'period_end': '2017-12-27T10:00:00Z',
'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..e061aad40e 100644
--- a/src/tests/api/test_order_change.py
+++ b/src/tests/api/test_order_change.py
@@ -608,7 +608,9 @@ def test_order_create_invoice(token_client, organizer, event, order):
'description': 'Budget Ticket
Attendee: Peter',
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
- 'event_date_to': None,
+ 'event_date_to': '2017-12-27T10:00:00Z',
+ 'period_start': '2017-12-27T10:00:00Z',
+ 'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'fee_type': None,
'fee_internal_type': None,
@@ -626,7 +628,9 @@ def test_order_create_invoice(token_client, organizer, event, order):
'description': 'Payment fee',
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
- 'event_date_to': None,
+ 'event_date_to': '2017-12-27T10:00:00Z',
+ 'period_start': '2017-12-27T10:00:00Z',
+ 'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'fee_type': "payment",
'fee_internal_type': None,
diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py
index 584a2bb017..b50a98f806 100644
--- a/src/tests/base/test_invoices.py
+++ b/src/tests/base/test_invoices.py
@@ -33,7 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import json
-from datetime import date, timedelta
+from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
import pytest
@@ -42,6 +42,7 @@ from django.utils.itercompat import is_iterable
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
+from i18nfield.strings import LazyI18nString
from pretix.base.invoice import addon_aware_groupby
from pretix.base.models import (
@@ -62,7 +63,8 @@ def env():
with scope(organizer=o):
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
- date_from=now(), plugins='pretix.plugins.banktransfer'
+ date_from=datetime(2024, 12, 1, 9, 0, 0, tzinfo=timezone.utc),
+ plugins='pretix.plugins.banktransfer'
)
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
@@ -660,3 +662,154 @@ def test_addon_aware_groupby():
[True, 102, 3.00],
]],
]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("period", ["auto", "event_date"])
+def test_period_from_event_start(env, period):
+ event, order = env
+ event.settings.invoice_period = period
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == event.date_from
+ assert l1.period_end == event.date_from
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("period", ["auto", "event_date"])
+def test_period_from_event_range(env, period):
+ event, order = env
+ event.date_to = event.date_from + timedelta(days=1)
+ event.settings.invoice_period = period
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == event.date_from
+ assert l1.period_end == event.date_to
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
+def test_period_from_ticket_validity(env, period):
+ event, order = env
+ p1 = order.positions.first()
+ p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
+ p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
+ p1.save()
+ event.date_to = event.date_from + timedelta(days=1)
+ event.settings.invoice_period = period
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == p1.valid_from
+ assert l1.period_end == p1.valid_until
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
+def test_period_from_subevent(env, period):
+ event, order = env
+ event.has_subevents = True
+ event.save()
+ se1 = event.subevents.create(
+ name=event.name,
+ active=True,
+ date_from=datetime((now().year + 1), 7, 31, 9, 0, 0, tzinfo=timezone.utc),
+ date_to=datetime((now().year + 1), 7, 31, 17, 0, 0, tzinfo=timezone.utc),
+ )
+ p1 = order.positions.first()
+ p1.subevent = se1
+ p1.save()
+ event.date_to = event.date_from + timedelta(days=1)
+ event.settings.invoice_period = period
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == se1.date_from
+ assert l1.period_end == se1.date_to
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
+def test_period_from_memberships(env, period):
+ event, order = env
+ event.date_to = event.date_from + timedelta(days=1)
+ event.settings.invoice_period = period
+ p1 = order.positions.first()
+ membershiptype = event.organizer.membership_types.create(
+ name=LazyI18nString({"en": "Week pass"}),
+ transferable=True,
+ allow_parallel_usage=False,
+ max_usages=15,
+ )
+ customer = event.organizer.customers.create(
+ identifier="8WSAJCJ",
+ email="foo@example.org",
+ name_parts={"_legacy": "Foo"},
+ name_cached="Foo",
+ is_verified=False,
+ )
+ m = customer.memberships.create(
+ membership_type=membershiptype,
+ granted_in=p1,
+ date_start=datetime(2021, 4, 1, 0, 0, 0, 0, tzinfo=timezone.utc),
+ date_end=datetime(2021, 4, 8, 23, 59, 59, 999999, tzinfo=timezone.utc),
+ attendee_name_parts={
+ "_scheme": "given_family",
+ 'given_name': 'John',
+ 'family_name': 'Doe',
+ }
+ )
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == m.date_start
+ assert l1.period_end == m.date_end
+
+
+@pytest.mark.django_db
+def test_period_auto_no_event_from_invoice(env):
+ event, order = env
+ event.settings.invoice_period = "auto_no_event"
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert abs(l1.period_start - now()) < timedelta(seconds=10)
+ assert abs(l1.period_end - now()) < timedelta(seconds=10)
+
+
+@pytest.mark.django_db
+def test_period_always_invoice_date(env):
+ event, order = env
+ p1 = order.positions.first()
+ p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
+ p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
+ p1.save()
+ event.settings.invoice_period = "invoice_date"
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert abs(l1.period_start - now()) < timedelta(seconds=10)
+ assert abs(l1.period_end - now()) < timedelta(seconds=10)
+
+
+@pytest.mark.django_db
+def test_period_always_event_date(env):
+ event, order = env
+ p1 = order.positions.first()
+ p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
+ p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
+ p1.save()
+ event.settings.invoice_period = "event_date"
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == event.date_from
+ assert l1.period_end == event.date_from
+
+
+@pytest.mark.django_db
+def test_period_always_order_date(env):
+ event, order = env
+ p1 = order.positions.first()
+ p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
+ p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
+ p1.save()
+ event.settings.invoice_period = "order_date"
+ inv = generate_invoice(order)
+ l1 = inv.lines.first()
+ assert l1.period_start == order.datetime
+ assert l1.period_end == order.datetime