From e1f5678d7c0c89473b2f7ddb77843a1de89d48b2 Mon Sep 17 00:00:00 2001 From: luelista Date: Thu, 4 Dec 2025 14:15:29 +0100 Subject: [PATCH] Refactor payment QR code generation code and add SPAYD format (#5680) Move generation of QR code contents out of the HTML template and into Python code, so it can be reused in plugins and tested with unit tests. Add the SPAYD QR code format which is used in Czech Republic and Slovakia [1]. Display BezahlCode QR codes only for German IBANs. [1] https://en.wikipedia.org/wiki/Short_Payment_Descriptor --- src/pretix/helpers/payment.py | 210 ++++++++++++++++++ src/pretix/plugins/banktransfer/payment.py | 58 +---- .../pretixplugins/banktransfer/pending.html | 96 +------- .../pretixpresale/event/payment_qr_codes.html | 44 ++++ src/tests/helpers/test_payment.py | 141 ++++++++++++ 5 files changed, 408 insertions(+), 141 deletions(-) create mode 100644 src/pretix/helpers/payment.py create mode 100644 src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html create mode 100644 src/tests/helpers/test_payment.py diff --git a/src/pretix/helpers/payment.py b/src/pretix/helpers/payment.py new file mode 100644 index 0000000000..8d19d56fd3 --- /dev/null +++ b/src/pretix/helpers/payment.py @@ -0,0 +1,210 @@ +# +# 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 . +# +# 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 +# . +# +from urllib.parse import quote, urlencode + +import text_unidecode +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ + + +def dotdecimal(value): + return str(value).replace(",", ".") + + +def commadecimal(value): + return str(value).replace(".", ",") + + +def generate_payment_qr_codes( + event, + code, + amount, + bank_details_sepa_bic, + bank_details_sepa_name, + bank_details_sepa_iban, +): + out = [] + for method in [ + swiss_qrbill, + czech_spayd, + euro_epc_qr, + euro_bezahlcode, + ]: + data = method( + event, + code, + amount, + bank_details_sepa_bic, + bank_details_sepa_name, + bank_details_sepa_iban, + ) + if data: + out.append(data) + + return out + + +def euro_epc_qr( + event, + code, + amount, + bank_details_sepa_bic, + bank_details_sepa_name, + bank_details_sepa_iban, +): + if event.currency != 'EUR' or not bank_details_sepa_iban: + return + + return { + "id": "girocode", + "label": "EPC-QR", + "qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [ + "BCD", # Service Tag: ‘BCD’ + "002", # Version: V2 + "2", # Character set: ISO 8859-1 + "SCT", # Identification code: ‘SCT‘ + bank_details_sepa_bic, # AT-23 BIC of the Beneficiary Bank + bank_details_sepa_name, # AT-21 Name of the Beneficiary + bank_details_sepa_iban, # AT-20 Account number of the Beneficiary + f"{event.currency}{dotdecimal(amount)}", # AT-04 Amount of the Credit Transfer in Euro + "", # AT-44 Purpose of the Credit Transfer + "", # AT-05 Remittance Information (Structured) + code, # AT-05 Remittance Information (Unstructured) + "", # Beneficiary to originator information + "", + ]), + } + + +def euro_bezahlcode( + event, + code, + amount, + bank_details_sepa_bic, + bank_details_sepa_name, + bank_details_sepa_iban, +): + if not bank_details_sepa_iban or bank_details_sepa_iban[:2] != 'DE': + return + if event.currency != 'EUR': + return + + qr_data = "bank://singlepaymentsepa?" + urlencode({ + "name": str(bank_details_sepa_name), + "iban": str(bank_details_sepa_iban), + "bic": str(bank_details_sepa_bic), + "amount": commadecimal(amount), + "reason": str(code), + "currency": str(event.currency), + }, quote_via=quote) + return { + "id": "bezahlcode", + "label": "BezahlCode", + "qr_data": mark_safe(qr_data), + "link": qr_data, + "link_aria_label": _("Open BezahlCode in your banking app to start the payment process."), + } + + +def swiss_qrbill( + event, + code, + amount, + bank_details_sepa_bic, + bank_details_sepa_name, + bank_details_sepa_iban, +): + if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CH', 'LI'): + return + if event.currency not in ('EUR', 'CHF'): + return + if not event.settings.invoice_address_from or not event.settings.invoice_address_from_country: + return + + data_fields = [ + 'SPC', + '0200', + '1', + bank_details_sepa_iban, + 'K', + bank_details_sepa_name[:70], + event.settings.invoice_address_from.replace('\n', ', ')[:70], + (event.settings.invoice_address_from_zipcode + ' ' + event.settings.invoice_address_from_city)[:70], + '', + '', + str(event.settings.invoice_address_from_country), + '', # rfu + '', # rfu + '', # rfu + '', # rfu + '', # rfu + '', # rfu + '', # rfu + str(amount), + event.currency, + '', # debtor address + '', # debtor address + '', # debtor address + '', # debtor address + '', # debtor address + '', # debtor address + '', # debtor address + 'NON', + '', # structured reference + code, + 'EPD', + ] + + data_fields = [text_unidecode.unidecode(d or '') for d in data_fields] + qr_data = '\r\n'.join(data_fields) + return { + "id": "qrbill", + "label": "QR-bill", + "html_prefix": mark_safe( + '' + '' + '' + ), + "qr_data": qr_data, + "css_class": "banktransfer-swiss-cross-overlay", + } + + +def czech_spayd( + event, + code, + amount, + bank_details_sepa_bic, + bank_details_sepa_name, + bank_details_sepa_iban, +): + if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CZ', 'SK'): + return + if event.currency not in ('EUR', 'CZK'): + return + + qr_data = f"SPD*1.0*ACC:{bank_details_sepa_iban}*AM:{dotdecimal(amount)}*CC:{event.currency}*MSG:{code}" + return { + "id": "spayd", + "label": "SPAYD", + "qr_data": qr_data, + } diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index 380c31a12d..c53aaa339a 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -46,12 +46,12 @@ from i18nfield.forms import I18nTextInput from i18nfield.strings import LazyI18nString from localflavor.generic.forms import BICFormField, IBANFormField from localflavor.generic.validators import IBANValidator -from text_unidecode import unidecode 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 @@ -313,51 +313,6 @@ class BankTransfer(BasePaymentProvider): t += str(self.settings.get('bank_details', as_type=LazyI18nString)) return t - def swiss_qrbill(self, payment): - if not self.settings.get('bank_details_sepa_iban') or not self.settings.get('bank_details_sepa_iban')[:2] in ('CH', 'LI'): - return - if self.event.currency not in ('EUR', 'CHF'): - return - if not self.event.settings.invoice_address_from or not self.event.settings.invoice_address_from_country: - return - - data_fields = [ - 'SPC', - '0200', - '1', - self.settings.get('bank_details_sepa_iban'), - 'K', - self.settings.get('bank_details_sepa_name')[:70], - self.event.settings.invoice_address_from.replace('\n', ', ')[:70], - (self.event.settings.invoice_address_from_zipcode + ' ' + self.event.settings.invoice_address_from_city)[:70], - '', - '', - str(self.event.settings.invoice_address_from_country), - '', # rfu - '', # rfu - '', # rfu - '', # rfu - '', # rfu - '', # rfu - '', # rfu - str(payment.amount), - self.event.currency, - '', # debtor address - '', # debtor address - '', # debtor address - '', # debtor address - '', # debtor address - '', # debtor address - '', # debtor address - 'NON', - '', # structured reference - self._code(payment.order), - 'EPD', - ] - - data_fields = [unidecode(d or '') for d in data_fields] - return '\r\n'.join(data_fields) - def payment_pending_render(self, request: HttpRequest, payment: OrderPayment): template = get_template('pretixplugins/banktransfer/pending.html') ctx = { @@ -367,13 +322,18 @@ class BankTransfer(BasePaymentProvider): 'amount': payment.amount, 'payment_info': payment.info_data, 'settings': self.settings, - 'swiss_qrbill': self.swiss_qrbill(payment), - 'eu_barcodes': self.event.currency == 'EUR', + '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(), } - ctx['any_barcodes'] = ctx['swiss_qrbill'] or ctx['eu_barcodes'] return template.render(ctx, request=request) def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str: diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html index 028d90bac2..5d25bad1b1 100644 --- a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/pending.html @@ -1,7 +1,6 @@ {% load i18n %} {% load l10n %} {% load commadecimal %} -{% load static %} {% load dotdecimal %} {% load ibanformat %} {% load money %} @@ -17,7 +16,7 @@ {% endblocktrans %}

-
+
{% trans "Reference code (important):" %}
{{ code }}
{% trans "Amount:" %}
{{ amount|money:event.currency }}
@@ -36,94 +35,7 @@ {% trans "We will send you an email as soon as we received your payment." %}

- {% if settings.bank_details_type == "sepa" and any_barcodes %} - + {% if payment_qr_codes %} + {% include "pretixpresale/event/payment_qr_codes.html" %} {% endif %} -
-{% if swiss_qrbill %} - -{% endif %} +
\ No newline at end of file diff --git a/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html b/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html new file mode 100644 index 0000000000..9c1500f082 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html @@ -0,0 +1,44 @@ +{% load i18n %} +{% load static %} + +{% if payment_qr_codes %} + + {% for code_info in payment_qr_codes %} + {% if code_info.id == "qrbill" %} + + {% endif %} + {% endfor %} +{% endif %} \ No newline at end of file diff --git a/src/tests/helpers/test_payment.py b/src/tests/helpers/test_payment.py new file mode 100644 index 0000000000..82ab754d69 --- /dev/null +++ b/src/tests/helpers/test_payment.py @@ -0,0 +1,141 @@ +# +# 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 . +# +# 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 +# . +# +from datetime import timedelta +from decimal import Decimal + +import pytest +from django.utils.timezone import now + +from pretix.base.models import Event, Organizer +from pretix.helpers.payment import generate_payment_qr_codes + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Verein für Testzwecke e.V.', slug='testverein') + event = Event.objects.create( + organizer=o, name='Testveranstaltung', slug='testveranst', + date_from=now() + timedelta(days=10), + live=True, is_public=False, currency='EUR', + ) + event.settings.invoice_address_from = 'Verein für Testzwecke e.V.' + event.settings.invoice_address_from_zipcode = '1234' + event.settings.invoice_address_from_city = 'Testhausen' + event.settings.invoice_address_from_country = 'CH' + + return o, event + + +@pytest.mark.django_db +def test_payment_qr_codes_euro(env): + o, event = env + codes = generate_payment_qr_codes( + event=event, + code='TESTVERANST-12345', + amount=Decimal('123.00'), + bank_details_sepa_bic='BYLADEM1MIL', + bank_details_sepa_iban='DE37796500000069799047', + bank_details_sepa_name='Verein für Testzwecke e.V.', + ) + assert len(codes) == 2 + assert codes[0]['label'] == 'EPC-QR' + assert codes[0]['qr_data'] == '''BCD +002 +2 +SCT +BYLADEM1MIL +Verein fur Testzwecke e.V. +DE37796500000069799047 +EUR123.00 + + +TESTVERANST-12345 + +''' + + assert codes[1]['label'] == 'BezahlCode' + assert codes[1]['qr_data'] == ('bank://singlepaymentsepa?name=Verein%20f%C3%BCr%20Testzwecke%20e.V.&iban=DE37796500000069799047' + '&bic=BYLADEM1MIL&amount=123%2C00&reason=TESTVERANST-12345¤cy=EUR') + + +@pytest.mark.django_db +def test_payment_qr_codes_swiss(env): + o, event = env + codes = generate_payment_qr_codes( + event=event, + code='TESTVERANST-12345', + amount=Decimal('123.00'), + bank_details_sepa_bic='TESTCHXXXXX', + bank_details_sepa_iban='CH6389144757654882127', + bank_details_sepa_name='Verein für Testzwecke e.V.', + ) + assert codes[0]['label'] == 'QR-bill' + assert codes[0]['qr_data'] == "\r\n".join([ + "SPC", + "0200", + "1", + "CH6389144757654882127", + "K", + "Verein fur Testzwecke e.V.", + "Verein fur Testzwecke e.V.", + "1234 Testhausen", + "", + "", + "CH", + "", + "", + "", + "", + "", + "", + "", + "123.00", + "EUR", + "", + "", + "", + "", + "", + "", + "", + "NON", + "", + "TESTVERANST-12345", + "EPD", + ]) + + +@pytest.mark.django_db +def test_payment_qr_codes_spayd(env): + o, event = env + codes = generate_payment_qr_codes( + event=event, + code='TESTVERANST-12345', + amount=Decimal('123.00'), + bank_details_sepa_bic='TESTCZXXXXX', + bank_details_sepa_iban='CZ7450513769129174398769', + bank_details_sepa_name='Verein für Testzwecke e.V.', + ) + assert len(codes) == 2 + assert codes[0]['label'] == 'SPAYD' + assert codes[0]['qr_data'] == 'SPD*1.0*ACC:CZ7450513769129174398769*AM:123.00*CC:EUR*MSG:TESTVERANST-12345' + assert codes[1]['label'] == 'EPC-QR'