diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index a58c533b78..46af36590d 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -116,6 +116,10 @@ The provider class .. automethod:: refund_control_render + .. automethod:: new_refund_control_form_render + + .. automethod:: new_refund_control_form_process + .. automethod:: api_payment_details .. automethod:: matching_id diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 83dc7a8cbd..2da96dbc82 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -9,7 +9,7 @@ import pytz from django import forms from django.conf import settings from django.contrib import messages -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db import transaction from django.dispatch import receiver from django.forms import Form @@ -773,6 +773,32 @@ class BasePaymentProvider: """ raise PaymentException(_('Automatic refunds are not supported by this payment provider.')) + def new_refund_control_form_render(self, request: HttpRequest, order: Order) -> str: + """ + Render a form that will be shown to backend users when trying to create a new refund. + + Usually, refunds are created from an existing payment object, e.g. if there is a credit card + payment and the credit card provider returns ``True`` from ``payment_refund_supported``, the system + will automatically create an ``OrderRefund`` and call ``execute_refund`` on that payment. This method + can and should not be used in that situation! Instead, by implementing this method you can add a refund + flow for this payment provider that starts without an existing payment. For example, even though an order + was paid by credit card, it could easily be refunded by SEPA bank transfer. In that case, the SEPA bank + transfer provider would implement this method and return a form that asks for the IBAN. + + This method should return HTML or ``None``. All form fields should have a globally unique name. + """ + return + + def new_refund_control_form_process(self, request: HttpRequest, amount: Decimal, order: Order) -> OrderRefund: + """ + Process a backend user's request to initiate a new refund with an amount of ``amount`` for ``order``. + + This method should parse the input provided to the form created and either raise ``ValidationError`` + or return an ``OrderRefund`` object in ``created`` state that has not yet been saved to the database. + The system will then call ``execute_refund`` on that object. + """ + raise ValidationError('Not implemented') + def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]): """ When personal data is removed from an event, this method is called to scrub payment-related data diff --git a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html index e8e342e4e5..05c91a3559 100644 --- a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html +++ b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html @@ -68,6 +68,28 @@ {% endfor %} + {% for prov, form in new_refunds %} + + + + + {{ prov.verbose_name }} + + + + {% trans "Automatically refund" context "amount_label" %} +
+ + + {{ request.event.currency }} + +

