Allow to create refunds without a payment (#1914)

Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2021-01-26 10:53:59 +01:00
committed by GitHub
parent 07ed7526c0
commit 41c69aaa2a
7 changed files with 158 additions and 5 deletions

View File

@@ -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

View File

@@ -68,6 +68,28 @@
</td>
</tr>
{% endfor %}
{% for prov, form in new_refunds %}
<tr>
<td></td>
<td></td>
<td>
{{ prov.verbose_name }}
</td>
<td></td>
<td>
{% trans "Automatically refund" context "amount_label" %}
<div class="input-group">
<input type="text" name="newrefund-{{ prov }}"
placeholder="{{ 0|floatformat:2 }}"
title="" class="form-control">
<span class="input-group-addon">
{{ request.event.currency }}
</span>
</div><br>
{{ form|safe }}
</td>
</tr>
{% endfor %}
<tr>
<td></td>
<td></td>

View File

@@ -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):

View File

@@ -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={

View File

@@ -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']),
})
)

View File

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