From 60c1ea8aad32a0f3bbc8fc17d9e92c294f477f29 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 11 Jan 2019 15:42:33 +0100 Subject: [PATCH] Allow to keep cancellation fees (#1130) * Allow to keep cancellation fees * Add tests and clarifications * Add API --- doc/api/resources/orders.rst | 59 ++++--------- src/pretix/api/views/order.py | 46 +++++++--- src/pretix/base/models/items.py | 5 +- src/pretix/base/services/orders.py | 58 ++++++++++--- src/pretix/control/forms/orders.py | 34 ++++++++ .../templates/pretixcontrol/order/cancel.html | 14 +-- src/pretix/control/views/orders.py | 26 ++++-- src/tests/api/test_orders.py | 55 +++++++++--- src/tests/base/test_models.py | 13 +++ src/tests/control/test_orders.py | 87 ++++++++++++++++++- 10 files changed, 302 insertions(+), 95 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 311fd84cc..034ed16dd 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -126,6 +126,11 @@ last_modified datetime Last modificati The ``sales_channel`` attribute has been added. +.. versionchanged:: 2.4: + + ``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and + ``…/mark_refunded/`` has been deprecated. + .. _order-position-resource: Order position resource @@ -775,7 +780,10 @@ Order state operations .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/ - Marks a pending order as canceled. + Cancels an order. For a pending order, this will set the order to status ``c``. For a paid order, this will set + the order to status ``c`` if no ``cancellation_fee`` is passed. If you do pass a ``cancellation_fee``, the order + will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation + fee as the only component of the order. **Example request**: @@ -787,7 +795,8 @@ Order state operations Content-Type: text/json { - "send_email": true + "send_email": true, + "cancellation_fee": null } **Example response**: @@ -848,44 +857,6 @@ Order state operations :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 404: The requested order does not exist. -.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/ - - Marks a paid order as refunded. - - .. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be - transferred back to the user automatically, the order will only be *marked* as refunded within pretix. - - **Example request**: - - .. sourcecode:: http - - POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ HTTP/1.1 - Host: pretix.eu - Accept: application/json, text/javascript - - **Example response**: - - .. sourcecode:: http - - HTTP/1.1 200 OK - Vary: Accept - Content-Type: application/json - - { - "code": "ABC12", - "status": "c", - ... - } - - :param organizer: The ``slug`` field of the organizer to modify - :param event: The ``slug`` field of the event to modify - :param code: The ``code`` field of the order to modify - :statuscode 200: no error - :statuscode 400: The order cannot be marked as expired since the current order status does not allow it. - :statuscode 401: Authentication failure - :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. - :statuscode 404: The requested order does not exist. - .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/ Marks a unpaid order as expired. @@ -1502,7 +1473,7 @@ Order payment endpoints { "amount": "23.00", - "mark_refunded": false + "mark_canceled": false } @@ -1649,7 +1620,7 @@ Order refund endpoints "payment": 1, "execution_date": null, "provider": "manual", - "mark_refunded": false + "mark_canceled": false } **Example response**: @@ -1719,7 +1690,7 @@ Order refund endpoints .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/ - Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``. + Acts on an external refund, either marks the order as canceled or pending. Only allowed in state ``external``. **Example request**: @@ -1730,7 +1701,7 @@ Order refund endpoints Accept: application/json, text/javascript Content-Type: application/json - {"mark_refunded": false} + {"mark_canceled": false} **Example response**: diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 2e6fd1adb..27b33a10e 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal import django_filters import pytz @@ -186,6 +187,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): @detail_route(methods=['POST']) def mark_canceled(self, request, **kwargs): send_mail = request.data.get('send_email', True) + cancellation_fee = request.data.get('cancellation_fee', None) + if cancellation_fee: + try: + cancellation_fee = float(Decimal(cancellation_fee)) + except: + cancellation_fee = None order = self.get_object() if not order.cancel_allowed(): @@ -194,14 +201,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): status=status.HTTP_400_BAD_REQUEST ) - cancel_order( - order, - user=request.user if request.user.is_authenticated else None, - api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None, - device=request.auth if isinstance(request.auth, Device) else None, - oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None, - send_mail=send_mail - ) + try: + cancel_order( + order, + user=request.user if request.user.is_authenticated else None, + api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None, + device=request.auth if isinstance(request.auth, Device) else None, + oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None, + send_mail=send_mail, + cancellation_fee=cancellation_fee + ) + except OrderError as e: + return Response( + {'detail': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) return self.retrieve(request, [], **kwargs) @detail_route(methods=['POST']) @@ -515,7 +529,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet): amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value( request.data.get('amount', str(payment.amount)) ) - mark_refunded = request.data.get('mark_refunded', False) + if 'mark_refunded' in request.data: + mark_refunded = request.data.get('mark_refunded', False) + else: + mark_refunded = request.data.get('mark_canceled', False) if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED: return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST) @@ -624,7 +641,11 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST) refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) - if request.data.get('mark_refunded', False): + if 'mark_refunded' in request.data: + mark_refunded = request.data.get('mark_refunded', False) + else: + mark_refunded = request.data.get('mark_canceled', False) + if mark_refunded: mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0): @@ -653,7 +674,10 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): return ctx def create(self, request, *args, **kwargs): - mark_refunded = request.data.pop('mark_refunded', False) + if 'mark_refunded' in request.data: + mark_refunded = request.data.pop('mark_refunded', False) + else: + mark_refunded = request.data.pop('mark_canceled', False) serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) with transaction.atomic(): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index ee25aec24..4ba6b92a8 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -327,9 +327,8 @@ class Item(LoggedModel): allow_cancel = models.BooleanField( verbose_name=_('Allow product to be canceled'), default=True, - help_text=_('If this is active and the general event settings allow it, orders containing this product can be ' - 'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own ' - 'and you can cancel orders at all times, regardless of this setting') + help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, ' + 'orders containing this product can not be canceled by users but only by you.') ) min_per_order = models.IntegerField( verbose_name=_('Minimum amount per order'), diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index ff8aaa8ee..374bc6f76 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -293,7 +293,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): @transaction.atomic -def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None): +def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None, + cancellation_fee=None): """ Mark this order as canceled :param order: The order to change @@ -309,20 +310,52 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device device = Device.objects.get(pk=device) if isinstance(oauth_application, int): oauth_application = OAuthApplication.objects.get(pk=oauth_application) - with order.event.lock(): - if not order.cancel_allowed(): - raise OrderError(_('You cannot cancel this order.')) - order.status = Order.STATUS_CANCELED - order.save(update_fields=['status']) - order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device) + if not order.cancel_allowed(): + raise OrderError(_('You cannot cancel this order.')) i = order.invoices.filter(is_cancellation=False).last() if i: generate_cancellation(i) - for position in order.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1) + if cancellation_fee: + with order.event.lock(): + for position in order.positions.all(): + if position.voucher: + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1) + position.canceled = True + position.save(update_fields=['canceled']) + for fee in order.fees.all(): + fee.canceled = True + fee.save(update_fields=['canceled']) + + f = OrderFee( + fee_type=OrderFee.FEE_TYPE_CANCELLATION, + value=cancellation_fee, + tax_rule=order.event.settings.tax_rate_default, + order=order, + ) + f._calculate_tax() + f.save() + + if order.payment_refund_sum < cancellation_fee: + raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.')) + order.status = Order.STATUS_PAID + order.total = f.value + order.save(update_fields=['status', 'total']) + + if i: + generate_invoice(order) + else: + with order.event.lock(): + order.status = Order.STATUS_CANCELED + order.save(update_fields=['status']) + + for position in order.positions.all(): + if position.voucher: + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1) + + order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, + data={'cancellation_fee': cancellation_fee}) if send_mail: email_template = order.event.settings.mail_text_order_canceled @@ -1331,10 +1364,11 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str], @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, - device=None): + device=None, cancellation_fee=None): try: try: - return _cancel_order(order, user, send_mail, api_token, device, oauth_application) + return _cancel_order(order, user, send_mail, api_token, device, oauth_application, + cancellation_fee) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index b5040718c..1d1fd9cd6 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -77,6 +77,40 @@ class ConfirmPaymentForm(forms.Form): del self.fields['force'] +class CancelForm(ConfirmPaymentForm): + send_email = forms.BooleanField( + required=False, + label=_('Notify user by e-mail'), + initial=True + ) + cancellation_fee = forms.DecimalField( + required=False, + max_digits=10, decimal_places=2, + localize=True, + label=_('Keep a cancellation fee of'), + help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced ' + 'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them ' + 'in your cancellation fee if you want to keep them. Please always enter a gross value, ' + 'tax will be calculated automatically.'), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + prs = self.instance.payment_refund_sum + if prs > 0: + change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency) + self.fields['cancellation_fee'].initial = Decimal('0.00') + self.fields['cancellation_fee'].max_value = prs + else: + del self.fields['cancellation_fee'] + + def clean_cancellation_fee(self): + val = self.cleaned_data['cancellation_fee'] + if val > self.instance.payment_refund_sum: + raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.')) + return val + + class MarkPaidForm(ConfirmPaymentForm): amount = forms.DecimalField( required=True, diff --git a/src/pretix/control/templates/pretixcontrol/order/cancel.html b/src/pretix/control/templates/pretixcontrol/order/cancel.html index 536021835..72823c5e3 100644 --- a/src/pretix/control/templates/pretixcontrol/order/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/order/cancel.html @@ -1,5 +1,6 @@ {% extends "pretixcontrol/event/base.html" %} {% load i18n %} +{% load bootstrap3 %} {% block title %} {% trans "Cancel order" %} {% endblock %} @@ -17,15 +18,14 @@ {% endblocktrans %}

{% endif %} -
+ {% csrf_token %} -
- -
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.send_email layout='' %} + {% if form.cancellation_fee %} + {% bootstrap_field form.cancellation_fee layout='' %} + {% endif %}