Repository: liberapay/liberapay.com

Issue:
Pledge activation notifications can be misleading
1. The message doesn't link to the actual account the donation has been attributed to, which can lead to confusion.
2. Donors aren't told if they need to add money to fund their new donations.



Discussion:
Example:

![image](https://user-images.githubusercontent.com/3729038/32613952-c451b9be-c531-11e7-842a-1bade2672e8e.png)


Resolving patch:
diff --git a/liberapay/main.py b/liberapay/main.py
--- a/liberapay/main.py
+++ b/liberapay/main.py
@@ -118,19 +118,21 @@ def _assert(x):
 
 conf = website.app_conf
 if conf:
+    intervals = conf.cron_intervals
     cron = Cron(website)
-    cron(conf.check_db_every, website.db.self_check, True)
-    cron(conf.dequeue_emails_every, Participant.dequeue_emails, True)
-    cron(conf.send_newsletters_every, Participant.send_newsletters, True)
-    cron(conf.refetch_elsewhere_data_every, refetch_elsewhere_data, True)
-    cron(conf.refetch_repos_every, refetch_repos, True)
+    cron(intervals['check_db'], website.db.self_check, True)
+    cron(intervals['dequeue_emails'], Participant.dequeue_emails, True)
+    cron(intervals['send_newsletters'], Participant.send_newsletters, True)
+    cron(intervals['refetch_elsewhere_data'], refetch_elsewhere_data, True)
+    cron(intervals['refetch_repos'], refetch_repos, True)
     cron(Weekly(weekday=3, hour=2), create_payday_issue, True)
-    cron(conf.clean_up_counters_every, website.db.clean_up_counters, True)
+    cron(intervals['clean_up_counters'], website.db.clean_up_counters, True)
     cron(Daily(hour=16), lambda: fetch_currency_exchange_rates(website.db), True)
     cron(Daily(hour=17), Payday.update_cached_amounts, True)
     cron(Daily(hour=8), clean_up_closed_accounts, True)
+    cron(intervals['notify_patrons'], Participant.notify_patrons, True)
     if conf.ses_feedback_queue_url:
-        cron(conf.fetch_email_bounces_every, handle_email_bounces, True)
+        cron(intervals['fetch_email_bounces'], handle_email_bounces, True)
 
     cron('once', website.cryptograph.rotate_stored_data, True)
 
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -12,6 +12,7 @@
 
 import aspen_jinja2_renderer
 from cached_property import cached_property
+from dateutil.parser import parse as parse_date
 from html2text import html2text
 import mangopay
 from markupsafe import escape as htmlescape
@@ -1144,7 +1145,7 @@ def set_email_lang(self, lang, cursor=None):
     # Notifications
     # =============
 
-    def notify(self, event, force_email=False, email=True, web=True, **context):
+    def notify(self, event, force_email=False, email=True, web=True, idem_key=None, **context):
         if email and not force_email:
             bit = EVENTS.get(event.split('~', 1)[0]).bit
             email = self.email_notif_bits & bit > 0
@@ -1152,8 +1153,8 @@ def notify(self, event, force_email=False, email=True, web=True, **context):
         context = serialize(context)
         n_id = self.db.one("""
             INSERT INTO notifications
-                        (participant, event, context, web, email)
-                 VALUES (%(p_id)s, %(event)s, %(context)s, %(web)s, %(email)s)
+                        (participant, event, context, web, email, idem_key)
+                 VALUES (%(p_id)s, %(event)s, %(context)s, %(web)s, %(email)s, %(idem_key)s)
               RETURNING id;
         """, locals())
         if not web:
@@ -1280,14 +1281,55 @@ def render_notifications(self, state, notifs=None):
                 website.tell_sentry(e, state)
         return r
 
-    def notify_patrons(self, elsewhere, tips):
+    @classmethod
+    def notify_patrons(cls):
+        grouped_tips = cls.db.all("""
+            SELECT event.payload AS takeover, json_agg(tip) AS tips
+              FROM current_tips tip
+              JOIN participants tippee ON tippee.id = tip.tippee
+              JOIN events event ON event.participant = tip.tippee
+                               AND event.type = 'take-over'
+             WHERE tip.renewal_mode > 0
+               AND tip.paid_in_advance IS NULL
+               AND tippee.payment_providers > 0
+               AND EXISTS (
+                       SELECT 1
+                         FROM tips old_tip
+                        WHERE old_tip.tipper = tip.tipper
+                          AND old_tip.tippee = (event.payload->>'owner')::int
+                   )
+               AND NOT EXISTS (
+                       SELECT 1
+                         FROM notifications n
+                        WHERE n.participant = tip.tipper
+                          AND n.event = 'pledgee_joined~v2'
+                          AND n.idem_key = event.payload->>'owner'
+                   )
+          GROUP BY event.payload
+        """)
+        for takeover, tips in grouped_tips:
+            elsewhere = AccountElsewhere._from_thing(
+                'user_id',
+                takeover['platform'], takeover['user_id'], takeover['domain']
+            )
+            cls._notify_patrons(elsewhere, tips)
+
+    @classmethod
+    def _notify_patrons(cls, elsewhere, tips):
+        assert elsewhere.participant.payment_providers > 0
         for t in tips:
-            Participant.from_id(t.tipper).notify(
-                'pledgee_joined',
+            Participant.from_id(t['tipper']).notify(
+                'pledgee_joined~v2',
+                idem_key=str(elsewhere.participant.id),
                 user_name=elsewhere.user_name,
                 platform=elsewhere.platform_data.display_name,
-                amount=t.amount,
-                profile_url=elsewhere.liberapay_url,
+                pledge_date=parse_date(t['ctime']).date(),
+                periodic_amount=Money(**t['periodic_amount']),
+                elsewhere_profile_url=elsewhere.html_url,
+                join_time=elsewhere.participant.join_time,
+                liberapay_profile_url=elsewhere.participant.url(),
+                liberapay_username=elsewhere.participant.username,
+                tippee_id=elsewhere.participant.id,
             )
 
 
@@ -2482,9 +2524,6 @@ def take_over(self, account, have_confirmation=False):
                 platform=platform, domain=domain, user_id=user_id, owner=other.id
             ))
 
