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 %}
-