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