mirror of
https://github.com/pretix/pretix.git
synced 2026-04-24 23:32:33 +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.
|
||||
|
||||
.. 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**:
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% trans "Cancel order" %}
|
||||
{% endblock %}
|
||||
@@ -17,15 +18,14 @@
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" href="">
|
||||
<form method="post" href="" class="">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="status" value="c"/>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="send_email" value="on" checked="checked">
|
||||
{% trans "Notify user by e-mail" %}
|
||||
</label>
|
||||
</div>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.send_email layout='' %}
|
||||
{% if form.cancellation_fee %}
|
||||
{% bootstrap_field form.cancellation_fee layout='' %}
|
||||
{% endif %}
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4">
|
||||
<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.control.forms.filter import EventOrderFilterForm, RefundFilterForm
|
||||
from pretix.control.forms.orders import (
|
||||
CommentForm, ConfirmPaymentForm, ExporterForm, ExtendForm, MarkPaidForm,
|
||||
OrderContactForm, OrderLocaleForm, OrderMailForm, OrderPositionAddForm,
|
||||
OrderPositionChangeForm, OrderRefundForm, OtherOperationsForm,
|
||||
CancelForm, CommentForm, ConfirmPaymentForm, ExporterForm, ExtendForm,
|
||||
MarkPaidForm, OrderContactForm, OrderLocaleForm, OrderMailForm,
|
||||
OrderPositionAddForm, OrderPositionChangeForm, OrderRefundForm,
|
||||
OtherOperationsForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.views import PaginationMixin
|
||||
@@ -772,7 +773,10 @@ class OrderRefundView(OrderView):
|
||||
'payments': payments,
|
||||
'remainder': to_refund,
|
||||
'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
|
||||
})
|
||||
|
||||
@@ -800,6 +804,13 @@ class OrderTransition(OrderView):
|
||||
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):
|
||||
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():
|
||||
@@ -847,8 +858,10 @@ class OrderTransition(OrderView):
|
||||
'confirmation mail.'))
|
||||
else:
|
||||
messages.success(self.request, _('The payment has been created successfully.'))
|
||||
elif self.order.cancel_allowed() and to == 'c':
|
||||
cancel_order(self.order, user=self.request.user, send_mail=self.request.POST.get("send_email") == "on")
|
||||
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.mark_canceled_form.cleaned_data['send_email'],
|
||||
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'))
|
||||
self.order.refresh_from_db()
|
||||
|
||||
if self.order.pending_sum < 0:
|
||||
@@ -877,6 +890,7 @@ class OrderTransition(OrderView):
|
||||
})
|
||||
elif self.order.cancel_allowed() and to == 'c':
|
||||
return render(self.request, 'pretixcontrol/order/cancel.html', {
|
||||
'form': self.mark_canceled_form,
|
||||
'order': self.order,
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -363,7 +363,7 @@ def test_payment_refund_fail(token_client, organizer, event, order, monkeypatch)
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'amount': '25.00',
|
||||
'mark_refunded': False
|
||||
'mark_canceled': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
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
|
||||
), format='json', data={
|
||||
'amount': '20.00',
|
||||
'mark_refunded': False
|
||||
'mark_canceled': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
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(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'mark_refunded': False
|
||||
'mark_canceled': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
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
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False
|
||||
'mark_canceled': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
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
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False
|
||||
'mark_canceled': False
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
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
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False,
|
||||
'mark_canceled': False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
), format='json', data={
|
||||
'amount': '23.00',
|
||||
'mark_refunded': False,
|
||||
'mark_canceled': False,
|
||||
})
|
||||
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.'}
|
||||
@@ -514,7 +514,7 @@ def test_refund_process_mark_refunded(token_client, organizer, event, order):
|
||||
p.create_external_refund()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||
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)
|
||||
assert resp.status_code == 200
|
||||
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(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={'mark_refunded': True})
|
||||
), format='json', data={'mark_canceled': True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ def test_refund_process_mark_pending(token_client, organizer, event, order):
|
||||
p.create_external_refund()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/refunds/2/process/'.format(
|
||||
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)
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
def test_order_mark_canceled_pending_no_email(token_client, organizer, event, order):
|
||||
djmail.outbox = []
|
||||
@@ -984,6 +998,25 @@ def test_order_mark_canceled_expired(token_client, organizer, event, order):
|
||||
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
|
||||
def test_order_mark_paid_refunded(token_client, organizer, event, order):
|
||||
order.status = Order.STATUS_PAID
|
||||
@@ -2415,7 +2448,7 @@ def test_refund_create(token_client, organizer, event, order):
|
||||
@pytest.mark.django_db
|
||||
def test_refund_create_mark_refunded(token_client, organizer, event, order):
|
||||
res = copy.deepcopy(REFUND_CREATE_PAYLOAD)
|
||||
res['mark_refunded'] = True
|
||||
res['mark_canceled'] = True
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/refunds/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
|
||||
@@ -127,6 +127,19 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
|
||||
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):
|
||||
self.quota.items.add(self.item1)
|
||||
self.quota.size = 3
|
||||
|
||||
@@ -11,7 +11,7 @@ from tests.base import SoupTest
|
||||
from tests.plugins.stripe.test_provider import MockedCharge
|
||||
|
||||
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,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -307,6 +307,91 @@ def test_order_cancel_free(client, env):
|
||||
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
|
||||
def test_order_invoice_create_forbidden(client, env):
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
Reference in New Issue
Block a user