Fix #815 -- Add configurable minimum/maximum amount for payment methods

This commit is contained in:
Raphael Michel
2018-06-19 17:59:13 +02:00
parent e187005130
commit 332af5d21b
3 changed files with 106 additions and 8 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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),