-        if old_tips:
-            self.notify_patrons(elsewhere, tips=old_tips)
-
         self.update_avatar()
 
         # Note: the order matters here, receiving needs to be updated before giving
diff --git a/liberapay/wireup.py b/liberapay/wireup.py
--- a/liberapay/wireup.py
+++ b/liberapay/wireup.py
@@ -207,14 +207,11 @@ class AppConf(object):
         bountysource_callback=str,
         bountysource_id=None.__class__,
         bountysource_secret=str,
-        check_db_every=int,
         check_email_domains=bool,
-        clean_up_counters_every=int,
-        dequeue_emails_every=int,
+        cron_intervals=dict,
         facebook_callback=str,
         facebook_id=str,
         facebook_secret=str,
-        fetch_email_bounces_every=int,
         github_callback=str,
         github_id=str,
         github_secret=str,
@@ -243,8 +240,6 @@ class AppConf(object):
         paypal_domain=str,
         paypal_id=str,
         paypal_secret=str,
-        refetch_elsewhere_data_every=int,
-        refetch_repos_every=int,
         s3_endpoint=str,
         s3_public_access_key=str,
         s3_secret_key=str,
@@ -252,7 +247,6 @@ class AppConf(object):
         s3_payday_logs_bucket=str,
         ses_feedback_queue_url=str,
         ses_region=str,
-        send_newsletters_every=int,
         sepa_creditor_identifier=str,
         show_sandbox_warning=bool,
         socket_timeout=float,


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
@@ -6,7 +6,9 @@
 from liberapay.elsewhere._base import UserInfo
 from liberapay.models.account_elsewhere import AccountElsewhere
 from liberapay.models.exchange_route import ExchangeRoute
+from liberapay.models.participant import Participant
 from liberapay.testing import EUR, Harness
+from liberapay.testing.emails import EmailHarness
 import liberapay.testing.elsewhere as user_info_examples
 from liberapay.utils import b64encode_s
 
@@ -18,7 +20,7 @@ def get_user_info_example(platform_name):
     return '', r
 
 
-class TestElsewhere(Harness):
+class TestElsewhere(EmailHarness):
 
     def test_associate_csrf(self):
         response = self.client.GxT('/on/github/associate?state=49b7c66246c7')
@@ -189,6 +191,33 @@ def test_public_json_opted_in(self):
         response = self.client.GxT('/on/github/alice/public.json')
         assert response.code == 302
 
+    def test_patrons_are_notified_after_pledgee_joins(self):
+        self.bob = self.make_participant('bob', email='bob@example.com')
+        self.dan = self.make_participant('dan', email='dan@example.com')
+        self.alice = self.make_participant('alice', email='alice@example.com')
+
+        dan_twitter = self.make_elsewhere('twitter', 1, 'dan')
+
+        self.alice.set_tip_to(self.dan, EUR('100'))  # Alice shouldn't receive an email.
+        self.bob.set_tip_to(dan_twitter, EUR('100'))  # Bob should receive an email.
+
+        self.dan.take_over(dan_twitter, have_confirmation=True)
+
+        # dan hasn't connected any payment account yet, so there shouldn't be a notification
+        Participant.notify_patrons()
+        Participant.dequeue_emails()
+        assert self.mailer.call_count == 0
+
+        # add a payment account and check again
+        self.add_payment_account(self.dan, 'stripe')
+        Participant.notify_patrons()
+        Participant.dequeue_emails()
+        assert self.mailer.call_count == 1
+        last_email = self.get_last_email()
+        assert last_email['to'][0] == 'bob <bob@example.com>'
+        assert "to dan" in last_email['text']
+        assert "Change your email settings" in last_email['text']
+
 
 class TestConfirmTakeOver(Harness):
 
diff --git a/tests/py/test_email_notifs.py b/tests/py/test_email_notifs.py
deleted file mode 100644
--- a/tests/py/test_email_notifs.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from liberapay.models.participant import Participant
-from liberapay.testing import EUR
-from liberapay.testing.emails import EmailHarness
-
-
-class TestTransactionalEmails(EmailHarness):
-
-    def setUp(self):
-        EmailHarness.setUp(self)
-        self.bob = self.make_participant('bob', email='bob@example.com')
-        self.dan = self.make_participant('dan', email='dan@example.com')
-        self.alice = self.make_participant('alice', email='alice@example.com')
-
-    def test_take_over_sends_notifications_to_patrons(self):
-        dan_twitter = self.make_elsewhere('twitter', 1, 'dan')
-
-        self.alice.set_tip_to(self.dan, EUR('100'))  # Alice shouldn't receive an email.
-        self.bob.set_tip_to(dan_twitter, EUR('100'))  # Bob should receive an email.
-
-        self.dan.take_over(dan_twitter, have_confirmation=True)
-
-        Participant.dequeue_emails()
-        assert self.mailer.call_count == 1
-        last_email = self.get_last_email()
-        assert last_email['to'][0] == 'bob <bob@example.com>'
-        assert "to dan" in last_email['text']
-        assert "Change your email settings" in last_email['text']