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