Repository: liberapay/liberapay.com

Issue:
Duplicate payment scheduled and executed automatically despite preexisting pending debit
handle: tuxayo
permission to examine my account data: given

![Screenshot_2020-04-09_05-55-23](https://user-images.githubusercontent.com/2678215/78856349-d2ab2980-7a26-11ea-9d9f-4c2c00946763.png)

The huge planed payment in the past of 168€ seems to be a whole year of the fundings.
It was already renewed yesterday with a ~45 € debit initialed to cover ~3months.
I was many months late in the renewal.

Is the result on the screenshot expected?
It mostly about display and confusion. It's clear to me that even with automated payments enabled since yesterday, no huge debit will be made.


Discussion:
The timing is perfect, after posting this message.I got and email telling me that a 168€ debit is planed.

Liberapay notification id: 87635

If stopping this could cause significant fees for Liberapay, we can let it pass. Which would mean that not automated payment will be triggered before more than a year right?
I see the problem: when you turned on automatic renewals, a new payment was scheduled, even though there was already a pending debit. Clearly the scheduler needs to be modified to check for pending payments, I just have to figure out the implementation details.

Stripe doesn't support cancelling SEPA direct debits, however once the payment has been settled I will be able to refund it.
Thanks for fixing the title ^^"
> Clearly the scheduler needs to be modified to check for pending payments, I just have to figure out the implementation details.

It's based on a one year period it seems. Is it based on my manual payments? Which is fine for the non groupable payments which is fine. Due to fees.
> Stripe doesn't support cancelling SEPA direct debits, however once the payment has been settled I will be able to refund it.

If the scheduler uses a one year period, then it won't change much, you can leave as it is. As long as Liberapay keeps track for this: 3 months + 1 year worth payments will be done.
For each donation, the scheduler uses the amount of the previous successful payment. When multiple donations are grouped, those amounts are simply summed.
> when you turned on automatic renewals, a new payment was scheduled

Note in case that helps, automatic renewals turned on many hours before this issue was open and the screenshot taken.
> For each donation, the scheduler uses the amount of the previous successful payment. When multiple donations are grouped, those amounts are simply summed.

ok, I'll try in the future to disable automatic renewal of my "EUR debit cluster" (if that's a good way to name it) to make one manual payment for 3 months and then switch back to automated.

I hope it don't cause chaos with this ^^"

Or maybe I hope so! Bugs are better be found by people reporting them before they hit "innocent people" ^^
At 5:05 UTC+02:00 I got an email like (rough translation)
> [reminder that 168 € will be debited on the 14 february]


Should I open an issue about the date being wrong?

Resolving patch:
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -2562,6 +2562,13 @@ def find_partial_match(new_sp, current_schedule_map):
                    AND ( tippee_p.goal IS NULL OR tippee_p.goal >= 0 )
                    AND tippee_p.is_suspended IS NOT TRUE
                    AND tippee_p.payment_providers > 0
