From 50d724f9e3bf93b49ee2d12f44e2d201342cb615 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 8 Sep 2025 18:15:40 +0200 Subject: [PATCH] Fix tests --- src/pretix/base/invoicing/pdf.py | 105 ++++++++++++++---- src/pretix/base/services/invoices.py | 86 +++++++++++++-- src/pretix/base/settings.py | 11 +- src/tests/api/test_invoices.py | 8 +- src/tests/api/test_order_change.py | 8 +- src/tests/base/test_invoices.py | 158 ++++++++++++++++++++++++++- 6 files changed, 331 insertions(+), 45 deletions(-) diff --git a/src/pretix/base/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py index 843e21bcd7..cc279e65a5 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 re import unicodedata @@ -522,6 +523,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) @@ -535,25 +550,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) @@ -657,6 +664,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): def _get_story(self, doc): has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge + header_dates = self._date_range_in_header() + tz = self.invoice.event.timezone story = [ NextPageTemplate('FirstPage'), @@ -700,15 +709,68 @@ 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( + for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby( self.invoice.lines.all(), 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. + if period_start and period_end and day(period_end) != day(period_start): + # It's a multi-day period, such as the validity of the ticket or an event date period + + if day(period_start) == header_dates[0] and day(period_end) == 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) == day(period_start) and + day(subevent.date_to) == day(period_end)): + # For subevents, build_invoice already includes the date in the description in the event-default format. + period_line = "" + + else: + period_line = f"\n{date_format(day(period_start), 'SHORT_DATE_FORMAT')} – {date_format(day(period_end), 'SHORT_END_FORMAT')}" + + elif period_start or period_end: + # It's a single-day period + + delivery_day = day(period_end or period_start) + if delivery_day in (header_dates[0], header_dates[1]): + # 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 = f"\n{date_format(day(delivery_day), 'SHORT_DATE_FORMAT')}" + else: + # No period known + period_line = "" + + description += period_line lines = list(lines) if has_taxes: if len(lines) > 1: @@ -717,6 +779,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']), diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 307b6d1bce..d1a86a9c97 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -82,6 +82,9 @@ def build_invoice(invoice: Invoice) -> Invoice: lp = invoice.order.payments.last() + min_period_start = None + max_period_end = None + 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 +211,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 +272,10 @@ def build_invoice(invoice: Invoice) -> Invoice: location=_location_oneliner(location) ) + period_start, period_end = _service_period_for_position(invoice, p) + 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 +286,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, - 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, + 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 +310,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, - 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, + period_start=period_start, + period_end=period_end, event_location=( None if invoice.event.has_subevents else (str(invoice.event.location) @@ -351,6 +376,43 @@ def build_cancellation(invoice: Invoice): return invoice +def _service_period_for_position(invoice, position): + if invoice.event.settings.invoice_period in ("auto", "auto_no_event"): + if position.valid_from or position.valid_until: + period_start = position.valid_from or now() + 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 and position.subevent: + period_start = position.subevent.date_from + period_end = position.subevent.date_to + elif invoice.event.settings.invoice_period == "auto_no_event": + period_start = now() + period_end = now() + 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 = now() + 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 +518,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 +574,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, - period_start=event.date_from, - period_end=event.date_to, + period_start=period_start, + period_end=period_end, event_location=event.settings.invoice_event_location, ) else: @@ -515,8 +583,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, - period_start=event.date_from, - period_end=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 15e37ebb53..2b87f00bb9 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1105,8 +1105,9 @@ DEFAULTS = { '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')), + ('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')), ), @@ -1115,13 +1116,15 @@ DEFAULTS = { 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')), + ('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': { diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py index 3eb33f1ff2..10cf0fb1a4 100644 --- a/src/tests/api/test_invoices.py +++ b/src/tests/api/test_invoices.py @@ -231,9 +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': None, + 'period_end': '2017-12-27T10:00:00Z', 'event_location': None, 'attendee_name': 'Peter', 'item': None, @@ -251,9 +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': None, + '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 c8d724022c..e061aad40e 100644 --- a/src/tests/api/test_order_change.py +++ b/src/tests/api/test_order_change.py @@ -608,9 +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': None, + 'period_end': '2017-12-27T10:00:00Z', 'event_location': None, 'fee_type': None, 'fee_internal_type': None, @@ -628,9 +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': None, + '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..2ce90e440c 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -19,7 +19,6 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # - # This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of # the Apache License 2.0 can be obtained at . # @@ -33,7 +32,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 +41,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 +62,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 +661,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