Repository: liberapay/liberapay.com

Issue:
A 0 £GB donation = bug ?
I just saw this:
![lp-0gb-bug](https://user-images.githubusercontent.com/8705846/57778493-765f5d00-7724-11e9-8edb-93d93dadc268.png)
which looks like there is a 0 £GB donation.

Sounds like a bug, isn't?


Discussion:
I found 4 donations with invalid amounts:

```sql
select ctime, mtime, amount, is_funded, period, periodic_amount, paid_in_advance, renewal_mode from current_tips where periodic_amount > 0 and amount = 0;
             ctime             |             mtime             |   amount   | is_funded | period | periodic_amount | paid_in_advance | renewal_mode 
-------------------------------+-------------------------------+------------+-----------+--------+-----------------+-----------------+--------------
 2019-01-25 21:58:32.783785+00 | 2019-01-25 21:58:42.298588+00 | (0.00,GBP) | t         | weekly | (0.009,GBP)     |                 |            0
 2019-05-08 14:10:15.783115+00 | 2019-05-08 14:14:46.4207+00   | (0.00,GBP) | t         | weekly | (0.009,GBP)     |                 |            1
 2019-04-15 21:47:38.424346+00 | 2019-04-15 21:48:28.719422+00 | (0.00,GBP) | t         | weekly | (0.009,GBP)     |                 |            0
 2019-05-04 20:28:32.562559+00 | 2019-05-04 20:28:32.562559+00 | (0.00,GBP) | t         | weekly | (0.009,GBP)     |                 |            1
(4 rows)
```

and fixed them:

```sql
UPDATE tips
   SET amount = ('0.01','GBP')::currency_amount
     , periodic_amount = ('0.01','GBP')::currency_amount
 WHERE periodic_amount = ('0.009','GBP')::currency_amount;
UPDATE 6
```

I've also found the cause:

```python
>>> constants.STANDARD_TIPS['GBP']
[StandardTip(label='Symbolic', weekly=<Money "0.009 GBP">, monthly=<Money "0.03899999999999999999999999999 GBP">, yearly=<Money "0.4680000000000000000000000000 GBP">), StandardTip(label='Small', weekly=<Money "0.2 GBP">, monthly=<Money "0.8666666666666666666666666666 GBP">, yearly=<Money "10.40000000000000000000000000 GBP">), StandardTip(label='Medium', weekly=<Money "0.9 GBP">, monthly=<Money "3.899999999999999999999999999 GBP">, yearly=<Money "46.80000000000000000000000000 GBP">), StandardTip(label='Large', weekly=<Money "4.4 GBP">, monthly=<Money "19.06666666666666666666666666 GBP">, yearly=<Money "228.8000000000000000000000000 GBP">), StandardTip(label='Maximum', weekly=<Money "87 GBP">, monthly=<Money "376.9999999999999999999999999 GBP">, yearly=<Money "4524.000000000000000000000000 GBP">)]
>>> constants.STANDARD_TIPS['EUR']
[StandardTip(label='Symbolic', weekly=<Money "0.01 EUR">, monthly=<Money "0.04333333333333333333333333333 EUR">, yearly=<Money "0.5200000000000000000000000000 EUR">), StandardTip(label='Small', weekly=<Money "0.25 EUR">, monthly=<Money "1.083333333333333333333333333 EUR">, yearly=<Money "13.00000000000000000000000000 EUR">), StandardTip(label='Medium', weekly=<Money "1.00 EUR">, monthly=<Money "4.333333333333333333333333333 EUR">, yearly=<Money "52.00000000000000000000000000 EUR">), StandardTip(label='Large', weekly=<Money "5.00 EUR">, monthly=<Money "21.66666666666666666666666666 EUR">, yearly=<Money "260.0000000000000000000000000 EUR">), StandardTip(label='Maximum', weekly=<Money "100.00 EUR">, monthly=<Money "433.3333333333333333333333333 EUR">, yearly=<Money "5200.000000000000000000000000 EUR">)]
```

Resolving patch:
diff --git a/liberapay/constants.py b/liberapay/constants.py
--- a/liberapay/constants.py
+++ b/liberapay/constants.py
@@ -31,20 +31,34 @@ def with_vat(self):
 
 
 def to_precision(x, precision, rounding=ROUND_HALF_UP):
-    if not x:
-        return x
+    """Round `x` to keep only `precision` of its most significant digits.
+
+    >>> to_precision(Decimal('0.0086820'), 2)
+    Decimal('0.0087')
+    >>> to_precision(Decimal('13567.89'), 3)
+    Decimal('13600')
+    >>> to_precision(Decimal('0.000'), 4)
+    Decimal('0')
+    """
+    if x == 0:
+        return Decimal(0)
+    log10 = x.log10().to_integral(ROUND_FLOOR)
     # round
-    factor = Decimal(10) ** (x.log10().to_integral(ROUND_FLOOR) + 1)
+    factor = Decimal(10) ** (log10 + 1)
     r = (x / factor).quantize(Decimal(10) ** -precision, rounding=rounding) * factor
     # remove trailing zeros
-    r = r.quantize(Decimal(10) ** (int(x.log10()) - precision + 1))
+    r = r.quantize(Decimal(10) ** (log10 - precision + 1))
     return r
 
 
 def convert_symbolic_amount(amount, target_currency, precision=2, rounding=ROUND_HALF_UP):
     from liberapay.website import website
     rate = website.currency_exchange_rates[('EUR', target_currency)]
-    return to_precision(amount * rate, precision, rounding)
+    minimum = Money.MINIMUMS[target_currency].amount
+    return max(
+        to_precision(amount * rate, precision, rounding).quantize(minimum, rounding),
+        minimum
+    )
 
 
 class MoneyAutoConvertDict(defaultdict):
@@ -90,11 +104,19 @@ def __missing__(self, currency):
 
 class _DonationLimits(defaultdict):
     def __missing__(self, currency):
+        minimum = Money.MINIMUMS[currency].amount
+        eur_weekly_amounts = DONATION_LIMITS_EUR_USD['weekly']
+        converted_weekly_amounts = (
+            convert_symbolic_amount(eur_weekly_amounts[0], currency),
+            convert_symbolic_amount(eur_weekly_amounts[1], currency)
+        )
         r = {
-            period: (
-                Money(convert_symbolic_amount(eur_amounts[0], currency, rounding=ROUND_UP), currency),
-                Money(convert_symbolic_amount(eur_amounts[1], currency, rounding=ROUND_UP), currency)
-            ) for period, eur_amounts in DONATION_LIMITS_EUR_USD.items()
+            'weekly': tuple(Money(x, currency) for x in converted_weekly_amounts),
+            'monthly': tuple(
+                Money((x * Decimal(52) / Decimal(12)).quantize(minimum, rounding=ROUND_UP), currency)
+                for x in converted_weekly_amounts
+            ),
+            'yearly': tuple(Money(x * Decimal(52), currency) for x in converted_weekly_amounts),
         }
         self[currency] = r
         return r
@@ -104,8 +126,7 @@ def __missing__(self, currency):
     'weekly': DONATION_LIMITS_WEEKLY_EUR_USD,
     'monthly': tuple((x * Decimal(52) / Decimal(12)).quantize(D_CENT, rounding=ROUND_UP)
                      for x in DONATION_LIMITS_WEEKLY_EUR_USD),
-    'yearly': tuple((x * Decimal(52)).quantize(D_CENT)
-                    for x in DONATION_LIMITS_WEEKLY_EUR_USD),
+    'yearly': tuple(x * Decimal(52) for x in DONATION_LIMITS_WEEKLY_EUR_USD),
 }
 DONATION_LIMITS = _DonationLimits(None, {
     'EUR': {k: (Money(v[0], 'EUR'), Money(v[1], 'EUR')) for k, v in DONATION_LIMITS_EUR_USD.items()},
@@ -399,11 +420,12 @@ def __missing__(self, currency):
 
 
 def make_standard_tip(label, weekly, currency):
+    minimum = Money.MINIMUMS[currency].amount if hasattr(Money, 'MINIMUMS') else D_CENT
     return StandardTip(
         label,
         Money(weekly, currency),
-        Money(weekly / PERIOD_CONVERSION_RATES['monthly'], currency),
-        Money(weekly / PERIOD_CONVERSION_RATES['yearly'], currency),
+        Money((weekly / PERIOD_CONVERSION_RATES['monthly']).quantize(minimum), currency),
+        Money((weekly / PERIOD_CONVERSION_RATES['yearly']).quantize(minimum), currency),
     )
 
 
@@ -411,7 +433,7 @@ class _StandardTips(defaultdict):
     def __missing__(self, currency):
         r = [
             make_standard_tip(
-                label, convert_symbolic_amount(weekly, currency, rounding=ROUND_UP), currency
+                label, convert_symbolic_amount(weekly, currency), currency
             ) for label, weekly in STANDARD_TIPS_EUR_USD
         ]
         self[currency] = r


Test patch:
diff --git a/tests/py/test_currencies.py b/tests/py/test_currencies.py
--- a/tests/py/test_currencies.py
+++ b/tests/py/test_currencies.py
@@ -4,7 +4,7 @@
 import pytest
 
 from liberapay.billing.transactions import swap_currencies, Transfer
-from liberapay.constants import CURRENCIES
+from liberapay.constants import CURRENCIES, DONATION_LIMITS, STANDARD_TIPS
 from liberapay.exceptions import NegativeBalance, TransferError
 from liberapay.i18n.currencies import Money, MoneyBasket
 from liberapay.payin.stripe import int_to_Money, Money_to_int
@@ -72,6 +72,33 @@ def test_MoneyBasket_comparisons(self):
         b2 = MoneyBasket(EUR=1, USD=1)
         assert not (b >= b2)
 
+    def test_donation_limits(self):
+        for currency in CURRENCIES:
+            currency_minimum = Money.MINIMUMS[currency]
+            currency_exponent = currency_minimum.amount.as_tuple()[2]
+            limits = DONATION_LIMITS[currency]
+            for period, (min_limit, max_limit) in limits.items():
+                print(period, min_limit, max_limit)
+                assert min_limit >= currency_minimum
+                assert max_limit > min_limit
+                assert min_limit.amount.as_tuple()[2] >= currency_exponent
+                assert max_limit.amount.as_tuple()[2] >= currency_exponent
+                if period == 'weekly':
+                    assert len(min_limit.amount.normalize().as_tuple().digits) <= 2
+
+    def test_standard_tips(self):
+        for currency in CURRENCIES:
+            minimum = Money.MINIMUMS[currency]
+            min_exponent = minimum.amount.as_tuple()[2]
+            standard_tips = STANDARD_TIPS[currency]
+            for st in standard_tips:
+                print(st)
+                assert st.weekly >= minimum
+                assert st.weekly.amount.as_tuple()[2] >= min_exponent
+                assert st.monthly.amount.as_tuple()[2] >= min_exponent
+                assert st.yearly.amount.as_tuple()[2] >= min_exponent
+                assert len(st.weekly.amount.normalize().as_tuple().digits) <= 2
+
 
 class TestCurrenciesInDB(Harness):