diff --git a/src/pretix/base/templatetags/money.py b/src/pretix/base/templatetags/money.py index bb3f674e7..91880ac7e 100644 --- a/src/pretix/base/templatetags/money.py +++ b/src/pretix/base/templatetags/money.py @@ -20,6 +20,7 @@ # . # from decimal import ROUND_HALF_UP, Decimal +from typing import Optional from babel import Locale, UnknownLocaleError from babel.numbers import format_currency @@ -35,32 +36,32 @@ register = template.Library() @register.filter("money") -def money_filter(value: Decimal, arg='', hide_currency=False): - if isinstance(value, (float, int)): +def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False): + if isinstance(value, (float, int, str)): + if value == '': + return value value = Decimal(value) if value is None: value = Decimal('0.00') if not isinstance(value, Decimal): - if value == '': - return value raise TypeError("Invalid data type passed to money filter: %r" % type(value)) if not arg: 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")) + if value.normalize().as_tuple().exponent < -9: + # Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we + # do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations + # are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python + # land to this filter (even though it should not happen). + value = value.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize() + + 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,14 +69,29 @@ 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 defined 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: Optional[Decimal | float | int | str], arg=''): + return money_filter(value, arg, hide_currency=True) @register.filter("money_numberfield") -def money_numberfield_filter(value: Decimal, arg=''): - if isinstance(value, (float, int)): +def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''): + if isinstance(value, (float, int, str)): value = Decimal(value) if not isinstance(value, Decimal): raise TypeError("Invalid data type passed to money filter: %r" % type(value)) @@ -87,15 +103,28 @@ def money_numberfield_filter(value: Decimal, arg=''): @register.filter(is_safe=True) -def tax_rate_format(number): +def tax_rate_format(number: Optional[Decimal | float | int | str]): """ Display a Decimal to its significant decimal places, used for tax rates. """ - assert isinstance(number, Decimal) + if isinstance(number, (float, int, str)): + if number == '': + return number + number = Decimal(number) + if number is None: + number = Decimal('0.00') + if not isinstance(number, Decimal): + raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number)) + if number.normalize().as_tuple().exponent < -9: + # Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we + # do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations + # are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python + # land to this filter (even though it should not happen). + number = number.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize() 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/tests/base/test_templatetag.py b/src/tests/base/test_templatetag.py index d2041e5d3..c7e71bb86 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 %}" @@ -60,7 +60,9 @@ def test_urlreplace_replace_parameter(): "locale,amount,currency,expected", [ ("en", None, "USD", "$0.00"), + ("en", "", "USD", ""), ("en", 1000000, "USD", "$1,000,000.00"), + ("en", 2.23, "USD", "$2.23"), ("en", Decimal("1000.00"), "USD", "$1,000.00"), ("de", Decimal("1.23"), "EUR", "1,23" + NBSP + "€"), ("de", Decimal("1000.00"), "EUR", "1.000,00" + NBSP + "€"), @@ -70,11 +72,14 @@ 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", 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 +103,31 @@ 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"), + ("en", "2.23", "2.23"), + ("en", 2.23, "2.23"), + ("en", 2, "2"), + ("en", "", ""), + ("en", None, "0"), + ] +) +def test_tax_rate_format(locale, rate, expected): + translation.activate(locale) + assert tax_rate_format(rate) == expected