+ {{ form|safe }} + + + {% endfor %} diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 51c653b9f2..7f208043de 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -46,8 +46,8 @@ from pretix.control.permissions import ( from pretix.control.signals import item_forms, item_formsets from pretix.helpers.models import modelcopy -from . import ChartContainingView, CreateView, PaginationMixin, UpdateView from ...base.channels import get_all_sales_channels +from . import ChartContainingView, CreateView, PaginationMixin, UpdateView class ItemList(ListView): diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index cc7e73b70a..4fb994f336 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -10,6 +10,7 @@ from urllib.parse import quote, urlencode import vat_moss.id from django.conf import settings from django.contrib import messages +from django.core.exceptions import ValidationError from django.core.files import File from django.db import transaction from django.db.models import ( @@ -866,6 +867,29 @@ class OrderRefundView(OrderView): }) )) + for identifier, prov in self.request.event.get_payment_providers().items(): + prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0' + prof_value = formats.sanitize_separators(prof_value) + try: + prof_value = Decimal(prof_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + continue + if prof_value > Decimal('0.00'): + try: + refund = prov.new_refund_control_form_process(self.request, prof_value, self.order) + except ValidationError as e: + for err in e: + messages.error(self.request, err) + is_valid = False + continue + if refund: + refund_selected += refund.amount + refund.comment = comment + refund.source = OrderRefund.REFUND_SOURCE_ADMIN + refunds.append(refund) + for p in payments: value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0' value = formats.sanitize_separators(value) @@ -907,7 +931,7 @@ class OrderRefundView(OrderView): 'local_id': r.local_id, 'provider': r.provider, }, user=self.request.user) - if r.payment or r.provider == "offsetting" or r.provider == "giftcard": + if r.provider != "manual": try: r.payment_provider.execute_refund(r) except PaymentException as e: @@ -969,8 +993,17 @@ class OrderRefundView(OrderView): messages.error(self.request, _('The refunds you selected do not match the selected total refund ' 'amount.')) + new_refunds = [] + for identifier, prov in self.request.event.get_payment_providers().items(): + form = prov.new_refund_control_form_render(self.request, self.order) + if form: + new_refunds.append( + (prov, form) + ) + return render(self.request, 'pretixcontrol/order/refund_choose.html', { 'payments': payments, + 'new_refunds': new_refunds, 'remainder': to_refund, 'order': self.order, 'comment': comment, @@ -2078,7 +2111,7 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View) messages.error( self.request, str(_('There was a problem processing your input:')) + ' ' + ', '.join( - ', '.join(l) for l in self.exporter.form.errors.values() + ', '.join(line) for line in self.exporter.form.errors.values() ) ) return redirect(reverse('control:event.orders.export', kwargs={ diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index eaa26aa991..04318518f5 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -1,6 +1,7 @@ import json import textwrap from collections import OrderedDict +from decimal import Decimal from django import forms from django.core.exceptions import ValidationError @@ -13,7 +14,7 @@ from i18nfield.strings import LazyI18nString from localflavor.generic.forms import BICFormField, IBANFormField from localflavor.generic.validators import IBANValidator -from pretix.base.models import OrderPayment, OrderRefund +from pretix.base.models import Order, OrderPayment, OrderRefund from pretix.base.payment import BasePaymentProvider @@ -298,6 +299,9 @@ class BankTransfer(BasePaymentProvider): We just keep a created refund object. It will be marked as done using the control view for bank transfer refunds. """ + if refund.info_data.get('iban'): + return # we're already done here + if refund.payment is None: raise ValueError(_("Can only create a bank transfer refund from an existing payment.")) @@ -310,3 +314,59 @@ class BankTransfer(BasePaymentProvider): def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str: return self._render_control_info(request, refund.order, refund.info_data) + + class NewRefundForm(forms.Form): + payer = forms.CharField( + label=_('Account holder'), + ) + iban = IBANFormField( + label=_('IBAN'), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for n, f in self.fields.items(): + f.required = False + f.widget.is_required = False + + def clean_payer(self): + val = self.cleaned_data.get('payer') + if not val: + raise ValidationError(_("This field is required.")) + return val + + def clean_iban(self): + val = self.cleaned_data.get('iban') + if not val: + raise ValidationError(_("This field is required.")) + return val + + def new_refund_control_form_render(self, request: HttpRequest, order: Order) -> str: + f = self.NewRefundForm( + prefix="refund-banktransfer", + data=request.POST if request.method == "POST" and request.POST.get("refund-banktransfer-iban") else None, + ) + template = get_template('pretixplugins/banktransfer/new_refund_control_form.html') + ctx = { + 'form': f, + } + return template.render(ctx) + + def new_refund_control_form_process(self, request: HttpRequest, amount: Decimal, order: Order) -> OrderRefund: + f = self.NewRefundForm( + prefix="refund-banktransfer", + data=request.POST + ) + if not f.is_valid(): + raise ValidationError(_('Your input was invalid, please see below for details.')) + return OrderRefund( + order=order, + payment=None, + state=OrderRefund.REFUND_STATE_CREATED, + amount=amount, + provider=self.identifier, + info=json.dumps({ + 'payer': f.cleaned_data['payer'], + 'iban': self.norm(f.cleaned_data['iban']), + }) + ) diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/new_refund_control_form.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/new_refund_control_form.html new file mode 100644 index 0000000000..a12cc8b868 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/new_refund_control_form.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load ibanformat %} +{% load bootstrap3 %} + +{% trans "to" %} +{% bootstrap_field form.payer layout="inline" %} +{% bootstrap_field form.iban layout="inline" %} +{% bootstrap_form_errors form error_types="all" %}