From 146f63e1f2235312f04e83637df40a7072824635 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 5 Aug 2026 11:38:17 +0200 Subject: [PATCH] Money representation in templates: Allow more precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When rendering money in templates, we used to have the following logic: - When the decimal places fit the currency, render with Babel - When they don't, e.g. we stored 123.67 JPY, even though there are no fractional Yens, render without Babel with a custom format, but render the fractional Yens because we'd rather *show* wrong data and make the bug obvious than hide it. However, we only did that up to a prevision of two places, we never showed more. This is still sufficient for core pretix, but we have plugins that need to operate in fractional cents. Also, we CAN render everything through babel for consistent formatting. There is one **risk**: This might cause weird results on SQLite. Since SQLite has no concept of precise decimal math, results of in-SQL computations can sometimes experience floating point errors and show with A LOT of decimal palces. This used to be invisible since the UI performed the rounding. With this PR – not any more. We'll need to see how annoying it is, but it should only affect development mode. This PR also fixes a bug in tax_rate_format that for some reason did not do what it was supposed to do, even though I tested it back then, weird. Might even be a Python version thing? --- src/pretix/base/templatetags/money.py | 47 ++++++++++++++++++--------- src/pretix/control/views/organizer.py | 4 ++- src/tests/base/test_templatetag.py | 31 ++++++++++++++---- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/pretix/base/templatetags/money.py b/src/pretix/base/templatetags/money.py index bb3f674e73..2dbe25a6e2 100644 --- a/src/pretix/base/templatetags/money.py +++ b/src/pretix/base/templatetags/money.py @@ -48,19 +48,12 @@ def money_filter(value: Decimal, arg='', hide_currency=False): raise ValueError("No currency passed.") arg = arg.upper() - places = settings.CURRENCY_PLACES.get(arg, 2) - rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP) - if places < 2 and rounded != value: - # We display decimal places even if we shouldn't for this currency if rounding - # would make the numbers incorrect. If this branch executes, it's likely a bug in - # pretix, but we won't show wrong numbers! - if hide_currency: - return floatformat(value, "2g") - else: - return '{} {}'.format(arg, floatformat(value, "2g")) + currency_places = settings.CURRENCY_PLACES.get(arg, 2) + required_places = -value.normalize().as_tuple().exponent + render_places = max(currency_places, required_places) if hide_currency: - return floatformat(value, f"{places}g") + return floatformat(value, f"{render_places}g") try: locale = Locale(get_babel_locale()) @@ -68,9 +61,24 @@ def money_filter(value: Decimal, arg='', hide_currency=False): locale = "en" try: - return format_currency(value, arg, locale=locale) + return format_currency( + value, + arg, + locale=locale, + # We only allow Babel to restrict the digits to the digits by the currency if this does not remove any + # precision in case we have sub-currency precision (which we shouldn't have in most places, but it's still + # better than showing wrong data). Note: Weird precision effects can occur after in-database arithmetic + # on SQLite, since SQLite does not have fixed-decimal computation. + currency_digits=currency_places >= required_places, + decimal_quantization=currency_places >= required_places, + ) except: - return '{} {}'.format(arg, floatformat(value, f"{places}g")) + return '{} {}'.format(arg, floatformat(value, f"{render_places}g")) + + +@register.filter("money_without_currency") +def money_filter_without_currency(value: Decimal, arg=''): + return money_filter(value, arg, hide_currency=True) @register.filter("money_numberfield") @@ -91,11 +99,18 @@ def tax_rate_format(number): """ Display a Decimal to its significant decimal places, used for tax rates. """ - assert isinstance(number, Decimal) + if isinstance(number, (float, int, str)): + number = Decimal(number) + if number is None: + number = Decimal('0.00') + if not isinstance(number, Decimal): + if number == '': + return number + raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number)) return mark_safe( formats.number_format( - number.normalize(), - -number.as_tuple().exponent, + number, + -number.normalize().as_tuple().exponent, use_l10n=True, force_grouping=False, ) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index ca6d5f11f8..6ca7d8d084 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -108,7 +108,9 @@ from pretix.base.services.export import ( init_organizer_exporters, multiexport, scheduled_organizer_export, ) from pretix.base.services.mail import mail, prefix_subject -from pretix.base.services.placeholders import prepare_sample_context_for_preview +from pretix.base.services.placeholders import ( + prepare_sample_context_for_preview, +) from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.tasks import AsyncAction from pretix.control.forms.exports import ScheduledOrganizerExportForm diff --git a/src/tests/base/test_templatetag.py b/src/tests/base/test_templatetag.py index d2041e5d38..a4b5cd3ef0 100644 --- a/src/tests/base/test_templatetag.py +++ b/src/tests/base/test_templatetag.py @@ -26,7 +26,7 @@ from django.template import Context, Template from django.test import RequestFactory from django.utils import translation -from pretix.base.templatetags.money import money_filter +from pretix.base.templatetags.money import money_filter, tax_rate_format TEMPLATE_REPLACE_PAGE = Template( "{% load urlreplace %}{% url_replace request 'page' 3 %}" @@ -70,11 +70,13 @@ def test_urlreplace_replace_parameter(): # unknown currency ("de", Decimal("1234.56"), "FOO", "1.234,56" + NBSP + "FOO"), - ("de", Decimal("1234.567"), "FOO", "1.234,57" + NBSP + "FOO"), + ("de", Decimal("1234.567"), "FOO", "1.234,567" + NBSP + "FOO"), - # rounding errors - ("de", Decimal("1.234"), "EUR", "1,23" + NBSP + "€"), - ("de", Decimal("1023.1"), "JPY", "JPY 1.023,10"), + # deal with precision that is higher than the currency + ("de", Decimal("1.234"), "EUR", "1,234" + NBSP + "€"), + ("de", Decimal("1.2340"), "EUR", "1,234" + NBSP + "€"), + ("de", Decimal("1.2300"), "EUR", "1,23" + NBSP + "€"), + ("de", Decimal("1023.1"), "JPY", "1.023,10" + NBSP + "¥"), ] ) def test_money_filter(locale, amount, currency, expected): @@ -98,9 +100,26 @@ def test_money_filter(locale, amount, currency, expected): [ ("de", Decimal("1000.00"), "EUR", "1.000,00"), ("en", Decimal("1000.00"), "EUR", "1,000.00"), - ("de", Decimal("1023.1"), "JPY", "1.023,10"), + ("de", Decimal("1023.1"), "JPY", "1.023,1"), ] ) def test_money_filter_hidecurrency(locale, amount, currency, expected): translation.activate(locale) assert money_filter(amount, currency, hide_currency=True) == expected + + +@pytest.mark.parametrize( + "locale,rate,expected", + [ + ("de", Decimal("2.00"), "2"), + ("de", Decimal("2.50"), "2,5"), + ("de", Decimal("2.2340"), "2,234"), + ("en", Decimal("2.00"), "2"), + ("en", Decimal("2.50"), "2.5"), + ("en", Decimal("4.3e7"), "43000000"), + ("en", Decimal("2.2340"), "2.234"), + ] +) +def test_tax_rate_format(locale, rate, expected): + translation.activate(locale) + assert tax_rate_format(rate) == expected