Repository: liberapay/liberapay.com

Issue:
Symbolic Donations Are Discouraging: Suggest to remove the not recommended warning
When trying to make a symbolic donation, there shouldn't be an option to choose a larger amount. It defeats the purpose of having the symbolic donation choice in the first place. Also the discouraging text should be removed from this tier:

![image](https://user-images.githubusercontent.com/45294513/89130149-a6815000-d4d0-11ea-9282-345f53758b29.png)


A warning text usually guides someone to the right choice, but in this case there is no right choice. 


Related to #975


Discussion:

Resolving patch:
diff --git a/liberapay/constants.py b/liberapay/constants.py
--- a/liberapay/constants.py
+++ b/liberapay/constants.py
@@ -64,11 +64,15 @@ def convert_symbolic_amount(amount, target_currency, precision=2, rounding=ROUND
 
 class MoneyAutoConvertDict(defaultdict):
 
-    def __init__(self, *args, **kw):
-        super(MoneyAutoConvertDict, self).__init__(None, *args, **kw)
+    def __init__(self, *args, precision=2):
+        super().__init__(None, *args)
+        self.precision = precision
 
     def __missing__(self, currency):
-        r = Money(convert_symbolic_amount(self['EUR'].amount, currency, 1), currency)
+        r = Money(
+            convert_symbolic_amount(self['EUR'].amount, currency, self.precision),
+            currency
+        )
         self[currency] = r
         return r
 
diff --git a/liberapay/payin/prospect.py b/liberapay/payin/prospect.py
--- a/liberapay/payin/prospect.py
+++ b/liberapay/payin/prospect.py
@@ -1,3 +1,5 @@
+from decimal import Decimal, ROUND_DOWN, ROUND_UP
+
 from ..constants import PAYIN_AMOUNTS, PERIOD_CONVERSION_MAP
 
 
@@ -8,9 +10,9 @@ class PayinProspect:
     __slots__ = (
         'tips', 'provider', 'currency', 'period',
         'one_periods_worth', 'one_weeks_worth', 'one_months_worth', 'one_years_worth',
-        'twelve_years_worth',
-        'min_acceptable_amount', 'moderate_fee_amount', 'max_acceptable_amount',
-        'min_proposed_amount', 'moderate_proposed_amount', 'low_fee_amount',
+        'twenty_years_worth',
+        'min_acceptable_amount', 'moderate_fee_amount', 'low_fee_amount', 'max_acceptable_amount',
+        'min_proposed_amount', 'moderate_proposed_amount', 'low_fee_proposed_amount',
         'suggested_amounts',
     )
 
@@ -40,9 +42,10 @@ def __init__(self, tips, provider):
             tip.periodic_amount * PERIOD_CONVERSION_MAP[(tip.period, 'yearly')]
             for tip in tips
         ).round()
-        self.twelve_years_worth = self.one_years_worth * 12
+        self.twenty_years_worth = self.one_years_worth * 20
         if self.period == 'weekly':
-            self.one_months_worth = (self.one_weeks_worth * 4).round()
+            # For weekly donations we round up the monthly amount to 5 weeks.
+            self.one_months_worth = (self.one_weeks_worth * 5).round()
         elif self.period == 'monthly':
             self.one_months_worth = self.one_periods_worth
         else:
@@ -59,39 +62,68 @@ def __init__(self, tips, provider):
         self.low_fee_amount = standard_amounts['low_fee'][self.currency]
         self.max_acceptable_amount = min(
             standard_amounts['max_acceptable'][self.currency],
-            self.twelve_years_worth
+            self.twenty_years_worth
         )
         self.min_proposed_amount = max(
-            self.min_acceptable_amount,
+            self.round_to_period(self.min_acceptable_amount, threshold=0),
             self.one_months_worth,
             self.one_periods_worth
         )
         self.moderate_proposed_amount = min(
             max(
-                self.moderate_fee_amount,
+                self.round_to_period(self.moderate_fee_amount),
                 self.one_months_worth,
                 self.one_periods_worth
             ),
-            self.twelve_years_worth
+            self.twenty_years_worth
         )
-        periodic_amounts = [
-            self.one_months_worth,
-            (self.one_years_worth / 4).round(),
-            (self.one_years_worth / 2).round(),
-            self.one_years_worth,
-        ]
-        recommended_amounts = [
-            a for a in periodic_amounts if (
-                a >= self.low_fee_amount and
-                a >= self.moderate_proposed_amount * 2 and
-                a <= self.max_acceptable_amount
-            )
+        self.low_fee_proposed_amount = min(
+            max(
+                self.round_to_period(self.low_fee_amount),
+                self.moderate_proposed_amount * 2
+            ),
+            self.max_acceptable_amount
+        )
+        if self.period == 'yearly':
+            suggested_amounts = [
+                self.one_years_worth,
+                self.one_years_worth * 2,
+            ]
+        else:
+            suggested_amounts = [
+                self.one_months_worth,
+                (self.one_years_worth / 4).round(),
+                (self.one_years_worth / 2).round(),
+                self.one_years_worth,
+            ]
+        if suggested_amounts[-1] < self.low_fee_proposed_amount:
+            if suggested_amounts[-1] < self.moderate_proposed_amount:
+                suggested_amounts.append(self.moderate_proposed_amount)
+            suggested_amounts.append(self.low_fee_proposed_amount)
+        suggested_amounts = [
+            a for a in suggested_amounts
+            if a >= self.moderate_proposed_amount and a <= self.max_acceptable_amount
         ]
-        self.suggested_amounts = sorted(set((
+        suggested_amounts = sorted(set((
             self.min_proposed_amount,
-            self.moderate_proposed_amount,
-            *recommended_amounts
+            *suggested_amounts
         )))
+        self.suggested_amounts = [
+            suggested_amounts[i] for i in range(len(suggested_amounts))
+            if i == 0 or suggested_amounts[i] >= (suggested_amounts[i-1] * Decimal('1.5'))
+        ]
+
+    def round_to_period(self, amount, threshold=Decimal('0.15')):
+        """Return the periodic amount “closest” to the given target amount.
+
+        The `threshold` argument controls the rounding behavior. The default
+        value strongly favors rounding up instead of down.
+        """
+        n_periods = amount / self.one_periods_worth
+        n_periods = n_periods.to_integral_value(rounding=(
+            ROUND_UP if n_periods % 1 > threshold else ROUND_DOWN
+        ))
+        return self.one_periods_worth * n_periods
 
     # The properties below exist because Jinja doesn't support list comprehensions.
 


Test patch:
diff --git a/tests/py/test_elsewhere.py b/tests/py/test_elsewhere.py
--- a/tests/py/test_elsewhere.py
+++ b/tests/py/test_elsewhere.py
@@ -273,7 +273,7 @@ def test_patrons_are_charged_after_pledgee_joins(self):
             "SELECT * FROM scheduled_payins WHERE payer = %s", (bob.id,)
         )
         assert len(scheduled_payins) == 1
-        assert scheduled_payins[0].amount == EUR('400.00')
+        assert scheduled_payins[0].amount == EUR('500.00')
         assert scheduled_payins[0].automatic is True
 
         # check that the notification isn't sent again
diff --git a/tests/py/test_payins.py b/tests/py/test_payins.py
--- a/tests/py/test_payins.py
+++ b/tests/py/test_payins.py
@@ -189,11 +189,11 @@ def test_minimum_weekly_EUR_tip(self):
         assert pp.period == 'weekly'
         assert pp.one_periods_worth == tip_amount
         assert pp.one_weeks_worth == tip_amount
-        assert pp.one_months_worth == tip_amount * 4
+        assert pp.one_months_worth == tip_amount * 5
         assert pp.one_years_worth == tip_amount * 52
-        assert pp.twelve_years_worth == pp.one_years_worth * 12
+        assert pp.twenty_years_worth == pp.one_years_worth * 20
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['EUR']
-        assert pp.suggested_amounts == [pp.min_acceptable_amount, pp.twelve_years_worth]
+        assert pp.suggested_amounts == [EUR('2.00'), EUR('10.00')]
 
     def test_minimum_monthly_EUR_tip(self):
         tip_amount = DONATION_LIMITS['EUR']['monthly'][0]
@@ -205,9 +205,9 @@ def test_minimum_monthly_EUR_tip(self):
         assert pp.one_weeks_worth == DONATION_LIMITS['EUR']['weekly'][0]
         assert pp.one_months_worth == tip_amount
         assert pp.one_years_worth == tip_amount * 12
-        assert pp.twelve_years_worth == pp.one_years_worth * 12
+        assert pp.twenty_years_worth == pp.one_years_worth * 20
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['EUR']
-        assert pp.suggested_amounts == [pp.min_acceptable_amount, pp.twelve_years_worth]
+        assert pp.suggested_amounts == [EUR('2.00'), EUR('10.00')]
 
     def test_minimum_yearly_EUR_tip(self):
         tip_amount = DONATION_LIMITS['EUR']['yearly'][0]
@@ -219,9 +219,9 @@ def test_minimum_yearly_EUR_tip(self):
         assert pp.one_weeks_worth == DONATION_LIMITS['EUR']['weekly'][0]
         assert pp.one_months_worth == (tip_amount / 12).round()
         assert pp.one_years_worth == tip_amount
-        assert pp.twelve_years_worth == pp.one_years_worth * 12
+        assert pp.twenty_years_worth == pp.one_years_worth * 20
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['EUR']
-        assert pp.suggested_amounts == [pp.min_acceptable_amount, pp.twelve_years_worth]
+        assert pp.suggested_amounts == [pp.min_proposed_amount, pp.twenty_years_worth]
 
     def test_small_weekly_USD_tip(self):
         tip_amount = STANDARD_TIPS['USD'][1].weekly
@@ -231,10 +231,10 @@ def test_small_weekly_USD_tip(self):
         assert pp.period == 'weekly'
         assert pp.one_periods_worth == tip_amount
         assert pp.one_weeks_worth == tip_amount
-        assert pp.one_months_worth == tip_amount * 4
+        assert pp.one_months_worth == tip_amount * 5
         assert pp.one_years_worth == tip_amount * 52
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['USD']
-        assert pp.suggested_amounts == [USD('2.00'), USD('12.00')]
+        assert pp.suggested_amounts == [USD('2.00'), USD('13.00'), USD('48.00')]
 
     def test_small_monthly_USD_tip(self):
         tip_amount = USD('1.00')
@@ -247,7 +247,7 @@ def test_small_monthly_USD_tip(self):
         assert pp.one_months_worth == tip_amount
         assert pp.one_years_worth == tip_amount * 12
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['USD']
-        assert pp.suggested_amounts == [USD('2.00'), USD('12.00')]
+        assert pp.suggested_amounts == [USD('2.00'), USD('12.00'), USD('48.00')]
 
     def test_small_yearly_USD_tip(self):
         tip_amount = USD('10.00')
@@ -260,7 +260,9 @@ def test_small_yearly_USD_tip(self):
         assert pp.one_months_worth == USD('0.83')
         assert pp.one_years_worth == tip_amount
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['USD']
-        assert pp.suggested_amounts == [USD('10.00'), USD('12.00')]
+        assert pp.suggested_amounts == [
+            USD('10.00'), USD('20.00'), USD('50.00')
+        ]
 
     def test_medium_weekly_JPY_tip(self):
         tip_amount = STANDARD_TIPS['JPY'][2].weekly
@@ -270,10 +272,12 @@ def test_medium_weekly_JPY_tip(self):
         assert pp.period == 'weekly'
         assert pp.one_periods_worth == tip_amount
         assert pp.one_weeks_worth == tip_amount
-        assert pp.one_months_worth == tip_amount * 4
+        assert pp.one_months_worth == tip_amount * 5
         assert pp.one_years_worth == tip_amount * 52
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['JPY']
-        assert pp.suggested_amounts == [JPY('520'), JPY('1000'), JPY('6760')]
+        assert pp.suggested_amounts == [
+            JPY('650'), JPY('1690'), JPY('3380'), JPY('6760')
+        ]
 
     def test_medium_monthly_JPY_tip(self):
         tip_amount = JPY('500')
@@ -286,7 +290,9 @@ def test_medium_monthly_JPY_tip(self):
         assert pp.one_months_worth == tip_amount
         assert pp.one_years_worth == tip_amount * 12
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['JPY']
-        assert pp.suggested_amounts == [JPY('500'), JPY('1000'), JPY('6000')]
+        assert pp.suggested_amounts == [
+            JPY('500'), JPY('1500'), JPY('3000'), JPY('6000')
+        ]
 
     def test_medium_yearly_JPY_tip(self):
         tip_amount = JPY('5000')
@@ -299,7 +305,7 @@ def test_medium_yearly_JPY_tip(self):
         assert pp.one_months_worth == JPY('417')
         assert pp.one_years_worth == tip_amount
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['JPY']
-        assert pp.suggested_amounts == [JPY('5000')]
+        assert pp.suggested_amounts == [JPY('5000'), JPY('10000')]
 
     def test_large_weekly_EUR_tip(self):
         tip_amount = STANDARD_TIPS['EUR'][3].weekly
@@ -309,10 +315,10 @@ def test_large_weekly_EUR_tip(self):
         assert pp.period == 'weekly'
         assert pp.one_periods_worth == tip_amount
         assert pp.one_weeks_worth == tip_amount
-        assert pp.one_months_worth == tip_amount * 4
+        assert pp.one_months_worth == tip_amount * 5
         assert pp.one_years_worth == tip_amount * 52
         assert pp.suggested_amounts == [
-            EUR('20.00'), EUR('65.00'), EUR('130.00'), EUR('260.00')
+            EUR('25.00'), EUR('65.00'), EUR('130.00'), EUR('260.00')
         ]
 
     def test_large_monthly_EUR_tip(self):
@@ -339,7 +345,7 @@ def test_large_yearly_EUR_tip(self):
         assert pp.one_weeks_worth == EUR('9.61')
         assert pp.one_months_worth == EUR('41.67')
         assert pp.one_years_worth == tip_amount
-        assert pp.suggested_amounts == [EUR('500.00')]
+        assert pp.suggested_amounts == [EUR('500.00'), EUR('1000.00')]
 
     def test_two_small_monthly_USD_tips(self):
         tip_amount = USD('1.00')
@@ -353,7 +359,9 @@ def test_two_small_monthly_USD_tips(self):
         assert pp.one_months_worth == pp.one_periods_worth
         assert pp.one_years_worth == tip_amount * 24
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['USD']
-        assert pp.suggested_amounts == [USD('2.00'), USD('12.00')]
+        assert pp.suggested_amounts == [
+            USD('2.00'), USD('12.00'), USD('24.00'), USD('48.00')
+        ]
 
     def test_two_medium_yearly_KRW_tips(self):
         tip_amount = KRW('50000')
@@ -367,7 +375,7 @@ def test_two_medium_yearly_KRW_tips(self):
         assert pp.one_months_worth == KRW('8333')
         assert pp.one_years_worth == pp.one_periods_worth
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['KRW']
-        assert pp.suggested_amounts == [KRW('100000')]
+        assert pp.suggested_amounts == [KRW('100000'), KRW('200000')]
 
     def test_two_very_different_EUR_tips(self):
         tip1 = self.alice.set_tip_to(self.bob, EUR('0.24'), period='weekly')
@@ -377,11 +385,11 @@ def test_two_very_different_EUR_tips(self):
         assert pp.period == 'weekly'
         assert pp.one_periods_worth == EUR('4.86')
         assert pp.one_weeks_worth == pp.one_weeks_worth
-        assert pp.one_months_worth == pp.one_weeks_worth * 4
+        assert pp.one_months_worth == pp.one_weeks_worth * 5
         assert pp.one_years_worth == EUR('252.48')
         assert pp.low_fee_amount == PAYIN_AMOUNTS['stripe']['low_fee']['EUR']
         assert pp.suggested_amounts == [
-            EUR('19.40'), EUR('63.12'), EUR('126.24'), EUR('252.48')
+            EUR('24.25'), EUR('63.12'), EUR('126.24'), EUR('252.48')
         ]
 
     def test_three_very_different_EUR_tips(self):
diff --git a/tests/py/test_schedule.py b/tests/py/test_schedule.py
--- a/tests/py/test_schedule.py
+++ b/tests/py/test_schedule.py
@@ -345,7 +345,7 @@ def test_newly_scheduled_automatic_payments_are_at_least_a_week_away(self):
         # At this point we should have an automatic renewal scheduled one week from now
         scheduled_payins = self.db.all("SELECT * FROM scheduled_payins")
         assert len(scheduled_payins) == 1
-        assert scheduled_payins[0].amount == EUR('10.00')
+        assert scheduled_payins[0].amount == EUR('10.12')
         assert scheduled_payins[0].automatic is True
         payment_timedelta = scheduled_payins[0].execution_date - utcnow().date()
         assert payment_timedelta.days in (6, 7)
@@ -384,7 +384,7 @@ def test_late_manual_payment_switched_to_automatic_is_scheduled_a_week_away(self
         assert tip.renewal_mode == 2
         scheduled_payins = self.db.all("SELECT * FROM scheduled_payins WHERE payin IS NULL")
         assert len(scheduled_payins) == 1
-        assert scheduled_payins[0].amount == EUR('10.00')
+        assert scheduled_payins[0].amount == EUR('10.12')
         assert scheduled_payins[0].automatic is True
         payment_timedelta = scheduled_payins[0].execution_date - utcnow().date()
         assert payment_timedelta.days in (6, 7)