Repository: liberapay/liberapay.com

Issue:
Closing account doesn't invalidate exchange routes
- [ ] modify `Participant.close()`: fetch routes and call `.invalidate()` on each



Discussion:

Resolving patch:
diff --git a/liberapay/main.py b/liberapay/main.py
--- a/liberapay/main.py
+++ b/liberapay/main.py
@@ -25,7 +25,7 @@
 from liberapay.cron import Cron, Daily, Weekly
 from liberapay.models.account_elsewhere import refetch_elsewhere_data
 from liberapay.models.community import Community
-from liberapay.models.participant import Participant
+from liberapay.models.participant import Participant, clean_up_closed_accounts
 from liberapay.models.repository import refetch_repos
 from liberapay.security import authentication, csrf, set_default_security_headers
 from liberapay.utils import b64decode_s, b64encode_s, erase_cookie, http_caching, i18n, set_cookie
@@ -112,6 +112,7 @@ def _assert(x):
     cron(conf.clean_up_counters_every, 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)
 
 
 # Website Algorithm
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -521,15 +521,10 @@ def resolve_stub(self):
     class AccountNotEmpty(Exception): pass
 
     def final_check(self, cursor):
-        """Sanity-check that balance and tips have been dealt with.
+        """Sanity-check that balance has been dealt with.
         """
         if self.balance != 0:
             raise self.AccountNotEmpty
-        incoming = cursor.one("""
-            SELECT count(*) FROM current_tips WHERE tippee = %s AND amount > 0
-        """, (self.id,))
-        if incoming > 0:
-            raise self.AccountNotEmpty
 
     class UnknownDisbursementStrategy(Exception): pass
 
@@ -547,11 +542,10 @@ def close(self, disbursement_strategy):
 
         with self.db.get_cursor() as cursor:
             self.clear_tips_giving(cursor)
-            self.clear_tips_receiving(cursor)
             self.clear_takes(cursor)
             if self.kind == 'group':
                 self.remove_all_members(cursor)
-            self.clear_personal_information(cursor)
+            self.clear_subscriptions(cursor)
             self.final_check(cursor)
             self.update_status('closed', cursor)
 
@@ -688,27 +682,60 @@ def clear_takes(self, cursor):
         for t in teams:
             t.set_take_for(self, None, self, cursor=cursor)
 
-    def clear_personal_information(self, cursor):
-        """Clear personal information such as statements and goal.
+    def clear_subscriptions(self, cursor):
+        """Unsubscribe from all newsletters.
         """
-        r = cursor.one("""
+        cursor.run("""
+            UPDATE subscriptions
+               SET is_on = false
+                 , mtime = current_timestamp
+             WHERE subscriber = %s
+        """, (self.id,))
+
+    def erase_personal_information(self):
+        """Erase forever the user's personal data (statements, goal, etc).
+        """
+        r = self.db.one("""
 
             DELETE FROM community_memberships WHERE participant=%(id)s;
             DELETE FROM subscriptions WHERE subscriber=%(id)s;
             DELETE FROM emails WHERE participant=%(id)s AND address <> %(email)s;
+            DELETE FROM notifications WHERE participant=%(id)s;
             DELETE FROM statements WHERE participant=%(id)s;
 
+            DELETE FROM events
+             WHERE participant = %(id)s
+               AND (recorder IS NULL OR recorder = participant)
+               AND type NOT IN (
+                       'account-kind-change',
+                       'mangopay-account-change',
+                       'set_status'
+                   );
+
             UPDATE participants
                SET goal=NULL
                  , avatar_url=NULL
-                 , giving=zero(giving)
-                 , receiving=zero(receiving)
-                 , npatrons=0
+                 , avatar_src=NULL
+                 , avatar_email=NULL
+                 , public_name=NULL
              WHERE id=%(id)s
          RETURNING *;
 
-        """, dict(id=self.id, email=self.email))
+        """, dict(id=self.id, email=self.email or self.get_any_email()))
         self.set_attributes(**r._asdict())
+        self.add_event(self.db, 'erase_personal_information', None)
+
+    def invalidate_exchange_routes(self):
+        """Disable any saved payment routes (cards, bank accounts).
+        """
+        routes = self.db.all("""
+            SELECT r
+              FROM exchange_routes r
+             WHERE participant = %s
+               AND coalesce(error, '') <> 'invalidated'
+        """, (self.id,))
+        for route in routes:
+            route.invalidate()
 
     @cached_property
     def closed_time(self):
@@ -1205,41 +1232,26 @@ def get_last_event_of_type(self, type):
 
     def upsert_subscription(self, on, publisher):
         subscriber = self.id
