forked from CGM_Public/pretix_original
Fix #815 -- Add configurable minimum/maximum amount for payment methods
This commit is contained in:
@@ -7,6 +7,7 @@ import pytz
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.forms import Form
|
from django.forms import Form
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@@ -185,6 +186,28 @@ class BasePaymentProvider:
|
|||||||
widget=I18nTextarea,
|
widget=I18nTextarea,
|
||||||
widget_kwargs={'attrs': {'rows': '2'}}
|
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',
|
('_fee_abs',
|
||||||
forms.DecimalField(
|
forms.DecimalField(
|
||||||
label=_('Additional fee'),
|
label=_('Additional fee'),
|
||||||
@@ -304,16 +327,36 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
return True
|
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
|
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
|
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
|
user will not be able to select this payment method. This will only be called
|
||||||
during checkout, not on retrying.
|
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:
|
def payment_form_render(self, request: HttpRequest) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -451,6 +494,12 @@ class BasePaymentProvider:
|
|||||||
|
|
||||||
:param order: The order object
|
: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)
|
return self._is_still_available(order=order)
|
||||||
|
|
||||||
def order_can_retry(self, order: Order) -> bool:
|
def order_can_retry(self, order: Order) -> bool:
|
||||||
@@ -647,7 +696,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
|||||||
mark_order_refunded(order, user=request.user)
|
mark_order_refunded(order, user=request.user)
|
||||||
messages.success(request, _('The order has been marked as refunded.'))
|
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
|
from .services.cart import get_fees
|
||||||
|
|
||||||
total = get_cart_total(request)
|
total = get_cart_total(request)
|
||||||
@@ -684,7 +733,7 @@ class BoxOfficeProvider(BasePaymentProvider):
|
|||||||
mark_order_refunded(order, user=request.user)
|
mark_order_refunded(order, user=request.user)
|
||||||
messages.success(request, _('The order has been marked as refunded.'))
|
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
|
return False
|
||||||
|
|
||||||
def order_change_allowed(self, order: Order) -> bool:
|
def order_change_allowed(self, order: Order) -> bool:
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
def provider_forms(self):
|
def provider_forms(self):
|
||||||
providers = []
|
providers = []
|
||||||
for provider in self.request.event.get_payment_providers().values():
|
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
|
continue
|
||||||
fee = provider.calculate_fee(self._total_order_value)
|
fee = provider.calculate_fee(self._total_order_value)
|
||||||
providers.append({
|
providers.append({
|
||||||
@@ -480,6 +480,12 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
def payment_provider(self):
|
def payment_provider(self):
|
||||||
return self.request.event.get_payment_providers().get(self.cart_session['payment'])
|
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):
|
def is_completed(self, request, warn=False):
|
||||||
self.request = request
|
self.request = request
|
||||||
if 'payment' not in self.cart_session or not self.payment_provider:
|
if 'payment' not in self.cart_session or not self.payment_provider:
|
||||||
@@ -488,7 +494,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
return False
|
return False
|
||||||
if not self.payment_provider.payment_is_valid_session(request) or \
|
if not self.payment_provider.payment_is_valid_session(request) or \
|
||||||
not self.payment_provider.is_enabled 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:
|
if warn:
|
||||||
messages.error(request, _('The payment information you entered was incomplete.'))
|
messages.error(request, _('The payment information you entered was incomplete.'))
|
||||||
return False
|
return False
|
||||||
@@ -499,7 +505,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
|
|
||||||
for p in self.request.event.get_payment_providers().values():
|
for p in self.request.event.get_payment_providers().values():
|
||||||
if p.is_implicit:
|
if p.is_implicit:
|
||||||
if p.is_allowed(request):
|
if self._is_allowed(p, request):
|
||||||
self.cart_session['payment'] = p.identifier
|
self.cart_session['payment'] = p.identifier
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -688,6 +688,49 @@ class CheckoutTestCase(TestCase):
|
|||||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||||
target_status_code=200)
|
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):
|
def test_premature_confirm(self):
|
||||||
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
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),
|
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
|
||||||
|
|||||||
Reference in New Issue
Block a user