+                   AND NOT EXISTS (
+                           SELECT 1
+                             FROM payin_transfers pt
+                            WHERE pt.payer = t.tipper
+                              AND coalesce(pt.team, pt.recipient) = t.tippee
+                              AND pt.status = 'pending'
+                       )
               ORDER BY t.tippee
             """, (self.id,))
 
@@ -2741,12 +2748,18 @@ def find_partial_match(new_sp, current_schedule_map):
             deletions = list(current_schedule_map.values())
             del current_schedule_map
 
-            # Make sure any newly scheduled payment is at least a week away
-            ONE_WEEK = timedelta(weeks=1)
-            one_week_from_today = utcnow().date() + ONE_WEEK
+            # Make sure any newly scheduled automatic payment is at least a week away
+            one_week_from_today = utcnow().date() + timedelta(weeks=1)
             for new_sp in insertions:
-                if new_sp.execution_date < one_week_from_today:
-                    new_sp.execution_date += ONE_WEEK
+                if new_sp.automatic:
+                    if new_sp.execution_date < one_week_from_today:
+                        new_sp.execution_date = one_week_from_today
+                        new_sp.customized = True
+            for cur_sp, new_sp in updates:
+                if new_sp.automatic and not cur_sp.automatic:
+                    if new_sp.execution_date < one_week_from_today:
+                        new_sp.execution_date = one_week_from_today
+                        new_sp.customized = True
 
             # Upsert the new schedule
             notify = False
@@ -2757,18 +2770,24 @@ def find_partial_match(new_sp, current_schedule_map):
                 """, [(sp.id,) for sp in deletions])
                 new_ids = execute_values(cursor, """
                     INSERT INTO scheduled_payins
-                                (execution_date, payer, amount, transfers, automatic)
+                                (execution_date, payer, amount, transfers, automatic, customized)
                          VALUES %s
                       RETURNING id
                 """, [
                     (sp.execution_date, self.id, sp.amount, json.dumps(sp.transfers),
-                     sp.automatic)
+                     sp.automatic, getattr(sp, 'customized', None))
                     for sp in insertions
                 ], fetch=True)
                 for i, row in enumerate(new_ids):
                     insertions[i].id = row.id
                 for cur_sp, new_sp in updates:
                     new_sp.id = cur_sp.id
+                    if new_sp.automatic and not cur_sp.automatic:
+                        new_sp.notifs_count = 0
+                        new_sp.last_notif_ts = None
+                    else:
+                        new_sp.notifs_count = cur_sp.notifs_count
+                        new_sp.last_notif_ts = cur_sp.last_notif_ts
                 execute_batch(cursor, """
                     UPDATE scheduled_payins
                        SET amount = %s
@@ -2776,6 +2795,8 @@ def find_partial_match(new_sp, current_schedule_map):
                          , execution_date = %s
                          , automatic = %s
                          , customized = %s
+                         , notifs_count = %s
+                         , last_notif_ts = %s
                          , mtime = current_timestamp
                      WHERE id = %s
                 """, [
@@ -2784,6 +2805,8 @@ def find_partial_match(new_sp, current_schedule_map):
                      new_sp.execution_date,
                      new_sp.automatic,
                      getattr(new_sp, 'customized', None),
+                     new_sp.notifs_count,
+                     new_sp.last_notif_ts,
                      cur_sp.id)
                     for cur_sp, new_sp in updates
                 ])
@@ -2799,7 +2822,7 @@ def find_partial_match(new_sp, current_schedule_map):
                         for old_sp, new_sp in updates
                     )
                 )
-        new_schedule.sort(key=lambda sp: sp.execution_date)
+        new_schedule.sort(key=lambda sp: (sp.execution_date, getattr(sp, 'id', id(sp))))
 
         # Notify the donor of important changes in scheduled payments
         if notify:


Test patch:
diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py
--- a/tests/py/test_participant.py
+++ b/tests/py/test_participant.py
@@ -1,9 +1,7 @@
-from datetime import timedelta
 from http.cookies import SimpleCookie
 
 import pytest
 
-from liberapay.billing.payday import compute_next_payday_date
 from liberapay.constants import SESSION, SESSION_REFRESH
 from liberapay.exceptions import (
     BadAmount,
@@ -18,12 +16,9 @@
     UsernameIsEmpty,
     UsernameTooLong,
 )
-from liberapay.i18n.base import LOCALE_EN
 from liberapay.i18n.currencies import Money
 from liberapay.models.participant import NeedConfirmation, Participant
-from liberapay.payin.cron import send_upcoming_debit_notifications
 from liberapay.testing import EUR, USD, Harness
-from liberapay.testing.emails import EmailHarness
 from liberapay.utils.types import Object
 
 
@@ -526,215 +521,3 @@ def test_rs_returns_openstreetmap_url_for_stub_from_openstreetmap(self):
         stub = Participant.from_username(unclaimed.participant.username)
         actual = stub.resolve_stub()
         assert actual == "/on/openstreetmap/alice/"
