Files
pretix_original/src/pretix/plugins/banktransfer/payment.py
Raphael Michel 4f3d90fc50 Bank transfer: Do not show reference before it is as complete as possible (fixes #5296) (#5621)
* Bank transfer: Do not show reference before it is as complete as possible (fixes #5296)

* Update src/pretix/plugins/banktransfer/payment.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Apply suggestion from @raphaelm

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-16 14:34:28 +01:00

518 lines
22 KiB
Python

#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import json
from collections import OrderedDict
from decimal import Decimal
from django import forms
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.fields import I18nFormField, I18nTextarea
from i18nfield.forms import I18nTextInput
from i18nfield.strings import LazyI18nString
from localflavor.generic.forms import BICFormField, IBANFormField
from localflavor.generic.validators import IBANValidator
from pretix.base.forms import I18nMarkdownTextarea
from pretix.base.models import InvoiceAddress, Order, OrderPayment, OrderRefund
from pretix.base.payment import BasePaymentProvider
from pretix.base.templatetags.money import money_filter
from pretix.helpers.payment import generate_payment_qr_codes
from pretix.plugins.banktransfer.templatetags.ibanformat import ibanformat
from pretix.presale.views.cart import cart_session
class BankTransfer(BasePaymentProvider):
identifier = 'banktransfer'
verbose_name = _('Bank transfer')
abort_pending_allowed = True
@staticmethod
def form_fields():
return OrderedDict([
('ack',
forms.BooleanField(
label=_('I have understood that people will pay the ticket price directly to my bank account and '
'pretix cannot automatically know what payments arrived. Therefore, I will either mark '
'payments as complete manually, or regularly import a digital bank statement in order to '
'give pretix the required information.'),
required=True,
)),
('bank_details_type', forms.ChoiceField(
label=_('Bank account type'),
widget=forms.RadioSelect,
choices=(
('sepa', _('SEPA bank account')),
('other', _('Other bank account')),
),
initial='sepa'
)),
('bank_details_sepa_name', forms.CharField(
label=_('Name of account holder'),
help_text=_(
'Please note: special characters other than letters, numbers, and some punctuation can cause problems with some banks.'),
widget=forms.TextInput(
attrs={
'data-display-dependency': '#id_payment_banktransfer_bank_details_type_0',
'data-required-if': '#id_payment_banktransfer_bank_details_type_0'
}
),
required=False
)),
('bank_details_sepa_iban', IBANFormField(
label=_('IBAN'),
required=False,
widget=forms.TextInput(
attrs={
'data-display-dependency': '#id_payment_banktransfer_bank_details_type_0',
'data-required-if': '#id_payment_banktransfer_bank_details_type_0'
}
),
)),
('bank_details_sepa_bic', BICFormField(
label=_('BIC'),
widget=forms.TextInput(
attrs={
'data-display-dependency': '#id_payment_banktransfer_bank_details_type_0',
'data-required-if': '#id_payment_banktransfer_bank_details_type_0'
}
),
required=False
)),
('bank_details_sepa_bank', forms.CharField(
label=_('Name of bank'),
widget=forms.TextInput(
attrs={
'data-display-dependency': '#id_payment_banktransfer_bank_details_type_0',
'data-required-if': '#id_payment_banktransfer_bank_details_type_0'
}
),
required=False
)),
('bank_details', I18nFormField(
label=_('Bank account details'),
widget=I18nMarkdownTextarea,
help_text=_(
'Include everything else that your customers might need to send you a bank transfer payment. '
'If you have lots of international customers, they might need your full address and your '
'bank\'s full address.'),
widget_kwargs={'attrs': {
'rows': '4',
'placeholder': _(
'For SEPA accounts, you can leave this empty. Otherwise, please add everything that '
'your customers need to transfer the money, e.g. account numbers, routing numbers, '
'addresses, etc.'
),
}},
required=False
)),
('invoice_immediately',
forms.BooleanField(
label=_('Create an invoice for orders using bank transfer immediately if the event is otherwise '
'configured to create invoices after payment is completed.'),
required=False,
)),
('public_name', I18nFormField(
label=_('Payment method name'),
widget=I18nTextInput,
required=False
)),
('omit_hyphen', forms.BooleanField(
label=_('Do not include hyphens in the payment reference.'),
help_text=_('This is required in some countries.'),
required=False
)),
('include_invoice_number', forms.BooleanField(
label=_('Include invoice number in the payment reference.'),
required=False
)),
('prefix', forms.CharField(
label=_('Prefix for the payment reference'),
required=False,
)),
('pending_description', I18nFormField(
label=_('Additional text to show on pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders in addition to '
'the standard text.'),
widget=I18nTextarea,
widget_kwargs={'attrs': {
'rows': '2',
}},
required=False,
)),
('refund_iban_blocklist', forms.CharField(
label=_('IBAN blocklist for refunds'),
required=False,
widget=forms.Textarea(attrs={'rows': 4}),
help_text=_('Put one IBAN or IBAN prefix per line. The system will not attempt to send refunds to any '
'of these IBANs. Useful e.g. if you receive a lot of "forwarded payments" by a third-party payment '
'provider. You can also list country codes such as "GB" if you never want to send refunds to '
'IBANs from a specific country. The check digits will be ignored for comparison, so you '
'can e.g. ban DE0012345 to ban all German IBANs with the bank identifier starting with '
'12345.')
)),
])
@property
def public_name(self):
return str(self.settings.get('public_name', as_type=LazyI18nString) or self.verbose_name)
@property
def test_mode_message(self):
return _('In test mode, you can just manually mark this order as paid in the backend after it has been '
'created.')
@property
def requires_invoice_immediately(self):
return self.settings.get('invoice_immediately', False, as_type=bool)
@property
def settings_form_fields(self):
more_fields_first = OrderedDict([
('_restricted_business',
forms.BooleanField(
label=_('Restrict to business customers'),
help_text=_('Only allow choosing this payment provider for customers who enter an invoice address '
'and select "Business or institutional customer".'),
required=False,
)),
])
d = OrderedDict(
list(super().settings_form_fields.items()) +
list(more_fields_first.items()) +
list(BankTransfer.form_fields().items())
)
d.move_to_end('invoice_immediately', last=False)
d.move_to_end('bank_details', last=False)
d.move_to_end('bank_details_sepa_bank', last=False)
d.move_to_end('bank_details_sepa_bic', last=False)
d.move_to_end('bank_details_sepa_iban', last=False)
d.move_to_end('bank_details_sepa_name', last=False)
d.move_to_end('bank_details_type', last=False)
d.move_to_end('ack', last=False)
d.move_to_end('_enabled', last=False)
return d
def settings_form_clean(self, cleaned_data):
if cleaned_data.get('payment_banktransfer_bank_details_type') == 'sepa':
for f in (
'bank_details_sepa_name', 'bank_details_sepa_bank', 'bank_details_sepa_bic',
'bank_details_sepa_iban'
):
if not cleaned_data.get('payment_banktransfer_%s' % f):
raise ValidationError(
{'payment_banktransfer_%s' % f: _('Please fill out your bank account details.')})
else:
if not cleaned_data.get('payment_banktransfer_bank_details'):
raise ValidationError(
{'payment_banktransfer_bank_details': _('Please enter your bank account details.')})
return cleaned_data
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
def get_invoice_address():
if not hasattr(request, '_checkout_flow_invoice_address'):
cs = cart_session(request)
iapk = cs.get('invoice_address')
if not iapk:
request._checkout_flow_invoice_address = InvoiceAddress()
else:
try:
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
request._checkout_flow_invoice_address = InvoiceAddress()
return request._checkout_flow_invoice_address
restricted_business = self.settings.get('_restricted_business', as_type=bool)
if restricted_business:
ia = get_invoice_address()
if not ia.is_business:
return False
return super().is_allowed(request, total)
def payment_form_render(self, request, total=None, order=None) -> str:
template = get_template('pretixplugins/banktransfer/checkout_payment_form.html')
ctx = {
'request': request,
'event': self.event,
'settings': self.settings,
'code': self._code(order, force=False) if order else None,
'order': order,
'details': self.settings.get('bank_details', as_type=LazyI18nString),
}
return template.render(ctx)
def checkout_prepare(self, request, total):
return True
def payment_prepare(self, request: HttpRequest, payment: OrderPayment):
return True
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request, order=None):
return self.payment_form_render(request, order=order)
def order_pending_mail_render(self, order, payment) -> str:
t = gettext("Please transfer the full amount to the following bank account:")
t += "\n\n"
md_nl2br = " \n"
if self.settings.get('bank_details_type') == 'sepa':
bankdetails = (
(_("Reference"), self._code(order, force=True)),
(_("Amount"), money_filter(payment.amount, self.event.currency)),
(_("Account holder"), self.settings.get('bank_details_sepa_name')),
(_("IBAN"), ibanformat(self.settings.get('bank_details_sepa_iban'))),
(_("BIC"), self.settings.get('bank_details_sepa_bic')),
(_("Bank"), self.settings.get('bank_details_sepa_bank')),
)
else:
bankdetails = (
(_("Reference"), self._code(order, force=True)),
(_("Amount"), money_filter(payment.amount, self.event.currency)),
)
t += md_nl2br.join([f"**{k}:** {v}" for k, v in bankdetails])
if self.settings.get('bank_details', as_type=LazyI18nString):
t += md_nl2br
t += str(self.settings.get('bank_details', as_type=LazyI18nString))
return t
def payment_pending_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/banktransfer/pending.html')
ctx = {
'event': self.event,
'code': self._code(payment.order, force=True),
'order': payment.order,
'amount': payment.amount,
'payment_info': payment.info_data,
'settings': self.settings,
'payment_qr_codes': generate_payment_qr_codes(
event=self.event,
code=self._code(payment.order),
amount=payment.amount,
bank_details_sepa_bic=self.settings.get('bank_details_sepa_bic'),
bank_details_sepa_name=self.settings.get('bank_details_sepa_name'),
bank_details_sepa_iban=self.settings.get('bank_details_sepa_iban'),
) if self.settings.bank_details_type == "sepa" else None,
'pending_description': self.settings.get('pending_description', as_type=LazyI18nString),
'details': self.settings.get('bank_details', as_type=LazyI18nString),
'has_invoices': payment.order.invoices.exists(),
}
return template.render(ctx, request=request)
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
warning = None
if not self.payment_refund_supported(payment):
warning = _("Invalid IBAN/BIC")
return self._render_control_info(request, payment.order, payment.info_data, warning=warning)
def _render_control_info(self, request, order, info_data, **extra_context):
template = get_template('pretixplugins/banktransfer/control.html')
ctx = {'request': request, 'event': self.event,
'code': self._code(order, force=True),
'payment_info': info_data, 'order': order,
**extra_context}
return template.render(ctx)
def _code(self, order, force=False):
prefix = self.settings.get('prefix', default='')
li = order.invoices.last()
invoice_number = li.number if self.settings.get('include_invoice_number', as_type=bool) and li else ''
invoice_will_be_generated = (
not li and
self.settings.get('include_invoice_number', as_type=bool) and
order.event.settings.get('invoice_generate') == 'paid' and
self.requires_invoice_immediately
)
if invoice_will_be_generated and not force:
return None
code = " ".join((prefix, order.full_code, invoice_number)).strip(" ")
if self.settings.get('omit_hyphen', as_type=bool):
code = code.replace('-', '')
return code
def shred_payment_info(self, obj):
if not obj.info_data:
return
d = obj.info_data
d['reference'] = ''
d['payer'] = ''
if 'send_invoice_to' in d:
d['send_invoice_to'] = ''
d['_shredded'] = True
obj.info = json.dumps(d)
obj.save(update_fields=['info'])
@staticmethod
def norm(s):
return s.strip().upper().replace(" ", "")
def payment_refund_supported(self, payment: OrderPayment) -> bool:
if not all(payment.info_data.get(key) for key in ("payer", "iban")):
return False
try:
iban = self.norm(payment.info_data['iban'])
IBANValidator()(iban)
except ValidationError:
return False
else:
def _compare(iban, prefix): # Compare IBAN with pretix ignoring the check digits
iban = iban[:2] + iban[4:]
prefix = prefix[:2] + prefix[4:]
return iban.startswith(prefix)
return not any(_compare(iban, b) for b in (self.settings.refund_iban_blocklist or '').splitlines() if b)
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
return self.payment_refund_supported(payment)
def payment_control_render_short(self, payment: OrderPayment) -> str:
pi = payment.info_data or {}
r = pi.get('payer', '')
if pi.get('iban'):
if r:
r += ' / '
r += pi.get('iban')
if pi.get('bic'):
if r:
r += ' / '
r += pi.get('bic')
return r
def payment_presale_render(self, payment: OrderPayment) -> str:
pi = payment.info_data or {}
if self.payment_refund_supported(payment):
try:
iban = self.norm(pi['iban'])
return gettext('Bank account {iban}').format(
iban=iban[0:2] + '****' + iban[-4:]
)
except:
pass
return super().payment_presale_render(payment)
def execute_refund(self, refund: OrderRefund):
"""
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."))
refund.info_data = {
'payer': refund.payment.info_data['payer'],
'iban': self.norm(refund.payment.info_data['iban']),
'bic': self.norm(refund.payment.info_data['bic']) if refund.payment.info_data.get('bic') else None,
}
refund.save(update_fields=["info"])
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'),
)
bic = BICFormField(
label=_('BIC (optional)'),
required=False,
)
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.'))
d = {
'payer': f.cleaned_data['payer'],
'iban': self.norm(f.cleaned_data['iban']),
}
if f.cleaned_data.get('bic'):
d['bic'] = f.cleaned_data['bic']
return OrderRefund(
order=order,
payment=None,
state=OrderRefund.REFUND_STATE_CREATED,
amount=amount,
provider=self.identifier,
info=json.dumps(d)
)