Repository: liberapay/liberapay.com

Issue:
Liberapay asks to renew a donation even though it's not time yet
![image](https://user-images.githubusercontent.com/14316128/88177174-1a9a3900-cc31-11ea-8461-48e19bba4455.png)

The title says it. I've received an email asking to renew the donation even though I've just started it a week ago and the payment already got through. Clicking the green button promts to pay the donation on the picture, even though it says "due in two weeks".


Discussion:

Resolving patch:
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -2592,7 +2592,7 @@ def find_partial_match(new_sp, current_schedule_map):
 
             # Get renewable tips
             renewable_tips = cursor.all("""
-                SELECT t.*, tippee_p
+                SELECT t.*::tips, tippee_p
                   FROM current_tips t
                   JOIN participants tippee_p ON tippee_p.id = t.tippee
                  WHERE t.tipper = %s
@@ -2615,6 +2615,9 @@ def find_partial_match(new_sp, current_schedule_map):
                        )
               ORDER BY t.tippee
             """, (self.id,))
+            for tip, tippee_p in renewable_tips:
+                tip.tippee_p = tippee_p
+            renewable_tips = [tip for tip, tippee_p in renewable_tips]
 
             # Get the existing schedule
             current_schedule = cursor.all("""
@@ -2677,28 +2680,7 @@ def find_partial_match(new_sp, current_schedule_map):
             next_payday = compute_next_payday_date()
             tip_groups = defaultdict(list)
             for tip in renewable_tips:
-                tip.weeks_left = int(tip.paid_in_advance // tip.amount)
-                if tip.weeks_left == 0:
-                    last_transfer_date = cursor.one("""
-                        SELECT tr.timestamp::date
-                          FROM transfers tr
-                         WHERE tr.tipper = %s
-                           AND coalesce(tr.team, tr.tippee) = %s
-                           AND tr.context IN ('tip', 'take')
-                      ORDER BY tr.timestamp DESC
-                         LIMIT 1
-                    """, (tip.tipper, tip.tippee)) or cursor.one("""
-                        SELECT pt.ctime::date
-                          FROM payin_transfers pt
-                         WHERE pt.payer = %s
-                           AND coalesce(pt.team, pt.recipient) = %s
-                           AND pt.context IN ('personal-donation', 'team-donation')
-                      ORDER BY pt.ctime DESC
-                         LIMIT 1
-                    """, (tip.tipper, tip.tippee))
-                    tip.due_date = (last_transfer_date or next_payday) + timedelta(weeks=1)
-                else:
-                    tip.due_date = next_payday + timedelta(weeks=tip.weeks_left - 1)
+                tip.due_date = tip.compute_renewal_due_date(next_payday, cursor)
                 renewal_quarter = tip.weeks_left // 13
                 tip_groups[(tip.renewal_mode, tip.amount.currency, renewal_quarter)].append(tip)
             del renewable_tips
diff --git a/liberapay/models/tip.py b/liberapay/models/tip.py
--- a/liberapay/models/tip.py
+++ b/liberapay/models/tip.py
@@ -1,3 +1,5 @@
+from datetime import timedelta
+
 from cached_property import cached_property
 from postgres.orm import Model
 
@@ -19,6 +21,8 @@ def __getitem__(self, key):
         except AttributeError:
             raise KeyError(key)
 
+    __setattr__ = object.__setattr__
+
     def __setitem__(self, key, value):
         try:
             object.__setattr__(self, key, value)
@@ -35,6 +39,37 @@ def _asdict(self):
         # For compatibility with namedtuple classes
         return self.__dict__.copy()
 
+    def compute_renewal_due_date(self, next_payday, cursor=None):
+        if not cursor:
+            cursor = self.db
+        weeks_left = self.weeks_left
+        if weeks_left is None:
+            return
+        if weeks_left == 0:
+            last_transfer_date = cursor.one("""
+                SELECT tr.timestamp::date
+                  FROM transfers tr
+                 WHERE tr.tipper = %s
+                   AND coalesce(tr.team, tr.tippee) = %s
+                   AND tr.context IN ('tip', 'take')
+              ORDER BY tr.timestamp DESC
+                 LIMIT 1
+            """, (self.tipper, self.tippee)) or cursor.one("""
+                SELECT pt.ctime::date
+                  FROM payin_transfers pt
+                 WHERE pt.payer = %s
+                   AND coalesce(pt.team, pt.recipient) = %s
+                   AND pt.context IN ('personal-donation', 'team-donation')
+              ORDER BY pt.ctime DESC
+                 LIMIT 1
+            """, (self.tipper, self.tippee))
+            if last_transfer_date:
+                return last_transfer_date + timedelta(weeks=1)
+            else:
+                return next_payday
+        else:
+            return next_payday + timedelta(weeks=weeks_left)
+
     def for_json(self):
         return {k: v for k, v in self.__dict__.items() if not isinstance(v, self.db.Participant)}
 
@@ -60,3 +95,9 @@ def tippee_p(self):
     @cached_property
     def tipper_p(self):
         return self.db.one("SELECT p FROM participants p WHERE p.id = %s", (self.tipper,))
+
+    @cached_property
+    def weeks_left(self):
+        if self.paid_in_advance is None:
+            return
+        return int(self.paid_in_advance // self.amount)
diff --git a/liberapay/payin/cron.py b/liberapay/payin/cron.py
--- a/liberapay/payin/cron.py
+++ b/liberapay/payin/cron.py
@@ -4,6 +4,7 @@
 
 from pando import json
 
+from ..billing.payday import compute_next_payday_date
 from ..cron import logger
 from ..exceptions import AccountSuspended, NextAction
 from ..i18n.currencies import Money
@@ -94,7 +95,19 @@ def send_donation_reminder_notifications():
                    SELECT sp.id, sp.execution_date, sp.amount, sp.transfers
                ) a ORDER BY a.execution_date)) AS payins
           FROM scheduled_payins sp
-         WHERE sp.execution_date <= (current_date + interval '14 days')
+     LEFT JOIN LATERAL (
+                   SELECT pi.*
+                     FROM payins pi
+                    WHERE pi.payer = sp.payer
+                      AND NOT pi.off_session
+                 ORDER BY pi.ctime DESC
+                    LIMIT 1
+               ) last_payin ON true
+         WHERE sp.execution_date <= (current_date + (CASE
+                   WHEN last_payin.ctime >= (current_date - interval '14 days')
+                   THEN interval '7 days'
+                   ELSE interval '14 days'
+               END))
            AND sp.automatic IS NOT true
            AND sp.payin IS NULL
            AND sp.ctime < (current_timestamp - interval '6 hours')
@@ -105,6 +118,7 @@ def send_donation_reminder_notifications():
                       OR sp.notifs_count = 2 AND sp.last_notif_ts <= (current_date - interval '26 weeks')
                ) > 0
     """)
+    next_payday = compute_next_payday_date()
     for payer, payins in rows:
         if payer.is_suspended or payer.status != 'active':
             continue
@@ -114,11 +128,14 @@ def send_donation_reminder_notifications():
         donations = []
         for sp in payins:
             for tr in sp['transfers']:
+                tip = tr['tip']
                 donations.append({
-                    'periodic_amount': tr['tip'].periodic_amount,
+                    'due_date': tip.compute_renewal_due_date(next_payday),
+                    'period': tip.period,
+                    'periodic_amount': tip.periodic_amount,
                     'tippee_username': tr['tippee_username'],
                 })
-        payer.notify('donate_reminder', donations=donations, email_unverified_address=True)
+        payer.notify('donate_reminder~v2', donations=donations, email_unverified_address=True)
         counts['donate_reminder'] += 1
         db.run("""
             UPDATE scheduled_payins


Test patch:
diff --git a/tests/py/test_payday.py b/tests/py/test_payday.py
--- a/tests/py/test_payday.py
+++ b/tests/py/test_payday.py
@@ -337,8 +337,6 @@ def test_payday_handles_paid_in_advance(self):
         self.janet.set_tip_to(team, EUR('0.40'))
         self.janet.distribute_balances_to_donees()
 
-        self.db.run("UPDATE scheduled_payins SET ctime = ctime - interval '12 hours'")
-
         # Preliminary checks
         janet = self.janet.refetch()
         assert janet.balance == 0
@@ -359,6 +357,11 @@ def test_payday_handles_paid_in_advance(self):
         assert transfers[1].context == 'take-in-advance'
 
         # Now run a payday and check the results
+        self.db.run("""
+            UPDATE scheduled_payins
+               SET ctime = ctime - interval '12 hours'
+                 , execution_date = current_date
+        """)
         Payday.start().run()
         tips = self.db.all("""
             SELECT *
@@ -1067,7 +1070,11 @@ def test_it_notifies_participants(self):
         self.make_payin_and_transfer(janet_card, team, EUR('25.00'))
         self.client.PxST('/homer/emails/', auth_as=self.homer,
                          data={'events': 'income', 'income': ''}, xhr=True)
-        self.db.run("UPDATE scheduled_payins SET ctime = ctime - interval '12 hours'")
+        self.db.run("""
+            UPDATE scheduled_payins
+               SET ctime = ctime - interval '12 hours'
+                 , execution_date = execution_date - interval '2 weeks'
+        """)
         Payday.start().run()
         emails = self.get_emails()
         assert len(emails) == 2
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
@@ -40,7 +40,7 @@ def test_schedule_renewals(self):
         assert len(new_schedule) == 1
         assert new_schedule[0].amount is None
         assert new_schedule[0].transfers == expected_transfers
-        assert new_schedule[0].execution_date == (next_payday + timedelta(weeks=49))
+        assert new_schedule[0].execution_date == (next_payday + timedelta(weeks=50))
         assert new_schedule[0].automatic is False
         # check idempotency
         new_schedule2 = alice.schedule_renewals()
@@ -52,7 +52,7 @@ def test_schedule_renewals(self):
         assert len(new_schedule) == 1
         assert new_schedule[0].amount is None
         assert new_schedule[0].transfers == expected_transfers
-        assert new_schedule[0].execution_date == (next_payday + timedelta(weeks=24))
+        assert new_schedule[0].execution_date == (next_payday + timedelta(weeks=25))
         assert new_schedule[0].automatic is False
         # enable automatic renewal for this donation
         alice.set_tip_to(bob, EUR('0.99'), renewal_mode=2)
@@ -61,7 +61,7 @@ def test_schedule_renewals(self):
         assert len(new_schedule) == 1
         assert new_schedule[0].amount == EUR('49.50')
         assert new_schedule[0].transfers == expected_transfers
-        assert new_schedule[0].execution_date == (next_payday + timedelta(weeks=49))
+        assert new_schedule[0].execution_date == (next_payday + timedelta(weeks=50))
         assert new_schedule[0].automatic is True
 
     def test_schedule_renewals_notifies_payer_of_changes(self):
@@ -82,11 +82,15 @@ def test_schedule_renewals_notifies_payer_of_changes(self):
         assert len(new_schedule) == 1
         assert new_schedule[0].amount == EUR('2.00')
         assert new_schedule[0].transfers == expected_transfers
-        expected_renewal_date = next_payday + timedelta(weeks=1)
+        expected_renewal_date = next_payday + timedelta(weeks=2)
         assert new_schedule[0].execution_date == expected_renewal_date
         assert new_schedule[0].automatic is True
         # Trigger the initial "upcoming charge" notification
-        self.db.run("UPDATE scheduled_payins SET ctime = ctime - interval '12 hours'")
+        self.db.run("""
+            UPDATE scheduled_payins
+               SET ctime = ctime - interval '12 hours'
+                 , execution_date = current_date
+        """)
         send_upcoming_debit_notifications()
         emails = self.get_emails()
         assert len(emails) == 1
@@ -94,6 +98,8 @@ def test_schedule_renewals_notifies_payer_of_changes(self):
         assert emails[0]['subject'] == 'Liberapay donation renewal: upcoming debit of €2.00'
         sp = self.db.one("SELECT * FROM scheduled_payins")
         assert sp.notifs_count == 1
+        self.db.run("UPDATE scheduled_payins SET execution_date = %s",
+                    (expected_renewal_date,))
         # Tweak the donation amount. The renewal shouldn't be pushed back and
         # the payer shouldn't be notified.
         alice.set_tip_to(bob, EUR('0.50'))
@@ -112,7 +118,7 @@ def test_schedule_renewals_notifies_payer_of_changes(self):
         assert len(new_schedule) == 1
         assert new_schedule[0].amount == EUR('2.00')
         assert new_schedule[0].transfers == expected_transfers
-        expected_renewal_date = next_payday + timedelta(weeks=19)
+        expected_renewal_date = next_payday + timedelta(weeks=20)
         assert new_schedule[0].execution_date == expected_renewal_date
         assert new_schedule[0].automatic is True
         emails = self.get_emails()
@@ -151,11 +157,15 @@ def test_schedule_renewals_finds_partial_matches(self):
         assert len(new_schedule) == 1
         assert new_schedule[0].amount == EUR('5.00')
         assert new_schedule[0].transfers == expected_transfers
-        expected_renewal_date = next_payday + timedelta(weeks=1)
+        expected_renewal_date = next_payday + timedelta(weeks=2)
         assert new_schedule[0].execution_date == expected_renewal_date
         assert new_schedule[0].automatic is True
         # Trigger the initial "upcoming charge" notification
-        self.db.run("UPDATE scheduled_payins SET ctime = ctime - interval '12 hours'")
+        self.db.run("""
+            UPDATE scheduled_payins
+               SET ctime = ctime - interval '12 hours'
+                 , execution_date = current_date - interval '6 days'
+        """)
         send_upcoming_debit_notifications()
         emails = self.get_emails()
         assert len(emails) == 1
@@ -163,6 +173,8 @@ def test_schedule_renewals_finds_partial_matches(self):
         assert emails[0]['subject'] == 'Liberapay donation renewal: upcoming debit of €5.00'
         sp = self.db.one("SELECT * FROM scheduled_payins WHERE payin IS NULL")
         assert sp.notifs_count == 1
+        self.db.run("UPDATE scheduled_payins SET execution_date = %s",
+                    (expected_renewal_date,))
         # Tweak the amount of the first donation. The renewal shouldn't be
         # pushed back and the payer shouldn't be notified.
         alice.set_tip_to(bob, EUR('0.50'))
@@ -182,7 +194,7 @@ def test_schedule_renewals_finds_partial_matches(self):
         assert new_schedule[0].amount == EUR('2.00')
         assert new_schedule[0].transfers == [expected_transfers[0]]
         previous_renewal_date = expected_renewal_date
-        expected_renewal_date = next_payday + timedelta(weeks=3)
+        expected_renewal_date = next_payday + timedelta(weeks=4)
         assert new_schedule[0].execution_date == expected_renewal_date
         assert new_schedule[0].automatic is True
         emails = self.get_emails()
@@ -398,7 +410,7 @@ def test_multiple_scheduled_payins(self):
 
         self.db.run("""
             UPDATE scheduled_payins
-               SET execution_date = (current_date + interval '14 days')
+               SET execution_date = (current_date + interval '7 days')
                  , ctime = (ctime - interval '12 hours')
         """)
         send_upcoming_debit_notifications()
@@ -664,7 +676,7 @@ def test_scheduled_payin_requiring_authentication(self):
 
         self.db.run("""
             UPDATE scheduled_payins
-               SET execution_date = (current_date + interval '14 days')
+               SET execution_date = (current_date + interval '7 days')
                  , ctime = (ctime - interval '12 hours')
         """)
         send_upcoming_debit_notifications()