diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index a12f6e085..97c1ee082 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -728,6 +728,7 @@ class EventSettingsSerializer(SettingsSerializer): 'payment_term_minutes', 'payment_term_last', 'payment_term_expire_automatically', + 'payment_term_expire_delay_days', 'payment_term_accept_late', 'payment_explanation', 'payment_pending_hidden', diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index bfcfe1461..ba2c44d8a 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -896,6 +896,28 @@ class Order(LockModel, LoggedModel): ), tz) return term_last + @property + def payment_term_expire_date(self): + delay = self.event.settings.get('payment_term_expire_delay_days', as_type=int) + if not delay: # performance saver + backwards compatibility + return self.expires + + term_last = self.payment_term_last + if term_last and self.expires > term_last: # backwards compatibility + return self.expires + + expires = self.expires.date() + timedelta(days=delay) + + tz = ZoneInfo(self.event.settings.timezone) + expires = make_aware(datetime.combine( + expires, + time(hour=23, minute=59, second=59) + ), tz) + if term_last: + return min(expires, term_last) + else: + return expires + def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]: error_messages = { 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 6521d8547..5f1efcc98 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1270,12 +1270,12 @@ def expire_orders(sender, **kwargs): Exists( OrderFee.objects.filter(order_id=OuterRef('pk'), fee_type=OrderFee.FEE_TYPE_CANCELLATION) ) - ).select_related('event').order_by('event_id') + ).prefetch_related('event').order_by('event_id') for o in qs: if o.event_id != event_id: expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool) event_id = o.event_id - if expire: + if expire and now() >= o.payment_term_expire_date: mark_order_expired(o) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index b7675b1e4..46fab3bb9 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -893,6 +893,28 @@ DEFAULTS = { "the pool and can be ordered by other people."), ) }, + 'payment_term_expire_delay_days': { + 'default': '0', + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwargs': dict( + label=_('Expiration delay'), + help_text=_("The order will only actually expire this many days after the expiration date communicated " + "to the customer. However, this will not delay beyond the \"last date of payments\" " + "configured above, which is always enforced. The delay may also end on a weekend regardless " + "of the other settings above."), + # Every order in between the official expiry date and the delayed expiry date has a performance penalty + # for the cron job, so we limit this feature to 30 days to prevent arbitrary numbers of orders needing + # to be checked. + min_value=0, + max_value=30, + ), + 'serializer_kwargs': dict( + min_value=0, + max_value=30, + ), + }, 'payment_pending_hidden': { 'default': 'False', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 5dff7a338..7740a5144 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -749,6 +749,7 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm): 'payment_term_minutes', 'payment_term_last', 'payment_term_expire_automatically', + 'payment_term_expire_delay_days', 'payment_term_accept_late', 'payment_pending_hidden', 'payment_explanation', diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index ed29699f7..cb47b6734 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -65,6 +65,8 @@ {% bootstrap_field form.payment_term_minutes layout="control" %} {% bootstrap_field form.payment_term_last layout="control" %} {% bootstrap_field form.payment_term_expire_automatically layout="control" %} + {% trans "days" context "unit" as days %} + {% bootstrap_field form.payment_term_expire_delay_days layout="control" addon_after=days %} {% bootstrap_field form.payment_term_accept_late layout="control" %} {% bootstrap_field form.payment_pending_hidden layout="control" %} diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 5c2e02881..068935215 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -20,7 +20,8 @@ # . # import json -from datetime import datetime, timedelta +import zoneinfo +from datetime import date, datetime, timedelta from decimal import Decimal from zoneinfo import ZoneInfo @@ -31,6 +32,7 @@ from django.test import TestCase from django.utils.timezone import make_aware, now from django_countries.fields import Country from django_scopes import scope +from freezegun import freeze_time from tests.testdummy.signals import FoobazSalesChannel from pretix.base.decimal import round_decimal @@ -406,6 +408,56 @@ def test_expiring_auto_disabled(event): assert o2.status == Order.STATUS_PENDING +@pytest.mark.django_db +def test_expiring_auto_delayed(event): + event.settings.set('payment_term_expire_delay_days', 3) + event.settings.set('payment_term_last', date(2023, 7, 2)) + event.settings.set('timezone', 'Europe/Berlin') + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=datetime(2023, 6, 22, 12, 13, 14, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")), + expires=datetime(2023, 6, 30, 23, 59, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")), + total=0, + ) + o2 = Order.objects.create( + code='FO2', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=datetime(2023, 6, 22, 12, 13, 14, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")), + expires=datetime(2023, 6, 28, 23, 59, 59, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")), + total=0, + ) + assert o1.payment_term_expire_date == o1.expires + timedelta(days=2) # limited by term_last + assert o2.payment_term_expire_date == o2.expires + timedelta(days=3) + with freeze_time("2023-06-29T00:01:00+02:00"): + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_PENDING + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_PENDING + + with freeze_time("2023-07-01T23:50:00+02:00"): + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_PENDING + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_PENDING + + with freeze_time("2023-07-02T00:01:00+02:00"): + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_PENDING + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_EXPIRED + + with freeze_time("2023-07-03T00:01:00+02:00"): + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_EXPIRED + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_EXPIRED + + @pytest.mark.django_db def test_do_not_expire_if_approval_pending(event): o1 = Order.objects.create(