-
-
-class TestDonationRenewalScheduling(EmailHarness):
-
-    def test_schedule_renewals(self):
-        alice = self.make_participant('alice')
-        bob = self.make_participant('bob')
-        # pre-test
-        new_schedule = alice.schedule_renewals()
-        assert new_schedule == []
-        # set up a donation
-        alice.set_tip_to(bob, EUR('0.99'))
-        new_schedule = alice.schedule_renewals()
-        assert new_schedule == []
-        # fund the donation
-        alice_card = self.upsert_route(alice, 'stripe-card')
-        self.make_payin_and_transfer(alice_card, bob, EUR('49.50'))
-        new_schedule = alice.schedule_renewals()
-        next_payday = compute_next_payday_date()
-        expected_transfers = [
-            {
-                'tippee_id': bob.id,
-                'tippee_username': 'bob',
-                'amount': None,
-            }
-        ]
-        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].automatic is False
-        # check idempotency
-        new_schedule2 = alice.schedule_renewals()
-        assert len(new_schedule2) == 1
-        assert new_schedule2[0].__dict__ == new_schedule[0].__dict__
-        # modify the donation amount
-        alice.set_tip_to(bob, EUR('1.98'))
-        new_schedule = alice.schedule_renewals()
-        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].automatic is False
-        # enable automatic renewal for this donation
-        alice.set_tip_to(bob, EUR('0.99'), renewal_mode=2)
-        expected_transfers[0]['amount'] = EUR('49.50')
-        new_schedule = alice.schedule_renewals()
-        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].automatic is True
-
-    def test_schedule_renewals_notifies_payer_of_changes(self):
-        alice = self.make_participant('alice', email='alice@liberapay.com')
-        bob = self.make_participant('bob', email='bob@liberapay.com')
-        alice.set_tip_to(bob, EUR('1.00'), renewal_mode=2)
-        alice_card = self.upsert_route(alice, 'stripe-card', address='pm_card_visa')
-        self.make_payin_and_transfer(alice_card, bob, EUR('2.00'))
-        new_schedule = alice.schedule_renewals()
-        next_payday = compute_next_payday_date()
-        expected_transfers = [
-            {
-                'tippee_id': bob.id,
-                'tippee_username': 'bob',
-                'amount': EUR('2.00'),
-            }
-        ]
-        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)
-        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'")
-        send_upcoming_debit_notifications()
-        emails = self.get_emails()
-        assert len(emails) == 1
-        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
-        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
-        # 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'))
-        new_schedule = alice.schedule_renewals()
-        assert len(new_schedule) == 1
-        assert new_schedule[0].amount == EUR('2.00')
-        assert new_schedule[0].transfers == expected_transfers
-        assert new_schedule[0].execution_date == expected_renewal_date
-        assert new_schedule[0].automatic is True
-        emails = self.get_emails()
-        assert not emails
-        # Lower the donation amount a lot more. This time the renewal should be
-        # rescheduled and the payer should be notified of that change.
-        alice.set_tip_to(bob, EUR('0.10'))
-        new_schedule = alice.schedule_renewals()
-        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)
-        assert new_schedule[0].execution_date == expected_renewal_date
-        assert new_schedule[0].automatic is True
-        emails = self.get_emails()
-        assert len(emails) == 1
-        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
-        assert emails[0]['subject'] == 'Liberapay donation renewal: your upcoming payment has changed'
-
-    def test_schedule_renewals_finds_partial_matches(self):
-        """
-        This test is designed to hit the `find_partial_match` function inside
-        `Participant.schedule_renewals`.
-        """
-        alice = self.make_participant('alice', email='alice@liberapay.com')
-        bob = self.make_participant('bob', email='bob@liberapay.com')
-        team = self.make_participant('team', kind='group', email='team@liberapay.com')
-        team.add_member(bob)
-        alice.set_tip_to(bob, EUR('1.00'), renewal_mode=2)
-        alice.set_tip_to(team, EUR('1.50'), renewal_mode=2)
-        alice_card = self.upsert_route(alice, 'stripe-card', address='pm_card_visa')
-        self.make_payin_and_transfer(alice_card, bob, EUR('2.00'))
-        self.make_payin_and_transfer(alice_card, team, EUR('3.00'))
-        new_schedule = alice.schedule_renewals()
-        next_payday = compute_next_payday_date()
-        expected_transfers = [
-            {
-                'tippee_id': bob.id,
-                'tippee_username': 'bob',
-                'amount': EUR('2.00'),
-            },
-            {
-                'tippee_id': team.id,
-                'tippee_username': 'team',
-                'amount': EUR('3.00'),
-            }
-        ]
-        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)
-        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'")
-        send_upcoming_debit_notifications()
-        emails = self.get_emails()
-        assert len(emails) == 1
-        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
-        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
-        # 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'))
-        new_schedule = alice.schedule_renewals()
-        assert len(new_schedule) == 1
-        assert new_schedule[0].amount == EUR('5.00')
-        assert new_schedule[0].transfers == expected_transfers
-        assert new_schedule[0].execution_date == expected_renewal_date
-        assert new_schedule[0].automatic is True
-        emails = self.get_emails()
-        assert not emails
-        # Stop the second donation. The renewal should be rescheduled and the
-        # payer should be notified of that change.
-        alice.stop_tip_to(team)
-        new_schedule = alice.schedule_renewals()
-        assert len(new_schedule) == 1
-        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)
-        assert new_schedule[0].execution_date == expected_renewal_date
-        assert new_schedule[0].automatic is True
-        emails = self.get_emails()
-        assert len(emails) == 1
-        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
-        assert emails[0]['subject'] == 'Liberapay donation renewal: your upcoming payment has changed'
-        expected_sentence = LOCALE_EN.format(
-            "The payment of €5.00 scheduled for {old_date} has been replaced by "
-            "a payment of €2.00 on {new_date}.",
-            old_date=previous_renewal_date, new_date=expected_renewal_date
-        )
-        assert expected_sentence in emails[0]['text']
-
-    def test_schedule_renewals_does_not_group_payments_years_apart(self):
-        alice = self.make_participant('alice', email='alice@liberapay.com')
-        bob = self.make_participant('bob')
-        carl = self.make_participant('carl')
-        dana = self.make_participant('dana')
-        alice.set_tip_to(bob, EUR('4.20'), renewal_mode=2)
-        alice_card = self.upsert_route(alice, 'stripe-card', address='pm_card_visa')
-        self.make_payin_and_transfer(alice_card, bob, EUR('42.00'))
-        alice.set_tip_to(carl, EUR('0.04'), renewal_mode=2)
-        self.make_payin_and_transfer(alice_card, carl, EUR('24.00'))
-        alice.set_tip_to(dana, EUR('0.75'), renewal_mode=2)
-        self.make_payin_and_transfer(alice_card, dana, EUR('7.50'))
-        scheduled_payins = alice.schedule_renewals()
-        assert len(scheduled_payins) == 2
-        assert scheduled_payins[0].amount == EUR('49.50')
-        assert scheduled_payins[1].amount == EUR('24.00')
-
-    def test_schedule_renewals_marks_paypal_payment_as_manual(self):
-        alice = self.make_participant('alice', email='alice@liberapay.com')
-        bob = self.make_participant('bob')
-        alice.set_tip_to(bob, EUR('5.99'), renewal_mode=2)
-        alice_card = self.upsert_route(alice, 'paypal')
-        self.make_payin_and_transfer(alice_card, bob, EUR('60.00'))
-        scheduled_payins = alice.schedule_renewals()
-        assert len(scheduled_payins) == 1
-        assert scheduled_payins[0].amount is None
-        assert scheduled_payins[0].automatic is False
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
@@ -1,5 +1,10 @@
 from datetime import timedelta
 
