diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index 92d5614b98..9003cbd2b2 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -87,6 +87,10 @@ The provider class .. automethod:: order_control_render + .. automethod:: order_control_refund_render + + .. automethod:: order_control_refund_perform + Additional views ---------------- diff --git a/src/pretix/base/models.py b/src/pretix/base/models.py index a9e24d15fc..9a6b0510bb 100644 --- a/src/pretix/base/models.py +++ b/src/pretix/base/models.py @@ -1555,6 +1555,16 @@ class Order(Versionable): return True return False # nothing there to modify + def mark_refunded(self): + """ + Mark this order as refunded. This clones the order object, sets the payment status and + returns the cloned order object. + """ + order = self.clone() + order.status = Order.STATUS_REFUNDED + order.save() + return order + def mark_paid(self, provider=None, info=None, date=None, manual=None): """ Mark this order as paid. This clones the order object, sets the payment provider, diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 86edb42a2a..1234fd9504 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1,6 +1,7 @@ from collections import OrderedDict from decimal import Decimal from django import forms +from django.contrib import messages from django.forms import Form from django.http import HttpRequest @@ -299,3 +300,38 @@ class BasePaymentProvider: :param order: The order object """ return _('Payment provider: %s' % self.verbose_name) + + def order_control_refund_render(self, order: Order) -> str: + """ + Will be called if the event administrator clicks an order's 'refund' button. + This can be used to display information *before* the order is being refunded. + + It should return HTML code which should be displayed to the user. It should + contain information about to which extend the money will be refunded + automatically. + + :param order: The order object + """ + return '
%s
' % _('The money can not be automatically refunded, ' + 'please transfer the money back manually.') + + def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str": + """ + Will be called if the event administrator confirms the refund. + + This should transfer the money back (if possible). You can return an URL the + user should be redirected to if you need special behaviour or None to continue + with default behaviour. + + On failure, you should use Django's message framework to display an error message + to the user. + + The default implementation sets the Orders state to refunded and shows a success + message. + + :param request: The HTTP request + :param order: The order object + """ + order.mark_refunded() + messages.success(request, _('The order has been marked as refunded. Please transfer the money ' + 'back to the buyer manually.')) diff --git a/src/pretix/control/templates/pretixcontrol/order/refund.html b/src/pretix/control/templates/pretixcontrol/order/refund.html new file mode 100644 index 0000000000..3474113eef --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/order/refund.html @@ -0,0 +1,34 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% block title %} + {% trans "Refund order" %} +{% endblock %} +{% block content %} +

+ {% trans "Refund order" %} +

+

{% blocktrans trimmed %} + Do you really want to refund this order? You cannot revert this action. + {% endblocktrans %}

+ + {{ payment|safe }} + +
+ {% csrf_token %} + +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 2580dffb52..2eb69d7d68 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -40,19 +40,19 @@ class OrderView(DetailView): def order(self): return self.get_object() - -class OrderDetail(EventPermissionRequiredMixin, OrderView): - template_name = 'pretixcontrol/order/index.html' - permission = 'can_view_orders' - @cached_property def payment_provider(self): responses = register_payment_providers.send(self.request.event) for receiver, response in responses: provider = response(self.request.event) - if provider.identifier == self.object.payment_provider: + if provider.identifier == self.order.payment_provider: return provider + +class OrderDetail(EventPermissionRequiredMixin, OrderView): + template_name = 'pretixcontrol/order/index.html' + permission = 'can_view_orders' + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['items'] = self.get_items() @@ -118,6 +118,10 @@ class OrderTransition(EventPermissionRequiredMixin, OrderView): order.payment_manual = True order.save() messages.success(self.request, _('The order has been marked as not paid.')) + elif self.order.status == 'p' and to == 'r': + ret = self.payment_provider.order_control_refund_perform(self.request, self.order) + if ret: + return redirect(ret) return redirect(reverse( 'control:event.order', kwargs={ @@ -134,14 +138,9 @@ class OrderTransition(EventPermissionRequiredMixin, OrderView): 'order': self.order, }) elif self.order.status == 'p' and to == 'r': - messages.error(self.request, _('Refunding orders is not yet implemented.')) - return redirect(reverse( - 'control:event.order', - kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - 'code': self.order.code, - } - )) + return render(self.request, 'pretixcontrol/order/refund.html', { + 'order': self.order, + 'payment': self.payment_provider.order_control_refund_render(self.order), + }) else: return HttpResponse(status=405) diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 1e70c790c4..3a639aeaa6 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -200,3 +200,37 @@ class Paypal(BasePaymentProvider): ctx = {'request': request, 'event': self.event, 'settings': self.settings, 'payment_info': payment_info, 'order': order} return template.render(ctx) + + def order_control_refund_render(self, order) -> str: + return '
%s
' % _('The money will be automatically refunded.') + + def order_control_refund_perform(self, request, order) -> "bool|str": + self.init_api() + + if order.payment_info: + payment_info = json.loads(order.payment_info) + else: + payment_info = None + + if not payment_info: + order.mark_refunded() + messages.warning(request, _('We were unable to transfer the money back automatically. ' + 'Please get in touch with the customer and transfer it back manually.')) + return + + for res in payment_info['transactions'][0]['related_resources']: + for k, v in res.items(): + if k == 'sale': + sale = paypalrestsdk.Sale.find(v['id']) + break + + refund = sale.refund({}) + if not refund.success(): + order.mark_refunded() + messages.warning(request, _('We were unable to transfer the money back automatically. ' + 'Please get in touch with the customer and transfer it back manually.')) + else: + sale = paypalrestsdk.Payment.find(payment_info['id']) + order = order.mark_refunded() + order.payment_info = json.dumps(sale.to_dict()) + order.save() diff --git a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html index 98a569d9ec..671a6875ee 100644 --- a/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html +++ b/src/pretix/plugins/paypal/templates/pretixplugins/paypal/control.html @@ -5,6 +5,10 @@

{% blocktrans trimmed %} This order has been paid via PayPal. {% endblocktrans %}

+ {% elif order.status == "r" %} +

{% blocktrans trimmed %} + This order has been planned to be paid via PayPal and has been marked as refunded. + {% endblocktrans %}

{% else %}

{% blocktrans trimmed %} This order has been planned to be paid via PayPal, but the payment has not yet been completed. diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 5d9d3f8537..2f3a62b2fa 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -91,3 +91,33 @@ class Stripe(BasePaymentProvider): ctx = {'request': request, 'event': self.event, 'settings': self.settings, 'payment_info': payment_info, 'order': order} return template.render(ctx) + + def order_control_refund_render(self, order) -> str: + return '

%s
' % _('The money will be automatically refunded.') + + def order_control_refund_perform(self, request, order) -> "bool|str": + self._init_api() + + if order.payment_info: + payment_info = json.loads(order.payment_info) + else: + payment_info = None + + if not payment_info: + order.mark_refunded() + messages.warning(request, _('We were unable to transfer the money back automatically. ' + 'Please get in touch with the customer and transfer it back manually.')) + return + + try: + ch = stripe.Charge.retrieve(payment_info['id']) + ch.refunds.create() + ch.refresh() + except stripe.error.StripeError: + order.mark_refunded() + messages.warning(request, _('We were unable to transfer the money back automatically. ' + 'Please get in touch with the customer and transfer it back manually.')) + else: + order = order.mark_refunded() + order.payment_info = str(ch) + order.save() diff --git a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html index faf50b4b35..3aad4a3eca 100644 --- a/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html +++ b/src/pretix/plugins/stripe/templates/pretixplugins/stripe/control.html @@ -5,6 +5,10 @@

{% blocktrans trimmed %} This order has been paid via Stripe. {% endblocktrans %}

+ {% elif order.status == "p" %} +

{% blocktrans trimmed %} + This order has been planned to be paid via Stripe and has been marked as refunded. + {% endblocktrans %}

{% else %}

{% blocktrans trimmed %} This order has been planned to be paid via Stripe, but the payment has not yet been completed.