-        token = str(uuid.uuid4()) if on else None
-        r = self.db.one("""
-            DO $$
-            DECLARE
-                cname text;
-            BEGIN
-                IF (%(on)s) THEN
-                BEGIN
-                    INSERT INTO subscriptions
-                                (publisher, subscriber, is_on, token)
-                         VALUES (%(publisher)s, %(subscriber)s, %(on)s, %(token)s);
-                    IF (FOUND) THEN RETURN; END IF;
-                EXCEPTION WHEN unique_violation THEN
-                    GET STACKED DIAGNOSTICS cname = CONSTRAINT_NAME;
-                    IF (cname <> 'subscriptions_publisher_subscriber_key') THEN
-                        RAISE;
-                    END IF;
-                END;
-                END IF;
+        if on:
+            token = str(uuid.uuid4())
+            return self.db.one("""
+                INSERT INTO subscriptions
+                            (publisher, subscriber, is_on, token)
+                     VALUES (%(publisher)s, %(subscriber)s, %(on)s, %(token)s)
+                ON CONFLICT (publisher, subscriber) DO UPDATE
+                        SET is_on = excluded.is_on
+                          , mtime = current_timestamp
+                  RETURNING *
+            """, locals())
+        else:
+            return self.db.one("""
                 UPDATE subscriptions
                    SET is_on = %(on)s
                      , mtime = CURRENT_TIMESTAMP
                  WHERE publisher = %(publisher)s
-                   AND subscriber = %(subscriber)s;
-            END;
-            $$ LANGUAGE plpgsql;
-
-            SELECT *
-              FROM subscriptions
-             WHERE publisher = %(publisher)s
-               AND subscriber = %(subscriber)s;
-        """, locals())
-        if not r and on:
-            raise Exception('upsert in subscriptions failed')
-        return r
+                   AND subscriber = %(subscriber)s
+             RETURNING *
+            """, locals())
 
     def check_subscription_status(self, subscriber):
         return self.db.one("""
@@ -1673,8 +1685,23 @@ def update_goal(self, goal, cursor=None):
             c.run("UPDATE participants SET goal=%s WHERE id=%s", (goal, self.id))
             self.set_attributes(goal=goal)
             if not self.accepts_tips:
-                self.clear_tips_receiving(c)
-                self.update_receiving(c)
+                tippers = c.all("""
+                    SELECT p
+                      FROM current_tips t
+                      JOIN participants p ON p.id = t.tipper
+                     WHERE t.tippee = %s
+                       AND t.amount > 0
+                """, (self.id,))
+                for tipper in tippers:
+                    tipper.update_giving(cursor=c)
+                r = c.one("""
+                    UPDATE participants
+                       SET receiving = zero(receiving)
+                         , npatrons = 0
+                     WHERE id = %s
+                 RETURNING receiving, npatrons
+                """, (self.id,))
+                self.set_attributes(**r._asdict())
 
     def update_status(self, status, cursor=None):
         with self.db.get_cursor(cursor) as c:
@@ -1694,7 +1721,6 @@ def update_status(self, status, cursor=None):
             self.set_attributes(**r._asdict())
             self.add_event(c, 'set_status', status)
             if not self.accepts_tips:
-                self.clear_tips_receiving(c)
                 self.update_receiving(c)
 
     def get_giving_in(self, currency):
@@ -1743,7 +1769,11 @@ def update_giving(self, cursor=None):
               JOIN participants p2 ON p2.id = t.tippee
              WHERE t.tipper = %s
                AND t.amount > 0
-          ORDER BY p2.join_time IS NULL, t.ctime ASC
+          ORDER BY ( p2.status = 'active' AND
+                     (p2.goal IS NULL OR p2.goal >= 0) AND
+                     (p2.mangopay_user_id IS NOT NULL OR p2.kind = 'group')
+                   ) DESC
+                 , p2.join_time IS NULL, t.ctime ASC
         """, (self.id,))
         updated = []
         for wallet in self.get_current_wallets(cursor):
