mirror of
https://github.com/pretix/pretix.git
synced 2026-08-26 13:14:40 +00:00
Allow to keep cancellation fees (#1130)
* Allow to keep cancellation fees * Add tests and clarifications * Add API
This commit is contained in:
@@ -126,6 +126,11 @@ last_modified datetime Last modificati
|
|||||||
|
|
||||||
The ``sales_channel`` attribute has been added.
|
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:
|
||||||
|
|
||||||
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/
|
.. 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**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -787,7 +795,8 @@ Order state operations
|
|||||||
Content-Type: text/json
|
Content-Type: text/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"send_email": true
|
"send_email": true,
|
||||||
|
"cancellation_fee": null
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**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 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.
|
: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/
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
||||||
|
|
||||||
Marks a unpaid order as expired.
|
Marks a unpaid order as expired.
|
||||||
@@ -1502,7 +1473,7 @@ Order payment endpoints
|
|||||||
|
|
||||||
{
|
{
|
||||||
"amount": "23.00",
|
"amount": "23.00",
|
||||||
"mark_refunded": false
|
"mark_canceled": false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1649,7 +1620,7 @@ Order refund endpoints
|
|||||||
"payment": 1,
|
"payment": 1,
|
||||||
"execution_date": null,
|
"execution_date": null,
|
||||||
"provider": "manual",
|
"provider": "manual",
|
||||||
"mark_refunded": false
|
"mark_canceled": false
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -1719,7 +1690,7 @@ Order refund endpoints
|
|||||||
|
|
||||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
.. 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**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -1730,7 +1701,7 @@ Order refund endpoints
|
|||||||
Accept: application/json, text/javascript
|
Accept: application/json, text/javascript
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{"mark_refunded": false}
|
{"mark_canceled": false}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
import pytz
|
import pytz
|
||||||
@@ -186,6 +187,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
@detail_route(methods=['POST'])
|
@detail_route(methods=['POST'])
|
||||||
def mark_canceled(self, request, **kwargs):
|
def mark_canceled(self, request, **kwargs):
|
||||||
send_mail = request.data.get('send_email', True)
|
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()
|
order = self.get_object()
|
||||||
if not order.cancel_allowed():
|
if not order.cancel_allowed():
|
||||||
@@ -194,14 +201,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel_order(
|
try:
|
||||||
order,
|
cancel_order(
|
||||||
user=request.user if request.user.is_authenticated else None,
|
order,
|
||||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
user=request.user if request.user.is_authenticated else None,
|
||||||
device=request.auth if isinstance(request.auth, Device) else None,
|
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
device=request.auth if isinstance(request.auth, Device) else None,
|
||||||
send_mail=send_mail
|
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)
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
@detail_route(methods=['POST'])
|
@detail_route(methods=['POST'])
|
||||||
@@ -515,7 +529,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||||
request.data.get('amount', str(payment.amount))
|
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:
|
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||||
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
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)
|
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)
|
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,
|
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||||
auth=self.request.auth)
|
auth=self.request.auth)
|
||||||
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
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
|
return ctx
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
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 = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|||||||
@@ -327,9 +327,8 @@ class Item(LoggedModel):
|
|||||||
allow_cancel = models.BooleanField(
|
allow_cancel = models.BooleanField(
|
||||||
verbose_name=_('Allow product to be canceled'),
|
verbose_name=_('Allow product to be canceled'),
|
||||||
default=True,
|
default=True,
|
||||||
help_text=_('If this is active and the general event settings allow it, orders containing this product can be '
|
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
|
||||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
'orders containing this product can not be canceled by users but only by you.')
|
||||||
'and you can cancel orders at all times, regardless of this setting')
|
|
||||||
)
|
)
|
||||||
min_per_order = models.IntegerField(
|
min_per_order = models.IntegerField(
|
||||||
verbose_name=_('Minimum amount per order'),
|
verbose_name=_('Minimum amount per order'),
|
||||||
|
|||||||
@@ -293,7 +293,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
|||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@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
|
Mark this order as canceled
|
||||||
:param order: The order to change
|
: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)
|
device = Device.objects.get(pk=device)
|
||||||
if isinstance(oauth_application, int):
|
if isinstance(oauth_application, int):
|
||||||
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
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()
|
i = order.invoices.filter(is_cancellation=False).last()
|
||||||
if i:
|
if i:
|
||||||
generate_cancellation(i)
|
generate_cancellation(i)
|
||||||
|
|
||||||
for position in order.positions.all():
|
if cancellation_fee:
|
||||||
if position.voucher:
|
with order.event.lock():
|
||||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
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:
|
if send_mail:
|
||||||
email_template = order.event.settings.mail_text_order_canceled
|
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,))
|
@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,
|
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:
|
||||||
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:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
except (MaxRetriesExceededError, LockTimeoutException):
|
except (MaxRetriesExceededError, LockTimeoutException):
|
||||||
|
|||||||
@@ -77,6 +77,40 @@ class ConfirmPaymentForm(forms.Form):
|
|||||||
del self.fields['force']
|
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):
|
class MarkPaidForm(ConfirmPaymentForm):
|
||||||
amount = forms.DecimalField(
|
amount = forms.DecimalField(
|
||||||
required=True,
|
required=True,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "pretixcontrol/event/base.html" %}
|
{% extends "pretixcontrol/event/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans "Cancel order" %}
|
{% trans "Cancel order" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -17,15 +18,14 @@
|
|||||||
{% endblocktrans %}</p>
|
{% endblocktrans %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" href="">
|
<form method="post" href="" class="">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="status" value="c"/>
|
<input type="hidden" name="status" value="c"/>
|
||||||
<div class="checkbox">
|
{% bootstrap_form_errors form %}
|
||||||
<label>
|
{% bootstrap_field form.send_email layout='' %}
|
||||||
<input type="checkbox" name="send_email" value="on" checked="checked">
|
{% if form.cancellation_fee %}
|
||||||
{% trans "Notify user by e-mail" %}
|
{% bootstrap_field form.cancellation_fee layout='' %}
|
||||||
</label>
|
{% endif %}
|
||||||
</div>
|
|
||||||
<div class="row checkout-button-row">
|
<div class="row checkout-button-row">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<a class="btn btn-block btn-default btn-lg"
|
<a class="btn btn-block btn-default btn-lg"
|
||||||
|
|||||||
@@ -62,9 +62,10 @@ from pretix.base.views.mixins import OrderQuestionsViewMixin
|
|||||||
from pretix.base.views.tasks import AsyncAction
|
from pretix.base.views.tasks import AsyncAction
|
||||||
from pretix.control.forms.filter import EventOrderFilterForm, RefundFilterForm
|
from pretix.control.forms.filter import EventOrderFilterForm, RefundFilterForm
|
||||||
from pretix.control.forms.orders import (
|
from pretix.control.forms.orders import (
|
||||||
CommentForm, ConfirmPaymentForm, ExporterForm, ExtendForm, MarkPaidForm,
|
CancelForm, CommentForm, ConfirmPaymentForm, ExporterForm, ExtendForm,
|
||||||
OrderContactForm, OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
|
MarkPaidForm, OrderContactForm, OrderLocaleForm, OrderMailForm,
|
||||||
OrderPositionChangeForm, OrderRefundForm, OtherOperationsForm,
|
OrderPositionAddForm, OrderPositionChangeForm, OrderRefundForm,
|
||||||
|
OtherOperationsForm,
|
||||||
)
|
)
|
||||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||||
from pretix.control.views import PaginationMixin
|
from pretix.control.views import PaginationMixin
|
||||||
@@ -772,7 +773,10 @@ class OrderRefundView(OrderView):
|
|||||||
'payments': payments,
|
'payments': payments,
|
||||||
'remainder': to_refund,
|
'remainder': to_refund,
|
||||||
'order': self.order,
|
'order': self.order,
|
||||||
'partial_amount': self.request.POST.get('start-partial_amount'),
|
'partial_amount': (
|
||||||
|
self.request.POST.get('start-partial_amount') if self.request.method == 'POST'
|
||||||
|
else self.request.GET.get('start-partial_amount')
|
||||||
|
),
|
||||||
'start_form': self.start_form
|
'start_form': self.start_form
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -800,6 +804,13 @@ class OrderTransition(OrderView):
|
|||||||
data=self.request.POST if self.request.method == "POST" else None,
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def mark_canceled_form(self):
|
||||||
|
return CancelForm(
|
||||||
|
instance=self.order,
|
||||||
|
data=self.request.POST if self.request.method == "POST" else None,
|
||||||
|
)
|
||||||
|
|
||||||
def post(self, *args, **kwargs):
|
def post(self, *args, **kwargs):
|
||||||
to = self.request.POST.get('status', '')
|
to = self.request.POST.get('status', '')
|
||||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p' and self.mark_paid_form.is_valid():
|
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p' and self.mark_paid_form.is_valid():
|
||||||
@@ -847,8 +858,10 @@ class OrderTransition(OrderView):
|
|||||||
'confirmation mail.'))
|
'confirmation mail.'))
|
||||||
else:
|
else:
|
||||||
messages.success(self.request, _('The payment has been created successfully.'))
|
messages.success(self.request, _('The payment has been created successfully.'))
|
||||||
elif self.order.cancel_allowed() and to == 'c':
|
elif self.order.cancel_allowed() and to == 'c' and self.mark_canceled_form.is_valid():
|
||||||
cancel_order(self.order, user=self.request.user, send_mail=self.request.POST.get("send_email") == "on")
|
cancel_order(self.order, user=self.request.user,
|
||||||
|
send_mail=self.mark_canceled_form.cleaned_data['send_email'],
|
||||||
|
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'))
|
||||||
self.order.refresh_from_db()
|
self.order.refresh_from_db()
|
||||||
|
|
||||||
if self.order.pending_sum < 0:
|
if self.order.pending_sum < 0:
|
||||||
@@ -877,6 +890,7 @@ class OrderTransition(OrderView):
|
|||||||
})
|
})
|
||||||
elif self.order.cancel_allowed() and to == 'c':
|
elif self.order.cancel_allowed() and to == 'c':
|
||||||
return render(self.request, 'pretixcontrol/order/cancel.html', {
|
return render(self.request, 'pretixcontrol/order/cancel.html', {
|
||||||
|
'form': self.mark_canceled_form,
|
||||||
'order': self.order,
|
'order': self.order,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch)
|
|||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'amount': '25.00',
|
'amount': '25.00',
|
||||||
'mark_refunded': False
|
'mark_canceled': False
|
||||||
})
|
})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.data == {'amount': ['Invalid refund amount, only 23.00 are available to refund.']}
|
assert resp.data == {'amount': ['Invalid refund amount, only 23.00 are available to refund.']}
|
||||||
@@ -372,7 +372,7 @@ def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch)
|
|||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'amount': '20.00',
|
'amount': '20.00',
|
||||||
'mark_refunded': False
|
'mark_canceled': False
|
||||||
})
|
})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.data == {'amount': ['Partial refund not available for this payment method.']}
|
assert resp.data == {'amount': ['Partial refund not available for this payment method.']}
|
||||||
@@ -380,7 +380,7 @@ def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch)
|
|||||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/refund/'.format(
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/2/refund/'.format(
|
||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'mark_refunded': False
|
'mark_canceled': False
|
||||||
})
|
})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.data == {'amount': ['Full refund not available for this payment method.']}
|
assert resp.data == {'amount': ['Full refund not available for this payment method.']}
|
||||||
@@ -389,7 +389,7 @@ def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch)
|
|||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'amount': '23.00',
|
'amount': '23.00',
|
||||||
'mark_refunded': False
|
'mark_canceled': False
|
||||||
})
|
})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.data == {'amount': ['Full refund not available for this payment method.']}
|
assert resp.data == {'amount': ['Full refund not available for this payment method.']}
|
||||||
@@ -398,7 +398,7 @@ def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch)
|
|||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'amount': '23.00',
|
'amount': '23.00',
|
||||||
'mark_refunded': False
|
'mark_canceled': False
|
||||||
})
|
})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.data == {'detail': 'Invalid state of payment.'}
|
assert resp.data == {'detail': 'Invalid state of payment.'}
|
||||||
@@ -431,7 +431,7 @@ def test_payment_refund_success(token_client, organizer, event, order, monkeypat
|
|||||||
organizer.slug, event.slug, order.code, p1.local_id
|
organizer.slug, event.slug, order.code, p1.local_id
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'amount': '23.00',
|
'amount': '23.00',
|
||||||
'mark_refunded': False,
|
'mark_canceled': False,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
r = order.refunds.get(local_id=resp.data['local_id'])
|
r = order.refunds.get(local_id=resp.data['local_id'])
|
||||||
@@ -464,7 +464,7 @@ def test_payment_refund_unavailable(token_client, organizer, event, order, monke
|
|||||||
organizer.slug, event.slug, order.code, p1.local_id
|
organizer.slug, event.slug, order.code, p1.local_id
|
||||||
), format='json', data={
|
), format='json', data={
|
||||||
'amount': '23.00',
|
'amount': '23.00',
|
||||||
'mark_refunded': False,
|
'mark_canceled': False,
|
||||||
})
|
})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert resp.data == {'detail': 'External error: We had trouble communicating with Stripe. Please try again and contact support if the problem persists.'}
|
assert resp.data == {'detail': 'External error: We had trouble communicating with Stripe. Please try again and contact support if the problem persists.'}
|
||||||
@@ -514,7 +514,7 @@ def test_refund_process_mark_refunded(token_client, organizer, event, order):
|
|||||||
p.create_external_refund()
|
p.create_external_refund()
|
||||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={'mark_refunded': True})
|
), format='json', data={'mark_canceled': True})
|
||||||
r = order.refunds.get(local_id=1)
|
r = order.refunds.get(local_id=1)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||||
@@ -523,7 +523,7 @@ def test_refund_process_mark_refunded(token_client, organizer, event, order):
|
|||||||
|
|
||||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={'mark_refunded': True})
|
), format='json', data={'mark_canceled': True})
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@@ -533,7 +533,7 @@ def test_refund_process_mark_pending(token_client, organizer, event, order):
|
|||||||
p.create_external_refund()
|
p.create_external_refund()
|
||||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
), format='json', data={'mark_refunded': False})
|
), format='json', data={'mark_canceled': False})
|
||||||
r = order.refunds.get(local_id=1)
|
r = order.refunds.get(local_id=1)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert r.state == OrderRefund.REFUND_STATE_DONE
|
assert r.state == OrderRefund.REFUND_STATE_DONE
|
||||||
@@ -955,6 +955,20 @@ def test_order_mark_canceled_pending(token_client, organizer, event, order):
|
|||||||
assert len(djmail.outbox) == 1
|
assert len(djmail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_mark_canceled_pending_fee_not_allowed(token_client, organizer, event, order):
|
||||||
|
djmail.outbox = []
|
||||||
|
resp = token_client.post(
|
||||||
|
'/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format(
|
||||||
|
organizer.slug, event.slug, order.code
|
||||||
|
), data={
|
||||||
|
'cancellation_fee': '7.00'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.data == {'detail': 'The cancellation fee cannot be higher than the payment credit of this order.'}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_order_mark_canceled_pending_no_email(token_client, organizer, event, order):
|
def test_order_mark_canceled_pending_no_email(token_client, organizer, event, order):
|
||||||
djmail.outbox = []
|
djmail.outbox = []
|
||||||
@@ -984,6 +998,25 @@ def test_order_mark_canceled_expired(token_client, organizer, event, order):
|
|||||||
assert order.status == Order.STATUS_EXPIRED
|
assert order.status == Order.STATUS_EXPIRED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_mark_paid_canceled_keep_fee(token_client, organizer, event, order):
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.save()
|
||||||
|
order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=order.total)
|
||||||
|
resp = token_client.post(
|
||||||
|
'/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format(
|
||||||
|
organizer.slug, event.slug, order.code
|
||||||
|
), data={
|
||||||
|
'cancellation_fee': '6.00'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data['status'] == Order.STATUS_PAID
|
||||||
|
order.refresh_from_db()
|
||||||
|
assert order.status == Order.STATUS_PAID
|
||||||
|
assert order.total == Decimal('6.00')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_order_mark_paid_refunded(token_client, organizer, event, order):
|
def test_order_mark_paid_refunded(token_client, organizer, event, order):
|
||||||
order.status = Order.STATUS_PAID
|
order.status = Order.STATUS_PAID
|
||||||
@@ -2415,7 +2448,7 @@ def test_refund_create(token_client, organizer, event, order):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_refund_create_mark_refunded(token_client, organizer, event, order):
|
def test_refund_create_mark_refunded(token_client, organizer, event, order):
|
||||||
res = copy.deepcopy(REFUND_CREATE_PAYLOAD)
|
res = copy.deepcopy(REFUND_CREATE_PAYLOAD)
|
||||||
res['mark_refunded'] = True
|
res['mark_canceled'] = True
|
||||||
resp = token_client.post(
|
resp = token_client.post(
|
||||||
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
||||||
organizer.slug, event.slug, order.code
|
organizer.slug, event.slug, order.code
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ class QuotaTestCase(BaseQuotaTestCase):
|
|||||||
|
|
||||||
self.assertEqual(quota2.availability(), (Quota.AVAILABILITY_OK, 1))
|
self.assertEqual(quota2.availability(), (Quota.AVAILABILITY_OK, 1))
|
||||||
|
|
||||||
|
def test_position_canceled(self):
|
||||||
|
self.quota.items.add(self.item1)
|
||||||
|
self.quota.size = 3
|
||||||
|
self.quota.save()
|
||||||
|
order = Order.objects.create(event=self.event, status=Order.STATUS_PAID,
|
||||||
|
expires=now() + timedelta(days=3),
|
||||||
|
total=4)
|
||||||
|
op = OrderPosition.objects.create(order=order, item=self.item1, price=2)
|
||||||
|
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 2))
|
||||||
|
op.canceled = True
|
||||||
|
op.save()
|
||||||
|
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 3))
|
||||||
|
|
||||||
def test_reserved(self):
|
def test_reserved(self):
|
||||||
self.quota.items.add(self.item1)
|
self.quota.items.add(self.item1)
|
||||||
self.quota.size = 3
|
self.quota.size = 3
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from tests.base import SoupTest
|
|||||||
from tests.plugins.stripe.test_provider import MockedCharge
|
from tests.plugins.stripe.test_provider import MockedCharge
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Event, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
Event, InvoiceAddress, Item, Order, OrderFee, OrderPayment, OrderPosition,
|
||||||
OrderRefund, Organizer, Question, QuestionAnswer, Quota, Team, User,
|
OrderRefund, Organizer, Question, QuestionAnswer, Quota, Team, User,
|
||||||
)
|
)
|
||||||
from pretix.base.payment import PaymentException
|
from pretix.base.payment import PaymentException
|
||||||
@@ -307,6 +307,91 @@ def test_order_cancel_free(client, env):
|
|||||||
assert o.status == Order.STATUS_CANCELED
|
assert o.status == Order.STATUS_CANCELED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_cancel_paid_keep_fee(client, env):
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=o.total)
|
||||||
|
o.status = Order.STATUS_PAID
|
||||||
|
o.save()
|
||||||
|
tr7 = o.event.tax_rules.create(rate=Decimal('7.00'))
|
||||||
|
o.event.settings.tax_rate_default = tr7
|
||||||
|
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c')
|
||||||
|
client.post('/control/event/dummy/dummy/orders/FOO/transition', {
|
||||||
|
'status': 'c',
|
||||||
|
'cancellation_fee': '6.00'
|
||||||
|
})
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
assert not o.positions.exists()
|
||||||
|
assert o.all_positions.exists()
|
||||||
|
f = o.fees.get()
|
||||||
|
assert f.fee_type == OrderFee.FEE_TYPE_CANCELLATION
|
||||||
|
assert f.value == Decimal('6.00')
|
||||||
|
assert f.tax_value == Decimal('0.39')
|
||||||
|
assert f.tax_rate == Decimal('7')
|
||||||
|
assert f.tax_rule == tr7
|
||||||
|
assert o.status == Order.STATUS_PAID
|
||||||
|
assert o.total == Decimal('6.00')
|
||||||
|
assert o.pending_sum == Decimal('-8.00')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_cancel_pending_keep_fee(client, env):
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=Decimal('8.00'))
|
||||||
|
o.status = Order.STATUS_PENDING
|
||||||
|
o.save()
|
||||||
|
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c')
|
||||||
|
client.post('/control/event/dummy/dummy/orders/FOO/transition', {
|
||||||
|
'status': 'c',
|
||||||
|
'cancellation_fee': '6.00'
|
||||||
|
})
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
assert not o.positions.exists()
|
||||||
|
assert o.all_positions.exists()
|
||||||
|
f = o.fees.get()
|
||||||
|
assert f.fee_type == OrderFee.FEE_TYPE_CANCELLATION
|
||||||
|
assert f.value == Decimal('6.00')
|
||||||
|
assert o.status == Order.STATUS_PAID
|
||||||
|
assert o.total == Decimal('6.00')
|
||||||
|
assert o.pending_sum == Decimal('-2.00')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_cancel_pending_fee_too_high(client, env):
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=Decimal('4.00'))
|
||||||
|
o.status = Order.STATUS_PENDING
|
||||||
|
o.save()
|
||||||
|
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c')
|
||||||
|
client.post('/control/event/dummy/dummy/orders/FOO/transition', {
|
||||||
|
'status': 'c',
|
||||||
|
'cancellation_fee': '6.00'
|
||||||
|
})
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
assert o.positions.exists()
|
||||||
|
assert not o.fees.exists()
|
||||||
|
assert o.status == Order.STATUS_PENDING
|
||||||
|
assert o.total == Decimal('14.00')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_order_cancel_unpaid_no_fees_allowed(client, env):
|
||||||
|
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c')
|
||||||
|
client.post('/control/event/dummy/dummy/orders/FOO/transition', {
|
||||||
|
'status': 'c',
|
||||||
|
'cancellation_fee': '6.00'
|
||||||
|
})
|
||||||
|
o = Order.objects.get(id=env[2].id)
|
||||||
|
assert o.positions.exists()
|
||||||
|
assert not o.fees.exists()
|
||||||
|
assert o.status == Order.STATUS_CANCELED
|
||||||
|
assert o.total == Decimal('14.00')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_order_invoice_create_forbidden(client, env):
|
def test_order_invoice_create_forbidden(client, env):
|
||||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
|||||||
Reference in New Issue
Block a user