Repository: liberapay/liberapay.com

Issue:
comma handling by payment system: French vs English notation
The French notation of decimals uses comma while the global notation uses dots.

At the time of adding money, this may lead to issues (and big payments!)

I wrote

![image](https://user-images.githubusercontent.com/6146483/38663250-9d549830-3e36-11e8-8237-45a3b69bbb86.png)

meaning 40.23

and indeed, it was interepreted as 10<sup>3</sup> higher

![image](https://user-images.githubusercontent.com/6146483/38663163-5fda69f8-3e36-11e8-8895-8a6754062604.png)



Discussion:

Resolving patch:
diff --git a/liberapay/exceptions.py b/liberapay/exceptions.py
--- a/liberapay/exceptions.py
+++ b/liberapay/exceptions.py
@@ -298,6 +298,24 @@ def msg(self, _):
         return _('"{0}" is not a valid number.', *self.args)
 
 
+class AmbiguousNumber(LazyResponse400):
+
+    def __init__(self, ambiguous_string, suggestions):
+        Response.__init__(self, 400, '')
+        self.ambiguous_string = ambiguous_string
+        self.suggestions = suggestions
+
+    def lazy_body(self, _, locale):
+        if self.suggestions:
+            return _(
+                '"{0}" is not a properly formatted number. Perhaps you meant {list_of_suggestions}?',
+                self.ambiguous_string,
+                list_of_suggestions=locale.format_list(self.suggestions, 'or')
+            )
+        else:
+            return _('"{0}" is not a properly formatted number.', self.ambiguous_string)
+
+
 class CommunityAlreadyExists(LazyResponse400):
     def msg(self, _):
         return _('The "{0}" community already exists.', *self.args)
diff --git a/liberapay/utils/i18n.py b/liberapay/utils/i18n.py
--- a/liberapay/utils/i18n.py
+++ b/liberapay/utils/i18n.py
@@ -18,15 +18,14 @@
 from babel.messages.pofile import Catalog
 from babel.numbers import (
     format_currency, format_decimal, format_number, format_percent,
-    NumberFormatError, parse_decimal
 )
 import jinja2.ext
 from mangopay.utils import Money
 from markupsafe import Markup
 from pando.utils import utcnow
 
-from liberapay.constants import CURRENCIES, D_CENT, D_MAX
-from liberapay.exceptions import InvalidNumber
+from liberapay.constants import CURRENCIES, D_MAX
+from liberapay.exceptions import AmbiguousNumber, InvalidNumber
 from liberapay.utils.currencies import MoneyBasket
 from liberapay.website import website
 
@@ -61,10 +60,20 @@ class Locale(babel.core.Locale):
 
     def __init__(self, *a, **kw):
         super(Locale, self).__init__(*a, **kw)
-        self.decimal_symbol = self.number_symbols.get('decimal', '.')
+        self.decimal_symbol = self.number_symbols['decimal']
         delta_p = self.currency_formats['standard'].pattern
-        assert ';' not in delta_p
-        self.currency_delta_pattern = '+{0};-{0}'.format(delta_p)
+        minus_sign = self.number_symbols.get('minusSign', '-')
+        plus_sign = self.number_symbols.get('plusSign', '+')
+        if ';' in delta_p:
+            pos, neg = delta_p.split(';')
+            assert len(neg) > len(pos)
+            assert minus_sign in neg
+            pos = neg.replace(minus_sign, plus_sign)
+            self.currency_delta_pattern = '%s;%s' % (pos, neg)
+        else:
+            self.currency_delta_pattern = (
+                '{0}{2};{1}{2}'.format(plus_sign, minus_sign, delta_p)
+            )
 
     def format_money(self, m, format=None, trailing_zeroes=True):
         s = format_currency(m.amount, m.currency, format, locale=self)
@@ -80,21 +89,22 @@ def format_date(self, date, format='medium'):
     def format_datetime(self, *a):
         return format_datetime(*a, locale=self)
 
-    def format_decimal(self, *a):
-        return format_decimal(*a, locale=self)
+    def format_decimal(self, *a, **kw):
+        kw['locale'] = self
+        return format_decimal(*a, **kw)
 
-    def format_list(self, l):
+    def format_list(self, l, pattern='standard'):
         n = len(l)
         if n > 2:
             last = n - 2
             r = l[0]
             for i, item in enumerate(l[1:]):
-                r = self.list_patterns[
+                r = self.list_patterns[pattern][
                     'start' if i == 0 else 'end' if i == last else 'middle'
                 ].format(r, item)
             return r
         elif n == 2:
-            return self.list_patterns['2'].format(*l)
+            return self.list_patterns[pattern]['2'].format(*l)
         else:
             return l[0] if n == 1 else None
 
@@ -123,16 +133,35 @@ def format_number(self, *a):
     def format_percent(self, *a):
         return format_percent(*a, locale=self)
 
-    def parse_decimal_or_400(self, s, maximum=D_MAX):
+    def parse_money_amount(self, string, currency, maximum=D_MAX):
+        group_symbol = self.number_symbols['group']
+        decimal_symbol = self.number_symbols['decimal']
         try:
-            r = parse_decimal(s, locale=self)
-        except (InvalidOperation, NumberFormatError, ValueError):
-            raise InvalidNumber(s)
-        if r.quantize(D_CENT) != r:
-            raise InvalidNumber(s)
-        if maximum is not None and r > maximum:
-            raise InvalidNumber(s)
-        return r
+            decimal = Decimal(
+                string.replace(group_symbol, '').replace(decimal_symbol, '.')
+            )
+        except (InvalidOperation, ValueError):
+            raise InvalidNumber(string)
+        if group_symbol in string:
+            proper = self.format_decimal(decimal, decimal_quantization=False)
+            if string != proper and string.rstrip('0') != (proper + decimal_symbol):
+                # Irregular number format (e.g. `10.00` in German)
+                try:
+                    decimal_alt = Decimal(
+                        string.replace(decimal_symbol, '').replace(group_symbol, '.')
+                    )
+                except (InvalidOperation, ValueError):
+                    raise AmbiguousNumber(string, [proper])
+                else:
+                    proper_alt = self.format_decimal(decimal_alt, decimal_quantization=False)
+                    raise AmbiguousNumber(string, [proper, proper_alt])
+        if maximum is not None and decimal > maximum:
+            raise InvalidNumber(string)
+        money = Money(decimal, currency).round_down()
+        if money.amount != decimal:
+            # The input amount exceeds maximum precision (e.g. $0.001).
+            raise InvalidNumber(string)
+        return money
 
     @staticmethod
     def title(s):
@@ -445,7 +474,6 @@ def add_helpers_to_context(context, loc):
         format_money_delta=loc.format_money_delta,
         format_number=loc.format_number,
         format_percent=loc.format_percent,
-        parse_decimal=loc.parse_decimal_or_400,
         to_age_str=loc.to_age_str,
     )
 


Test patch:
diff --git a/tests/py/test_goal.py b/tests/py/test_goal.py
--- a/tests/py/test_goal.py
+++ b/tests/py/test_goal.py
@@ -8,21 +8,25 @@ class Tests(Harness):
     def setUp(self):
         self.alice = self.make_participant('alice')
 
-    def change_goal(self, goal, goal_custom="", auth_as="alice"):
-        return self.client.PxST(
+    def change_goal(self, goal, goal_custom="", auth_as="alice", expect_success=False):
+        r = self.client.PxST(
             "/alice/edit/goal",
             {'goal': goal, 'goal_custom': goal_custom},
             auth_as=self.alice if auth_as == 'alice' else auth_as
         )
+        if expect_success and r.code >= 400:
+            raise r
+        return r
 
     def test_changing_to_minus_1_asks_confirmation(self):
         r = self.client.PxST('/alice/edit/goal', {'goal': '-1'}, auth_as=self.alice)
         assert "Warning: Doing this will remove all the tips you are currently receiving." in r.text
 
-    def test_wonky_custom_amounts_are_standardized(self):
-        self.change_goal("custom", ",100,100.0")
-        alice = self.alice.from_id(self.alice.id)
-        assert alice.goal == 100100
+    def test_wonky_custom_amounts_are_rejected(self):
+        r = self.change_goal("custom", ",100,100.0")
+        assert r.code == 400
+        alice = self.alice.refetch()
+        assert alice.goal is None
 
     def test_anonymous_gets_403(self):
         response = self.change_goal("100.00", auth_as=None)
@@ -37,14 +41,14 @@ def test_invalid_custom_amount_is_400(self):
         assert response.code == 400, response.code
 
     def test_change_goal(self):
-        self.change_goal("custom", "100")
-        self.change_goal("0")
-        self.change_goal("custom", "1,100.00")
-        self.change_goal("null", "")
-        self.change_goal("custom", "400")
+        self.change_goal("custom", "100", expect_success=True)
+        self.change_goal("0", expect_success=True)
+        self.change_goal("custom", "1,100.00", expect_success=True)
+        self.change_goal("null", "", expect_success=True)
+        self.change_goal("custom", "4000", expect_success=True)
 
         actual = self.db.one("SELECT goal FROM participants")
-        assert actual == EUR('400.00')
+        assert actual == EUR('4000.00')
 
         actual = self.db.all("""
             SELECT payload
@@ -52,7 +56,7 @@ def test_change_goal(self):
              WHERE type = 'set_goal'
           ORDER BY ts DESC
         """)
-        assert actual == ['400.00 EUR', None, '1100.00 EUR', '0.00 EUR', '100.00 EUR']
+        assert actual == ['4000.00 EUR', None, '1100.00 EUR', '0.00 EUR', '100.00 EUR']
 
     def test_team_member_can_change_team_goal(self):
         team = self.make_participant('team', kind='group')
diff --git a/tests/py/test_i18n.py b/tests/py/test_i18n.py
new file mode 100644
--- /dev/null
+++ b/tests/py/test_i18n.py
@@ -0,0 +1,37 @@
+# coding: utf8
+
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+from liberapay.exceptions import AmbiguousNumber, InvalidNumber
+from liberapay.testing import Harness
+from liberapay.utils.i18n import LOCALE_EN, Money
+
+
+class Tests(Harness):
+
+    def test_format_currency_without_trailing_zeroes(self):
+        expected = '$16'
+        actual = LOCALE_EN.format_money(Money(16, 'USD'), trailing_zeroes=False)
+        assert actual == expected
+
+    def test_format_currency_defaults_to_trailing_zeroes(self):
+        expected = '$16.00'
+        actual = LOCALE_EN.format_money(Money(16, 'USD'))
+        assert actual == expected
+
+    def test_locales_share_message_keys(self):
+        msgkey1 = self.website.locales['de'].catalog['Save'].id
+        msgkey2 = self.website.locales['fr'].catalog['Save'].id
+        assert id(msgkey1) == id(msgkey2)
+
+    def test_parse_money_amount_rejects_overly_precise_numbers(self):
+        with self.assertRaises(InvalidNumber):
+            LOCALE_EN.parse_money_amount("100.00001", 'EUR')
+
+    def test_parse_money_amount_rejects_irregular_numbers(self):
+        with self.assertRaises(AmbiguousNumber):
+            LOCALE_EN.parse_money_amount(",100,100", 'USD')
+
+    def test_parse_money_amount_rejects_ambiguous_numbers(self):
+        with self.assertRaises(AmbiguousNumber):
+            LOCALE_EN.parse_money_amount("10,00", 'EUR')
diff --git a/tests/py/test_utils.py b/tests/py/test_utils.py
--- a/tests/py/test_utils.py
+++ b/tests/py/test_utils.py
@@ -5,13 +5,12 @@
 from datetime import datetime
 from datetime import timedelta
 
-from mangopay.utils import Money
 from markupsafe import escape
 from pando.http.response import Response
 
 from liberapay import utils
 from liberapay.testing import Harness
-from liberapay.utils import i18n, markdown, b64encode_s, b64decode_s
+from liberapay.utils import markdown, b64encode_s, b64decode_s
 from liberapay.wireup import CSP
 
 
@@ -98,24 +97,6 @@ def test_not_expired(self):
         expiration = datetime.utcnow() + timedelta(days=100)
         assert not utils.is_card_expired(expiration.year, expiration.month)
 
-    # i18n
-    # ====
-
-    def test_format_currency_without_trailing_zeroes(self):
-        expected = '$16'
-        actual = i18n.LOCALE_EN.format_money(Money(16, 'USD'), trailing_zeroes=False)
-        assert actual == expected
-
-    def test_format_currency_defaults_to_trailing_zeroes(self):
-        expected = '$16.00'
-        actual = i18n.LOCALE_EN.format_money(Money(16, 'USD'))
-        assert actual == expected
-
-    def test_locales_share_message_keys(self):
-        msgkey1 = self.website.locales['de'].catalog['Save'].id
-        msgkey2 = self.website.locales['fr'].catalog['Save'].id
-        assert id(msgkey1) == id(msgkey2)
-
     # Markdown
     # ========