forked from CGM_Public/pretix_original
Allow to create refunds without a payment (#1914)
Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
@@ -116,6 +116,10 @@ The provider class
|
|||||||
|
|
||||||
.. automethod:: refund_control_render
|
.. automethod:: refund_control_render
|
||||||
|
|
||||||
|
.. automethod:: new_refund_control_form_render
|
||||||
|
|
||||||
|
.. automethod:: new_refund_control_form_process
|
||||||
|
|
||||||
.. automethod:: api_payment_details
|
.. automethod:: api_payment_details
|
||||||
|
|
||||||
.. automethod:: matching_id
|
.. automethod:: matching_id
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import pytz
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
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.db import transaction
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.forms import Form
|
from django.forms import Form
|
||||||
@@ -773,6 +773,32 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
|
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]):
|
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
|
When personal data is removed from an event, this method is called to scrub payment-related data
|
||||||
|
|||||||
@@ -68,6 +68,28 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% 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>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ from pretix.control.permissions import (
|
|||||||
from pretix.control.signals import item_forms, item_formsets
|
from pretix.control.signals import item_forms, item_formsets
|
||||||
from pretix.helpers.models import modelcopy
|
from pretix.helpers.models import modelcopy
|
||||||
|
|
||||||
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
|
|
||||||
from ...base.channels import get_all_sales_channels
|
from ...base.channels import get_all_sales_channels
|
||||||
|
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
|
||||||
|
|
||||||
|
|
||||||
class ItemList(ListView):
|
class ItemList(ListView):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from urllib.parse import quote, urlencode
|
|||||||
import vat_moss.id
|
import vat_moss.id
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import (
|
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:
|
for p in payments:
|
||||||
value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
|
value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
|
||||||
value = formats.sanitize_separators(value)
|
value = formats.sanitize_separators(value)
|
||||||
@@ -907,7 +931,7 @@ class OrderRefundView(OrderView):
|
|||||||
'local_id': r.local_id,
|
'local_id': r.local_id,
|
||||||
'provider': r.provider,
|
'provider': r.provider,
|
||||||
}, user=self.request.user)
|
}, user=self.request.user)
|
||||||
if r.payment or r.provider == "offsetting" or r.provider == "giftcard":
|
if r.provider != "manual":
|
||||||
try:
|
try:
|
||||||
r.payment_provider.execute_refund(r)
|
r.payment_provider.execute_refund(r)
|
||||||
except PaymentException as e:
|
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 '
|
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
|
||||||
'amount.'))
|
'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', {
|
return render(self.request, 'pretixcontrol/order/refund_choose.html', {
|
||||||
'payments': payments,
|
'payments': payments,
|
||||||
|
'new_refunds': new_refunds,
|
||||||
'remainder': to_refund,
|
'remainder': to_refund,
|
||||||
'order': self.order,
|
'order': self.order,
|
||||||
'comment': comment,
|
'comment': comment,
|
||||||
@@ -2078,7 +2111,7 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View)
|
|||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
str(_('There was a problem processing your input:')) + ' ' + ', '.join(
|
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={
|
return redirect(reverse('control:event.orders.export', kwargs={
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import textwrap
|
import textwrap
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
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.forms import BICFormField, IBANFormField
|
||||||
from localflavor.generic.validators import IBANValidator
|
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
|
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
|
We just keep a created refund object. It will be marked as done using the control view
|
||||||
for bank transfer refunds.
|
for bank transfer refunds.
|
||||||
"""
|
"""
|
||||||
|
if refund.info_data.get('iban'):
|
||||||
|
return # we're already done here
|
||||||
|
|
||||||
if refund.payment is None:
|
if refund.payment is None:
|
||||||
raise ValueError(_("Can only create a bank transfer refund from an existing payment."))
|
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:
|
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
|
||||||
return self._render_control_info(request, refund.order, refund.info_data)
|
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']),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|||||||
@@ -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" %}
|
||||||
Reference in New Issue
Block a user