diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index bab788b237..c129108153 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -336,6 +336,12 @@ class BasePaymentProvider: help_text=_('Users will not be able to choose this payment provider after the given date.'), required=False, )), + ('_availability_start', + RelativeDateField( + label=_('Available from'), + help_text=_('Users will not be able to choose this payment provider before the given date.'), + required=False, + )), ('_total_min', forms.DecimalField( label=_('Minimum order total'), @@ -554,40 +560,65 @@ class BasePaymentProvider: return form - def _is_still_available(self, now_dt=None, cart_id=None, order=None): + def _absolute_availability_date(self, rel_date, cart_id=None, order=None, aggregate_fn=min): + if not rel_date: + return None + if self.event.has_subevents and cart_id: + dates = [ + rel_date.datetime(se).date() + for se in self.event.subevents.filter( + id__in=CartPosition.objects.filter( + cart_id=cart_id, event=self.event + ).values_list('subevent', flat=True) + ) + ] + return aggregate_fn(dates) if dates else None + elif self.event.has_subevents and order: + dates = [ + rel_date.datetime(se).date() + for se in self.event.subevents.filter( + id__in=order.positions.values_list('subevent', flat=True) + ) + ] + return aggregate_fn(dates) if dates else None + elif self.event.has_subevents: + raise NotImplementedError('Payment provider is not subevent-ready.') + else: + return rel_date.datetime(self.event).date() + + def _is_available_by_time(self, now_dt=None, cart_id=None, order=None): now_dt = now_dt or now() tz = ZoneInfo(self.event.settings.timezone) - availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper) - if availability_date: - if self.event.has_subevents and cart_id: - dates = [ - availability_date.datetime(se).date() - for se in self.event.subevents.filter( - id__in=CartPosition.objects.filter( - cart_id=cart_id, event=self.event - ).values_list('subevent', flat=True) - ) - ] - availability_date = min(dates) if dates else None - elif self.event.has_subevents and order: - dates = [ - availability_date.datetime(se).date() - for se in self.event.subevents.filter( - id__in=order.positions.values_list('subevent', flat=True) - ) - ] - availability_date = min(dates) if dates else None - elif self.event.has_subevents: - logger.error('Payment provider is not subevent-ready.') - return False - else: - availability_date = availability_date.datetime(self.event).date() + try: + availability_start = self._absolute_availability_date( + self.settings.get('_availability_start', as_type=RelativeDateWrapper), + cart_id, + order, + # In an event series, we use min() for the start as well. This might be inconsistent with using min() for + # for the end, but makes it harder to put one self into a situation where no payment provider is available. + aggregate_fn=min + ) - if availability_date: - return availability_date >= now_dt.astimezone(tz).date() + if availability_start: + if availability_start > now_dt.astimezone(tz).date(): + return False - return True + availability_end = self._absolute_availability_date( + self.settings.get('_availability_date', as_type=RelativeDateWrapper), + cart_id, + order, + aggregate_fn=min + ) + + if availability_end: + if availability_end < now_dt.astimezone(tz).date(): + return False + + return True + except NotImplementedError: + logger.exception('Unable to check availability') + return False def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: """ @@ -596,9 +627,9 @@ class BasePaymentProvider: user will not be able to select this payment method. This will only be called during checkout, not on retrying. - The default implementation checks for the _availability_date setting to be either unset or in the future - and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries`` - and ``_restrict_to_sales_channels`` setting. + The default implementation checks for the ``_availability_date`` setting to be either unset or in the future + and for the ``_availability_from``, ``_total_max``, and ``_total_min`` requirements to be met. It also checks + the ``_restrict_countries`` and ``_restrict_to_sales_channels`` setting. :param total: The total value without the payment method fee, after taxes. @@ -607,7 +638,7 @@ class BasePaymentProvider: The ``total`` parameter has been added. For backwards compatibility, this method is called again without this parameter if it raises a ``TypeError`` on first try. """ - timing = self._is_still_available(cart_id=get_or_create_cart_id(request)) + timing = self._is_available_by_time(cart_id=get_or_create_cart_id(request)) pricing = True if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None: @@ -791,8 +822,8 @@ class BasePaymentProvider: Will be called to check whether it is allowed to change the payment method of an order to this one. - The default implementation checks for the _availability_date setting to be either unset or in the future, - as well as for the _total_max, _total_min and _restricted_countries settings. + The default implementation checks for the ``_availability_date`` setting to be either unset or in the future, + as well as for the ``_availability_from``, ``_total_max``, ``_total_min``, and ``_restricted_countries`` settings. :param order: The order object """ @@ -819,7 +850,7 @@ class BasePaymentProvider: if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']): return False - return self._is_still_available(order=order) + return self._is_available_by_time(order=order) def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]: """ diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index a964f9cb78..8fa14b7530 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -554,9 +554,6 @@ class StripeMethod(BasePaymentProvider): ctx = {'request': request, 'event': self.event, 'settings': self.settings, 'provider': self} return template.render(ctx) - def payment_can_retry(self, payment): - return self._is_still_available(order=payment.order) - def _charge_source(self, request, source, payment): try: params = {} @@ -1581,9 +1578,6 @@ class StripeSofort(StripeMethod): return True return False - def payment_can_retry(self, payment): - return payment.state != OrderPayment.PAYMENT_STATE_PENDING and self._is_still_available(order=payment.order) - def payment_presale_render(self, payment: OrderPayment) -> str: pi = payment.info_data or {} try: diff --git a/src/tests/base/test_payment.py b/src/tests/base/test_payment.py index f3983ac20c..da22ab3234 100644 --- a/src/tests/base/test_payment.py +++ b/src/tests/base/test_payment.py @@ -97,7 +97,15 @@ def test_payment_fee_reverse_percent_and_abs_default(event): def test_availability_date_available(event): prov = DummyPaymentProvider(event) prov.settings.set('_availability_date', datetime.date.today() + datetime.timedelta(days=1)) - result = prov._is_still_available() + result = prov._is_available_by_time() + assert result + + +@pytest.mark.django_db +def test_availability_start_available(event): + prov = DummyPaymentProvider(event) + prov.settings.set('_availability_start', datetime.date.today() - datetime.timedelta(days=1)) + result = prov._is_available_by_time() assert result @@ -105,7 +113,15 @@ def test_availability_date_available(event): def test_availability_date_not_available(event): prov = DummyPaymentProvider(event) prov.settings.set('_availability_date', datetime.date.today() - datetime.timedelta(days=1)) - result = prov._is_still_available() + result = prov._is_available_by_time() + assert not result + + +@pytest.mark.django_db +def test_availability_start_not_available(event): + prov = DummyPaymentProvider(event) + prov.settings.set('_availability_start', datetime.date.today() + datetime.timedelta(days=1)) + result = prov._is_available_by_time() assert not result @@ -121,9 +137,26 @@ def test_availability_date_relative(event): )) utc = datetime.timezone.utc - assert prov._is_still_available(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc)) - assert prov._is_still_available(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc)) - assert not prov._is_still_available(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc)) + assert prov._is_available_by_time(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc)) + assert prov._is_available_by_time(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc)) + assert not prov._is_available_by_time(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc)) + + +@pytest.mark.django_db +def test_availability_start_relative(event): + event.settings.set('timezone', 'US/Pacific') + tz = ZoneInfo('US/Pacific') + event.date_from = datetime.datetime(2016, 12, 3, 12, 0, 0, tzinfo=tz) + event.save() + prov = DummyPaymentProvider(event) + prov.settings.set('_availability_start', RelativeDateWrapper( + RelativeDate(days_before=2, time=datetime.time(12, 0), base_date_name='date_from', minutes_before=None) + )) + + utc = datetime.timezone.utc + assert not prov._is_available_by_time(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc)) + assert prov._is_available_by_time(datetime.datetime(2016, 12, 1, 0, 0, tzinfo=tz).astimezone(utc)) + assert prov._is_available_by_time(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc)) @pytest.mark.django_db @@ -134,9 +167,9 @@ def test_availability_date_timezones(event): tz = ZoneInfo('US/Pacific') utc = ZoneInfo('UTC') - assert prov._is_still_available(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc)) - assert prov._is_still_available(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc)) - assert not prov._is_still_available(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc)) + assert prov._is_available_by_time(datetime.datetime(2016, 11, 30, 23, 0, 0, tzinfo=tz).astimezone(utc)) + assert prov._is_available_by_time(datetime.datetime(2016, 12, 1, 23, 59, 0, tzinfo=tz).astimezone(utc)) + assert not prov._is_available_by_time(datetime.datetime(2016, 12, 2, 0, 0, 1, tzinfo=tz).astimezone(utc)) @pytest.mark.django_db @@ -162,12 +195,12 @@ def test_availability_date_cart_relative_subevents(event): prov.settings.set('_availability_date', RelativeDateWrapper( RelativeDate(days_before=3, time=None, base_date_name='date_from', minutes_before=None) )) - assert prov._is_still_available(cart_id="123") + assert prov._is_available_by_time(cart_id="123") prov.settings.set('_availability_date', RelativeDateWrapper( RelativeDate(days_before=4, time=None, base_date_name='date_from', minutes_before=None) )) - assert not prov._is_still_available(cart_id="123") + assert not prov._is_available_by_time(cart_id="123") @pytest.mark.django_db @@ -201,9 +234,9 @@ def test_availability_date_order_relative_subevents(event): prov.settings.set('_availability_date', RelativeDateWrapper( RelativeDate(days_before=3, time=None, base_date_name='date_from', minutes_before=None) )) - assert prov._is_still_available(order=order) + assert prov._is_available_by_time(order=order) prov.settings.set('_availability_date', RelativeDateWrapper( RelativeDate(days_before=4, time=None, base_date_name='date_from', minutes_before=None) )) - assert not prov._is_still_available(order=order) + assert not prov._is_available_by_time(order=order)