+from pando.utils import utcnow
+
+from liberapay.billing.payday import compute_next_payday_date
+from liberapay.i18n.base import LOCALE_EN
+from liberapay.payin.common import update_payin_transfer
 from liberapay.payin.cron import (
     execute_scheduled_payins,
     send_donation_reminder_notifications, send_upcoming_debit_notifications,
@@ -8,6 +13,302 @@
 from liberapay.testing.emails import EmailHarness
 
 
+class TestDonationRenewalScheduling(EmailHarness):
+
+    def test_schedule_renewals(self):
+        alice = self.make_participant('alice')
+        bob = self.make_participant('bob')
+        # pre-test
+        new_schedule = alice.schedule_renewals()
+        assert new_schedule == []
+        # set up a donation
+        alice.set_tip_to(bob, EUR('0.99'))
+        new_schedule = alice.schedule_renewals()
+        assert new_schedule == []
+        # fund the donation
+        alice_card = self.upsert_route(alice, 'stripe-card')
+        self.make_payin_and_transfer(alice_card, bob, EUR('49.50'))
+        new_schedule = alice.schedule_renewals()
+        next_payday = compute_next_payday_date()
+        expected_transfers = [
+            {
+                'tippee_id': bob.id,
+                'tippee_username': 'bob',
+                'amount': None,
+            }
+        ]
+        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].automatic is False
+        # check idempotency
+        new_schedule2 = alice.schedule_renewals()
+        assert len(new_schedule2) == 1
+        assert new_schedule2[0].__dict__ == new_schedule[0].__dict__
+        # modify the donation amount
+        alice.set_tip_to(bob, EUR('1.98'))
+        new_schedule = alice.schedule_renewals()
+        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].automatic is False
+        # enable automatic renewal for this donation
+        alice.set_tip_to(bob, EUR('0.99'), renewal_mode=2)
+        expected_transfers[0]['amount'] = EUR('49.50')
+        new_schedule = alice.schedule_renewals()
+        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].automatic is True
+
+    def test_schedule_renewals_notifies_payer_of_changes(self):
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob', email='bob@liberapay.com')
+        alice.set_tip_to(bob, EUR('1.00'), renewal_mode=2)
+        alice_card = self.upsert_route(alice, 'stripe-card', address='pm_card_visa')
+        self.make_payin_and_transfer(alice_card, bob, EUR('2.00'))
+        new_schedule = alice.schedule_renewals()
+        next_payday = compute_next_payday_date()
+        expected_transfers = [
+            {
+                'tippee_id': bob.id,
+                'tippee_username': 'bob',
+                'amount': EUR('2.00'),
+            }
+        ]
+        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)
+        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'")
+        send_upcoming_debit_notifications()
+        emails = self.get_emails()
+        assert len(emails) == 1
+        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
+        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
+        # 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'))
+        new_schedule = alice.schedule_renewals()
+        assert len(new_schedule) == 1
+        assert new_schedule[0].amount == EUR('2.00')
+        assert new_schedule[0].transfers == expected_transfers
+        assert new_schedule[0].execution_date == expected_renewal_date
+        assert new_schedule[0].automatic is True
+        emails = self.get_emails()
+        assert not emails
+        # Lower the donation amount a lot more. This time the renewal should be
+        # rescheduled and the payer should be notified of that change.
+        alice.set_tip_to(bob, EUR('0.10'))
+        new_schedule = alice.schedule_renewals()
+        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)
+        assert new_schedule[0].execution_date == expected_renewal_date
+        assert new_schedule[0].automatic is True
+        emails = self.get_emails()
+        assert len(emails) == 1
+        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
+        assert emails[0]['subject'] == 'Liberapay donation renewal: your upcoming payment has changed'
+
+    def test_schedule_renewals_finds_partial_matches(self):
+        """
+        This test is designed to hit the `find_partial_match` function inside
+        `Participant.schedule_renewals`.
+        """
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob', email='bob@liberapay.com')
+        team = self.make_participant('team', kind='group', email='team@liberapay.com')
+        team.add_member(bob)
+        alice.set_tip_to(bob, EUR('1.00'), renewal_mode=2)
+        alice.set_tip_to(team, EUR('1.50'), renewal_mode=2)
+        alice_card = self.upsert_route(alice, 'stripe-card', address='pm_card_visa')
+        self.make_payin_and_transfer(alice_card, bob, EUR('2.00'))
+        self.make_payin_and_transfer(alice_card, team, EUR('3.00'))
+        new_schedule = alice.schedule_renewals()
+        next_payday = compute_next_payday_date()
+        expected_transfers = [
+            {
+                'tippee_id': bob.id,
+                'tippee_username': 'bob',
+                'amount': EUR('2.00'),
+            },
+            {
+                'tippee_id': team.id,
+                'tippee_username': 'team',
+                'amount': EUR('3.00'),
+            }
+        ]
+        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)
+        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'")
+        send_upcoming_debit_notifications()
+        emails = self.get_emails()
+        assert len(emails) == 1
+        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
+        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
+        # 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'))
+        new_schedule = alice.schedule_renewals()
+        assert len(new_schedule) == 1
+        assert new_schedule[0].amount == EUR('5.00')
+        assert new_schedule[0].transfers == expected_transfers
+        assert new_schedule[0].execution_date == expected_renewal_date
+        assert new_schedule[0].automatic is True
+        emails = self.get_emails()
+        assert not emails
+        # Stop the second donation. The renewal should be rescheduled and the
+        # payer should be notified of that change.
+        alice.stop_tip_to(team)
+        new_schedule = alice.schedule_renewals()
+        assert len(new_schedule) == 1
+        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)
+        assert new_schedule[0].execution_date == expected_renewal_date
+        assert new_schedule[0].automatic is True
+        emails = self.get_emails()
+        assert len(emails) == 1
+        assert emails[0]['to'][0] == 'alice <alice@liberapay.com>'
+        assert emails[0]['subject'] == 'Liberapay donation renewal: your upcoming payment has changed'
+        expected_sentence = LOCALE_EN.format(
+            "The payment of €5.00 scheduled for {old_date} has been replaced by "
+            "a payment of €2.00 on {new_date}.",
+            old_date=previous_renewal_date, new_date=expected_renewal_date
+        )
+        assert expected_sentence in emails[0]['text']
+
+    def test_schedule_renewals_does_not_group_payments_years_apart(self):
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob')
+        carl = self.make_participant('carl')
+        dana = self.make_participant('dana')
+        alice.set_tip_to(bob, EUR('4.20'), renewal_mode=2)
+        alice_card = self.upsert_route(alice, 'stripe-card', address='pm_card_visa')
+        self.make_payin_and_transfer(alice_card, bob, EUR('42.00'))
+        alice.set_tip_to(carl, EUR('0.04'), renewal_mode=2)
+        self.make_payin_and_transfer(alice_card, carl, EUR('24.00'))
+        alice.set_tip_to(dana, EUR('0.75'), renewal_mode=2)
+        self.make_payin_and_transfer(alice_card, dana, EUR('7.50'))
+        scheduled_payins = alice.schedule_renewals()
+        assert len(scheduled_payins) == 2
+        assert scheduled_payins[0].amount == EUR('49.50')
+        assert scheduled_payins[1].amount == EUR('24.00')
+
+    def test_schedule_renewals_marks_paypal_payment_as_manual(self):
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob')
+        alice.set_tip_to(bob, EUR('5.99'), renewal_mode=2)
+        alice_paypal = self.upsert_route(alice, 'paypal')
+        self.make_payin_and_transfer(alice_paypal, bob, EUR('60.00'))
+        scheduled_payins = alice.schedule_renewals()
+        assert len(scheduled_payins) == 1
+        assert scheduled_payins[0].amount is None
+        assert scheduled_payins[0].automatic is False
+
+    def test_no_new_renewal_is_scheduled_when_there_is_a_pending_transfer(self):
+        # Set up a funded manual donation
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob')
+        alice.set_tip_to(bob, EUR('4.16'), renewal_mode=1)
+        alice_sdd = self.upsert_route(alice, 'stripe-sdd')
+        self.make_payin_and_transfer(alice_sdd, bob, EUR('8.32'))
+        # At this point we should have a manual renewal scheduled in two weeks
+        scheduled_payins = alice.schedule_renewals()
+        assert len(scheduled_payins) == 1
+        assert scheduled_payins[0].amount is None
+        assert scheduled_payins[0].automatic is False
+        # Initiate a new payment. It should "fulfill" the scheduled renewal.
+        self.make_payin_and_transfer(alice_sdd, bob, EUR('77.00'), status='pending')
+        scheduled_payins = alice.schedule_renewals()
+        assert len(scheduled_payins) == 0
+        # Turn on automatic renewals. No new renewal should be scheduled since
+        # there is still a pending payment.
+        r = self.client.PxST("/alice/giving/", {"auto_renewal": "yes"}, auth_as=alice)
+        assert r.code == 302
+        tip = alice.get_tip_to(bob)
+        assert tip.renewal_mode == 2
+        scheduled_payins = self.db.all("SELECT * FROM scheduled_payins WHERE payin IS NULL")
+        assert len(scheduled_payins) == 0
+
+    def test_newly_scheduled_automatic_payments_are_at_least_a_week_away(self):
+        # Set up an automatic donation funded 4 weeks ago
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob')
+        alice.set_tip_to(bob, EUR('0.23'), renewal_mode=2)
+        alice_sdd = self.upsert_route(alice, 'stripe-sdd')
+        payin, pt = self.make_payin_and_transfer(alice_sdd, bob, EUR('0.22'), status='pending')
+        self.db.run("UPDATE payin_transfers SET ctime = ctime - interval '4 weeks'")
+        update_payin_transfer(self.db, pt.id, pt.remote_id, 'succeeded', None)
+        # 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].automatic is True
+        payment_timedelta = scheduled_payins[0].execution_date - utcnow().date()
+        assert payment_timedelta.days in (6, 7)
+        assert scheduled_payins[0].customized is True
+        # Running the scheduler again shouldn't change anything.
+        old_scheduled_payins = scheduled_payins
+        alice.schedule_renewals()
+        scheduled_payins = self.db.all("SELECT * FROM scheduled_payins")
+        assert old_scheduled_payins == scheduled_payins
+
+    def test_late_manual_payment_switched_to_automatic_is_scheduled_a_week_away(self):
+        # Set up a manual donation
+        alice = self.make_participant('alice', email='alice@liberapay.com')
+        bob = self.make_participant('bob')
+        alice.set_tip_to(bob, EUR('0.23'), renewal_mode=1)
+        alice_sdd = self.upsert_route(alice, 'stripe-sdd')
+        payin, pt = self.make_payin_and_transfer(alice_sdd, bob, EUR('0.22'), status='pending')
+        self.db.run("UPDATE payin_transfers SET ctime = ctime - interval '3 weeks'")
+        update_payin_transfer(self.db, pt.id, pt.remote_id, 'succeeded', None)
+        # At this point we should have a manual renewal scheduled in the past
+        scheduled_payins = self.db.all("SELECT * FROM scheduled_payins")
+        assert len(scheduled_payins) == 1
+        assert scheduled_payins[0].amount is None
+        assert scheduled_payins[0].automatic is False
+        payment_timedelta = scheduled_payins[0].execution_date - utcnow().date()
+        assert payment_timedelta.days <= -6
+        # Running the scheduler again shouldn't change anything.
+        old_scheduled_payins = scheduled_payins
+        alice.schedule_renewals()
+        scheduled_payins = self.db.all("SELECT * FROM scheduled_payins")
+        assert old_scheduled_payins == scheduled_payins
+        # Turn on automatic renewals. The execution date should now be in the future.
+        r = self.client.PxST("/alice/giving/", {"auto_renewal": "yes"}, auth_as=alice)
+        assert r.code == 302
+        tip = alice.get_tip_to(bob)
+        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].automatic is True
+        payment_timedelta = scheduled_payins[0].execution_date - utcnow().date()
+        assert payment_timedelta.days in (6, 7)
+        assert scheduled_payins[0].customized is True
+        assert scheduled_payins[0].last_notif_ts is None
+        assert scheduled_payins[0].notifs_count == 0
+
+
 class TestScheduledPayins(EmailHarness):
 
     def test_no_scheduled_payins(self):