diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index c611f07e51..e1ce381baf 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -7,6 +7,7 @@ import pytz from django import forms from django.conf import settings from django.contrib import messages +from django.core.exceptions import ImproperlyConfigured from django.dispatch import receiver from django.forms import Form from django.http import HttpRequest @@ -185,6 +186,28 @@ class BasePaymentProvider: widget=I18nTextarea, widget_kwargs={'attrs': {'rows': '2'}} )), + ('_total_min', + forms.DecimalField( + label=_('Minimum order total'), + help_text=_('This payment will be available only if the order total is equal to or exceeds the given ' + 'value. The order total for this purpose may be computed without taking the fees imposed ' + 'by this payment method into account.'), + localize=True, + required=False, + decimal_places=places, + widget=DecimalTextInput(places=places) + )), + ('_total_max', + forms.DecimalField( + label=_('Maximum order total'), + help_text=_('This payment will be available only if the order total is equal to or below the given ' + 'value. The order total for this purpose may be computed without taking the fees imposed ' + 'by this payment method into account.'), + localize=True, + required=False, + decimal_places=places, + widget=DecimalTextInput(places=places) + )), ('_fee_abs', forms.DecimalField( label=_('Additional fee'), @@ -304,16 +327,36 @@ class BasePaymentProvider: return True - def is_allowed(self, request: HttpRequest) -> bool: + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: """ You can use this method to disable this payment provider for certain groups of users, products or other criteria. If this method returns ``False``, the 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. + 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. + + :param total: The total value without the payment method fee, after taxes. + + .. versionchanged:: 1.17.0 + + 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. """ - return self._is_still_available(cart_id=get_or_create_cart_id(request)) + timing = self._is_still_available(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: + raise ImproperlyConfigured('This payment provider does not support maximum or minimum amounts.') + + if self.settings._total_max is not None: + pricing = pricing and total <= Decimal(self.settings._total_max) + + if self.settings._total_min is not None: + pricing = pricing and total >= Decimal(self.settings._total_min) + + return timing and pricing def payment_form_render(self, request: HttpRequest) -> str: """ @@ -451,6 +494,12 @@ class BasePaymentProvider: :param order: The order object """ + if self.settings._total_max is not None and order.total > Decimal(self.settings._total_max): + return False + + if self.settings._total_min is not None and order.total < Decimal(self.settings._total_min): + return False + return self._is_still_available(order=order) def order_can_retry(self, order: Order) -> bool: @@ -647,7 +696,7 @@ class FreeOrderProvider(BasePaymentProvider): mark_order_refunded(order, user=request.user) messages.success(request, _('The order has been marked as refunded.')) - def is_allowed(self, request: HttpRequest) -> bool: + def is_allowed(self, request: HttpRequest, total: Decimal) -> bool: from .services.cart import get_fees total = get_cart_total(request) @@ -684,7 +733,7 @@ class BoxOfficeProvider(BasePaymentProvider): mark_order_refunded(order, user=request.user) messages.success(request, _('The order has been marked as refunded.')) - def is_allowed(self, request: HttpRequest) -> bool: + def is_allowed(self, request: HttpRequest, total: Decimal) -> bool: return False def order_change_allowed(self, order: Order) -> bool: diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 48910b9db8..a1313ab77e 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -437,7 +437,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): def provider_forms(self): providers = [] for provider in self.request.event.get_payment_providers().values(): - if not provider.is_enabled or not provider.is_allowed(self.request): + if not provider.is_enabled or not self._is_allowed(provider, self.request): continue fee = provider.calculate_fee(self._total_order_value) providers.append({ @@ -480,6 +480,12 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): def payment_provider(self): return self.request.event.get_payment_providers().get(self.cart_session['payment']) + def _is_allowed(self, prov, request): + try: + return prov.is_allowed(request, total=self._total_order_value) + except TypeError: + return prov.is_allowed(request) + def is_completed(self, request, warn=False): self.request = request if 'payment' not in self.cart_session or not self.payment_provider: @@ -488,7 +494,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): return False if not self.payment_provider.payment_is_valid_session(request) or \ not self.payment_provider.is_enabled or \ - not self.payment_provider.is_allowed(request): + not self._is_allowed(self.payment_provider, request): if warn: messages.error(request, _('The payment information you entered was incomplete.')) return False @@ -499,7 +505,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): for p in self.request.event.get_payment_providers().values(): if p.is_implicit: - if p.is_allowed(request): + if self._is_allowed(p, request): self.cart_session['payment'] = p.identifier return False diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index b3c7321d02..636de97022 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -688,6 +688,49 @@ class CheckoutTestCase(TestCase): self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), target_status_code=200) + def test_payment_max_value(self): + self.event.settings.set('payment_stripe__enabled', True) + self.event.settings.set('payment_banktransfer__total_max', Decimal('42.00')) + self.event.settings.set('payment_banktransfer__enabled', True) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select('input[name=payment]')), 2) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select('input[name=payment]')), 1) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer' + }, follow=True) + self.assertEqual(response.status_code, 200) + doc = BeautifulSoup(response.rendered_content, "lxml") + assert doc.select(".alert-danger") + + def test_payment_min_value(self): + self.event.settings.set('payment_stripe__enabled', True) + self.event.settings.set('payment_banktransfer__total_min', Decimal('42.00')) + self.event.settings.set('payment_banktransfer__enabled', True) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select('input[name=payment]')), 1) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer' + }, follow=True) + self.assertEqual(response.status_code, 200) + doc = BeautifulSoup(response.rendered_content, "lxml") + assert doc.select(".alert-danger") + def test_premature_confirm(self): response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),