@@ -1769,14 +1799,15 @@ def update_giving(self, cursor=None):
         giving = (cursor or self.db).one("""
             UPDATE participants p
                SET giving = coalesce_currency_amount((
-                     SELECT sum(amount, %(currency)s)
-                       FROM current_tips
-                       JOIN participants p2 ON p2.id = tippee
-                      WHERE tipper = %(id)s
+                     SELECT sum(t.amount, %(currency)s)
+                       FROM current_tips t
+                       JOIN participants p2 ON p2.id = t.tippee
+                      WHERE t.tipper = %(id)s
                         AND p2.status = 'active'
-                        AND (p2.mangopay_user_id IS NOT NULL OR kind = 'group')
-                        AND amount > 0
-                        AND is_funded
+                        AND (p2.goal IS NULL OR p2.goal >= 0)
+                        AND (p2.mangopay_user_id IS NOT NULL OR p2.kind = 'group')
+                        AND t.amount > 0
+                        AND t.is_funded
                    ), p.main_currency)
              WHERE p.id = %(id)s
          RETURNING giving
@@ -2422,3 +2453,37 @@ def __ne__(self, other):
     def __bool__(self):
         return any(self._all)
     __nonzero__ = __bool__
+
+
+def clean_up_closed_accounts():
+    participants = website.db.all("""
+        SELECT p, closed_time
+          FROM (
+                 SELECT p
+                      , ( SELECT e2.ts
+                            FROM events e2
+                           WHERE e2.participant = p.id
+                             AND e2.type = 'set_status'
+                             AND e2.payload = '"closed"'
+                        ORDER BY e2.ts DESC
+                           LIMIT 1
+                        ) AS closed_time
+                   FROM participants p
+                  WHERE p.status = 'closed'
+                    AND p.kind IN ('individual', 'organization')
+               ) a
+         WHERE closed_time < (current_timestamp - INTERVAL '7 days')
+           AND NOT EXISTS (
+                   SELECT e.id
+                     FROM events e
+                    WHERE e.participant = (p).id
+                      AND e.type = 'erase_personal_information'
+                      AND e.ts > closed_time
+               )
+    """)
+    for p, closed_time in participants:
+        sleep(0.1)
+        print("Deleting data of account ~%i (closed on %s)..." % (p.id, closed_time))
+        p.erase_personal_information()
+        p.invalidate_exchange_routes()
+    return len(participants)


Test patch:
diff --git a/tests/py/test_close.py b/tests/py/test_close.py
--- a/tests/py/test_close.py
+++ b/tests/py/test_close.py
@@ -4,7 +4,7 @@
 
 from liberapay.billing.payday import Payday
 from liberapay.models.community import Community
-from liberapay.models.participant import Participant
+from liberapay.models.participant import Participant, clean_up_closed_accounts
 from liberapay.testing import EUR
 from liberapay.testing.mangopay import FakeTransfersHarness
 
@@ -28,7 +28,7 @@ def test_close_closes(self):
 
         alice.close('downstream')
 
-        assert carl.get_tip_to(alice)['amount'] == 0
+        assert carl.get_tip_to(alice)['amount'] == EUR(2)
         assert alice.balance == 0
         assert len(team.get_current_takes()) == 1
 
@@ -40,7 +40,7 @@ def test_close_raises_for_unknown_disbursement_strategy(self):
     def test_close_page_is_usually_available(self):
         alice = self.make_participant('alice')
         body = self.client.GET('/alice/settings/close', auth_as=alice).text
-        assert 'Personal Information' in body
+        assert '<h3>Username' in body
 
     def test_close_page_is_not_available_during_payday(self):
         Payday.start()
@@ -250,9 +250,9 @@ def test_ctr_clears_multiple_tips_receiving(self):
         assert ntips() == 0
 
 
-    # cpi - clear_personal_information
+    # epi - erase_personal_information
 
-    def test_cpi_clears_personal_information(self):
+    def test_epi_deletes_personal_information(self):
         alice = self.make_participant(
             'alice',
             goal=EUR(100),
@@ -260,15 +260,11 @@ def test_cpi_clears_personal_information(self):
             hide_receiving=True,
             avatar_url='img-url',
             email='alice@example.com',
-            giving=EUR(20),
-            receiving=EUR(40),
-            npatrons=21,
         )
         alice.upsert_statement('en', 'not forgetting to be awesome!')
         alice.add_email('alice@example.net')
 
-        with self.db.get_cursor() as cursor:
-            alice.clear_personal_information(cursor)
+        alice.erase_personal_information()
         new_alice = Participant.from_username('alice')
 
         assert alice.get_statement(['en']) == (None, None)
@@ -277,15 +273,12 @@ def test_cpi_clears_personal_information(self):
         assert alice.hide_receiving == new_alice.hide_receiving == True
         assert alice.avatar_url == new_alice.avatar_url == None
         assert alice.email == new_alice.email
-        assert alice.giving == new_alice.giving == 0
-        assert alice.receiving == new_alice.receiving == 0
-        assert alice.npatrons == new_alice.npatrons == 0
         emails = alice.get_emails()
         assert len(emails) == 1
         assert emails[0].address == 'alice@example.com'
         assert emails[0].verified
 
-    def test_cpi_clears_communities(self):
+    def test_epi_clears_communities(self):
         alice = self.make_participant('alice')
         c = alice.create_community('test')
         alice.upsert_community_membership(True, c.id)
@@ -294,7 +287,35 @@ def test_cpi_clears_communities(self):
 
         assert Community.from_name('test').nmembers == 2  # sanity check
 
-        with self.db.get_cursor() as cursor:
-            alice.clear_personal_information(cursor)
+        alice.erase_personal_information()
 
         assert Community.from_name('test').nmembers == 1
+
+
+    def test_clean_up_closed_accounts(self):
+        alice = self.make_participant(
+            'alice',
+            goal=EUR(555),
+            avatar_url='img-url',
+            email='alice@example.com',
+        )
+        alice.upsert_statement('en', 'not forgetting to be awesome!')
+        alice.add_email('alice@example.net')
+        alice.close(None)
+
+        cleaned = clean_up_closed_accounts()
+        assert cleaned == 0
+
+        self.db.run("UPDATE events SET ts = ts - interval '7 days'")
+        cleaned = clean_up_closed_accounts()
+        assert cleaned == 1
+
+        alice = alice.refetch()
+        assert alice.get_statement(['en']) == (None, None)
+        assert alice.goal == None
+        assert alice.avatar_url == None
+        assert alice.email == 'alice@example.com'
+        emails = alice.get_emails()
+        assert len(emails) == 1
+        assert emails[0].address == 'alice@example.com'
+        assert emails[0].verified