Repository: liberapay/liberapay.com

Issue:
Donations should not be zeroed
When a donation is stopped by the donor ~~or the recipient~~ we change its amount to zero, this is problematic and should be replaced by a new column indicating the status of the donation.


Discussion:

Resolving patch:
diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py
--- a/liberapay/billing/payday.py
+++ b/liberapay/billing/payday.py
@@ -174,6 +174,7 @@ def prepare(cursor, ts_start):
             SELECT id
                  , username
                  , join_time
+                 , status
                  , ( COALESCE((eur_w.balance).amount, '0.00'),
                      COALESCE((usd_w.balance).amount, '0.00'),
                      NULL
@@ -193,7 +194,7 @@ def prepare(cursor, ts_start):
                                 AND %(use_mangopay)s
              WHERE join_time < %(ts_start)s
                AND is_suspended IS NOT true
-               AND status = 'active'
+               AND status <> 'stub'
           ORDER BY join_time;
 
         CREATE UNIQUE INDEX ON payday_participants (id);
@@ -201,6 +202,9 @@ def prepare(cursor, ts_start):
         CREATE TEMPORARY TABLE payday_tips ON COMMIT DROP AS
             SELECT t.id, t.tipper, t.tippee, t.amount, (p2.kind = 'group') AS to_team
                  , coalesce_currency_amount(t.paid_in_advance, t.amount::currency) AS paid_in_advance
+                 , ( t.renewal_mode > 0 AND (p2.goal IS NULL OR p2.goal >= 0) AND
+                     p.status = 'active' AND p2.status = 'active'
+                   ) AS process_real_transfers
               FROM ( SELECT DISTINCT ON (tipper, tippee) *
                        FROM tips
                       WHERE mtime < %(ts_start)s
@@ -208,8 +212,7 @@ def prepare(cursor, ts_start):
                    ) t
               JOIN payday_participants p ON p.id = t.tipper
               JOIN payday_participants p2 ON p2.id = t.tippee
-             WHERE t.amount > 0
-               AND (p2.goal IS NULL or p2.goal >= 0)
+             WHERE (p2.goal IS NULL OR p2.goal >= 0 OR t.paid_in_advance > t.amount)
           ORDER BY p.join_time ASC, t.ctime ASC;
 
         CREATE INDEX ON payday_tips (tipper);
@@ -282,6 +285,9 @@ def prepare(cursor, ts_start):
                         END IF;
                         transfer_amount := transfer_amount - in_advance;
                     END IF;
+                    IF (NOT tip.process_real_transfers) THEN
+                        transfer_amount := zero(transfer_amount);
+                    END IF;
                 END IF;
 
                 UPDATE payday_participants
@@ -309,6 +315,9 @@ def prepare(cursor, ts_start):
                 IF ($1.is_funded IS true) THEN RETURN NULL; END IF;
                 okay := $1.paid_in_advance >= $1.amount;
                 IF (okay IS NOT true) THEN
+                    IF (NOT $1.process_real_transfers) THEN
+                        RETURN false;
+                    END IF;
                     tipper_balances := (
                         SELECT balances
                           FROM payday_participants p
@@ -984,6 +993,8 @@ def notify_participants(self):
         """, (previous_ts_end, self.ts_end))
         for tippee_id, transfers in r:
             p = Participant.from_id(tippee_id)
+            if not p.accepts_tips:
+                continue
             for t in transfers:
                 t['amount'] = Money(**t['amount'])
             by_team = {k: (MoneyBasket(t['amount'] for t in v), len(set(t['tipper'] for t in v)))
@@ -1020,13 +1031,14 @@ def notify_participants(self):
                      SELECT t.*, p2.username AS tippee_username
                        FROM current_tips t
                        JOIN participants p2 ON p2.id = t.tippee
-                      WHERE t.amount > 0
+                      WHERE t.renewal_mode > 0
                         AND ( t.paid_in_advance IS NULL OR
                               t.paid_in_advance < t.amount
                             )
                         AND p2.status = 'active'
                         AND p2.is_suspended IS NOT true
                         AND p2.payment_providers > 0
+                        AND (p2.goal IS NULL OR p2.goal >= 0)
                    ) t
              WHERE EXISTS (
                      SELECT 1
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -565,6 +565,7 @@ def distribute_balances_to_donees(self, final_gift=True):
               FROM current_tips t
               JOIN participants p ON p.id = t.tippee
              WHERE tipper = %s
+               AND renewal_mode > 0
                AND p.status = 'active'
                AND (p.mangopay_user_id IS NOT NULL OR kind = 'group')
                AND p.is_suspended IS NOT TRUE
@@ -659,22 +660,21 @@ def distribute_balances_to_donees(self, final_gift=True):
             raise UnableToDistributeBalance(balance)
 
     def clear_tips_giving(self, cursor):
-        """Zero out tips from a given user.
+        """Turn off the renewal of all tips from a given user.
         """
         tippees = cursor.all("""
-
-            SELECT ( SELECT p.*::participants
-                       FROM participants p
-                      WHERE p.id=t.tippee
-                    ) AS tippee
-                 , t.amount
-              FROM current_tips t
-             WHERE tipper = %s
-               AND amount > 0
-
+            INSERT INTO tips
+                      ( ctime, tipper, tippee, amount, period, periodic_amount
+                      , paid_in_advance, is_funded, renewal_mode )
+                 SELECT ctime, tipper, tippee, amount, period, periodic_amount
+                      , paid_in_advance, is_funded, 0
+                   FROM current_tips
+                  WHERE tipper = %s
+                    AND renewal_mode > 0
+              RETURNING ( SELECT p FROM participants p WHERE p.id = tippee ) AS tippee
         """, (self.id,))
-        for tippee, amount in tippees:
-            self.set_tip_to(tippee, amount.zero(), update_self=False, cursor=cursor)
+        for tippee in tippees:
+            tippee.update_receiving(cursor=cursor)
 
     def clear_tips_receiving(self, cursor):
         """Zero out tips to a given user.
@@ -1799,7 +1799,6 @@ def update_goal(self, goal, cursor=None):
                       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)
@@ -1880,7 +1879,6 @@ def update_giving(self, cursor=None):
               FROM current_tips t
               JOIN participants p2 ON p2.id = t.tippee
              WHERE t.tipper = %s
-               AND t.amount > 0
           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')
@@ -1896,7 +1894,7 @@ def update_giving(self, cursor=None):
             for tip in (t for t in tips if t.amount.currency == currency):
                 if tip.amount <= (tip.paid_in_advance or 0):
                     is_funded = True
-                elif tip.amount > fake_balance:
+                elif tip.renewal_mode <= 0 or tip.amount > fake_balance:
                     is_funded = False
                 else:
                     fake_balance -= tip.amount
@@ -1918,7 +1916,6 @@ def update_giving(self, cursor=None):
                        FROM current_tips t
                        JOIN participants p2 ON p2.id = t.tippee
                       WHERE t.tipper = %(id)s
-                        AND t.amount > 0
                         AND ( p2.status = 'active' AND
                               (p2.goal IS NULL OR p2.goal >= 0) AND
                               (p2.mangopay_user_id IS NOT NULL OR p2.kind = 'group') AND
@@ -1946,7 +1943,6 @@ def update_receiving(self, cursor=None):
                          SELECT amount
                            FROM current_tips
                           WHERE tippee = %(id)s
-                            AND amount > 0
                             AND is_funded
                      )
                 UPDATE participants p
@@ -1991,6 +1987,9 @@ def set_tip_to(self, tippee, periodic_amount, period='weekly',
         if self.id == tippee.id:
             raise NoSelfTipping
 
+        if periodic_amount == 0:
+            return self.stop_tip_to(tippee, cursor=cursor)
+
         amount = (periodic_amount * PERIOD_CONVERSION_RATES[period]).round_down()
 
         if periodic_amount != 0:
@@ -2045,7 +2044,32 @@ def _zero_tip_dict(tippee, currency=None):
             currency = tippee.main_currency
         zero = Money.ZEROS[currency]
         return dict(amount=zero, is_funded=False, tippee=tippee.id,
-                    period='weekly', periodic_amount=zero)
+                    period='weekly', periodic_amount=zero, renewal_mode=0)
+
+
+    def stop_tip_to(self, tippee, cursor=None):
+        t = (cursor or self.db).one("""
+            INSERT INTO tips
+                      ( ctime, tipper, tippee, amount, period, periodic_amount
+                      , paid_in_advance, is_funded, renewal_mode )
+                 SELECT ctime, tipper, tippee, amount, period, periodic_amount
+                      , paid_in_advance, is_funded, 0
+                   FROM current_tips
+                  WHERE tipper = %(tipper)s
+                    AND tippee = %(tippee)s
+                    AND renewal_mode > 0
+              RETURNING *
+                      , ( SELECT join_time IS NULL FROM participants WHERE id = %(tippee)s ) AS is_pledge
+        """, dict(tipper=self.id, tippee=tippee.id))
+        if not t:
+            return
+        if t.amount > (t.paid_in_advance or 0):
+            # Update giving amount of tipper
+            self.update_giving(cursor)
+            # Update receiving amount of tippee
+            tippee.update_receiving(cursor)
+        return t._asdict()
+
 
 
     def get_tip_to(self, tippee, currency=None):
@@ -2100,8 +2124,7 @@ def get_tip_distribution(self):
                    ORDER BY tipper
                           , mtime DESC
                     ) AS foo
-             WHERE amount > 0
-               AND is_funded
+             WHERE is_funded
           GROUP BY amount
           ORDER BY (amount).amount
         """, (self.id,))
@@ -2142,6 +2165,7 @@ def get_giving_details(self):
                      , p AS tippee_p
                      , t.is_funded
                      , t.paid_in_advance
+                     , t.renewal_mode
                      , p.payment_providers
                      , ( t.paid_in_advance IS NULL OR
                          t.paid_in_advance < (t.periodic_amount * 0.75) OR
@@ -2165,6 +2189,7 @@ def get_giving_details(self):
                      , tippee
                      , t.ctime
                      , t.mtime
+                     , t.renewal_mode
                      , (e, p)::elsewhere_with_participant AS e_account
                   FROM tips t
                   JOIN participants p ON p.id = t.tippee
@@ -2184,7 +2209,7 @@ def get_tips_awaiting_renewal(self):
               FROM current_tips t
               JOIN participants p ON p.id = t.tippee
              WHERE t.tipper = %s
-               AND t.amount > 0
+               AND t.renewal_mode > 0
                AND ( t.paid_in_advance IS NULL OR
                      t.paid_in_advance < (t.periodic_amount * 0.75) OR
                      t.paid_in_advance < (t.amount * 4)
@@ -2291,7 +2316,7 @@ def take_over(self, account, have_confirmation=False):
                 SELECT *
                   FROM current_tips
                  WHERE (tippee = %(dead)s OR tippee = %(live)s)
-                   AND amount > 0;
+                   AND renewal_mode > 0;
         """
 
         CONSOLIDATE_TIPS_RECEIVING = """
@@ -2301,12 +2326,13 @@ def take_over(self, account, have_confirmation=False):
             -- sum the amounts to prevent the new one from being above the
             -- maximum allowed.
             INSERT INTO tips
-                        (ctime, tipper, tippee, amount, period,
-                         periodic_amount, is_funded, paid_in_advance)
+                      ( ctime, tipper, tippee, amount, period
+                      , periodic_amount, is_funded, renewal_mode
+                      , paid_in_advance )
                  SELECT DISTINCT ON (tipper)
-                        ctime, tipper, %(live)s AS tippee, amount, period,
-                        periodic_amount, is_funded,
-                        ( SELECT sum(t2.paid_in_advance, t.amount::currency)
+                        ctime, tipper, %(live)s AS tippee, amount, period
+                      , periodic_amount, is_funded, renewal_mode
+                      , ( SELECT sum(t2.paid_in_advance, t.amount::currency)
                             FROM temp_tips t2
                            WHERE t2.tipper = t.tipper
                         ) AS paid_in_advance
@@ -2320,10 +2346,15 @@ def take_over(self, account, have_confirmation=False):
         """
 
         ZERO_OUT_OLD_TIPS_RECEIVING = """
-            INSERT INTO tips (ctime, tipper, tippee, amount, period, periodic_amount)
-                SELECT ctime, tipper, tippee, zero(amount), period, zero(periodic_amount)
-                  FROM temp_tips
-                 WHERE tippee=%s
+            INSERT INTO tips
+                      ( ctime, tipper, tippee, amount, period, periodic_amount
+                      , paid_in_advance, is_funded, renewal_mode )
+                 SELECT ctime, tipper, tippee, amount, period, periodic_amount
+                      , NULL, false, 0
+                   FROM temp_tips
+                  WHERE tippee = %(dead)s
+                    AND ( coalesce_currency_amount(paid_in_advance, amount::currency) > 0 OR
+                          renewal_mode > 0 )
         """
 
         with self.db.get_cursor() as cursor:
@@ -2393,10 +2424,10 @@ def take_over(self, account, have_confirmation=False):
 
             # Turn pledges into actual tips
             if old_tips:
-                x, y = self.id, other.id
-                cursor.run(CREATE_TEMP_TABLE_FOR_TIPS, dict(live=x, dead=y))
-                cursor.run(CONSOLIDATE_TIPS_RECEIVING, dict(live=x, dead=y))
-                cursor.run(ZERO_OUT_OLD_TIPS_RECEIVING, (other.id,))
+                params = dict(live=self.id, dead=other.id)
+                cursor.run(CREATE_TEMP_TABLE_FOR_TIPS, params)
+                cursor.run(CONSOLIDATE_TIPS_RECEIVING, params)
+                cursor.run(ZERO_OUT_OLD_TIPS_RECEIVING, params)
 
             # Try to delete the stub account, or prevent new pledges to it
             if not other_is_a_real_participant:
diff --git a/liberapay/payin/common.py b/liberapay/payin/common.py
--- a/liberapay/payin/common.py
+++ b/liberapay/payin/common.py
@@ -303,7 +303,6 @@ def update_payin_transfer(
                      SELECT t.amount
                        FROM current_tips t
                       WHERE t.tippee = %(p_id)s
-                        AND t.amount > 0
                         AND t.is_funded
                  )
             UPDATE participants AS p


Test patch:
diff --git a/tests/py/test_charts_json.py b/tests/py/test_charts_json.py
--- a/tests/py/test_charts_json.py
+++ b/tests/py/test_charts_json.py
@@ -145,7 +145,7 @@ def test_transfer_volume(self):
             "date": today(),
             "transfer_volume": {'amount': '3.00', 'currency': 'EUR'},
             "nactive": '3',
-            "nparticipants": '4',
+            "nparticipants": '5',
             "nusers": '4',
             "week_deposits": zero,
             "week_withdrawals": zero,
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
@@ -99,14 +99,14 @@ def test_dbtd_gives_all_to_claimed(self):
         assert Participant.from_id(carl.id).balance == EUR('0.00')
         assert Participant.from_id(alice.id).balance == EUR('0.00')
 
-    def test_dbtd_skips_zero_tips(self):
+    def test_dbtd_skips_stopped_tips(self):
         alice = self.make_participant('alice', balance=EUR('10.00'))
         bob = self.make_participant('bob')
         carl = self.make_participant('carl')
-        alice.set_tip_to(bob, EUR('0.00'))
+        alice.set_tip_to(bob, EUR('1.00'))
+        alice.stop_tip_to(bob)
         alice.set_tip_to(carl, EUR('2.00'))
         alice.distribute_balances_to_donees()
-        assert self.db.one("SELECT count(*) FROM tips WHERE tippee=%s", (bob.id,)) == 1
         assert Participant.from_username('bob').balance == EUR('0.00')
         assert Participant.from_username('carl').balance == EUR('10.00')
         assert Participant.from_username('alice').balance == EUR('0.00')
@@ -165,7 +165,7 @@ def test_ctg_clears_tips_giving(self):
         alice = self.make_participant('alice')
         alice.set_tip_to(self.make_participant('bob'), EUR('1.00'))
         ntips = lambda: self.db.one("SELECT count(*) FROM current_tips "
-                                    "WHERE tipper=%s AND amount > 0",
+                                    "WHERE tipper=%s AND renewal_mode > 0",
                                     (alice.id,))
         assert ntips() == 1
         with self.db.get_cursor() as cursor:
@@ -199,7 +199,7 @@ def test_ctg_clears_multiple_tips_giving(self):
         alice.set_tip_to(self.make_participant('evelyn'), EUR('1.00'))
         alice.set_tip_to(self.make_participant('francis'), EUR('1.00'))
         ntips = lambda: self.db.one("SELECT count(*) FROM current_tips "
-                                    "WHERE tipper=%s AND amount > 0",
+                                    "WHERE tipper=%s AND renewal_mode > 0",
                                     (alice.id,))
         assert ntips() == 5
         with self.db.get_cursor() as cursor:
@@ -213,14 +213,14 @@ def test_ctr_clears_tips_receiving(self):
         alice = self.make_participant('alice')
         self.make_participant('bob').set_tip_to(alice, EUR('1.00'))
         ntips = lambda: self.db.one("SELECT count(*) FROM current_tips "
-                                    "WHERE tippee=%s AND amount > 0",
+                                    "WHERE tippee=%s AND renewal_mode > 0",
                                     (alice.id,))
         assert ntips() == 1
         with self.db.get_cursor() as cursor:
             alice.clear_tips_receiving(cursor)
         assert ntips() == 0
 
-    def test_ctr_doesnt_duplicate_zero_tips(self):
+    def test_ctr_doesnt_duplicate_stopped_tips(self):
         alice = self.make_participant('alice')
         bob = self.make_participant('bob')
         bob.set_tip_to(alice, EUR('1.00'))
@@ -231,7 +231,7 @@ def test_ctr_doesnt_duplicate_zero_tips(self):
             alice.clear_tips_receiving(cursor)
         assert ntips() == 2
 
-    def test_ctr_doesnt_zero_when_theres_no_tip(self):
+    def test_ctr_doesnt_insert_when_theres_no_tip(self):
         alice = self.make_participant('alice')
         ntips = lambda: self.db.one("SELECT count(*) FROM tips WHERE tippee=%s", (alice.id,))
         assert ntips() == 0
@@ -247,7 +247,7 @@ def test_ctr_clears_multiple_tips_receiving(self):
         self.make_participant('evelyn').set_tip_to(alice, EUR('4.00'))
         self.make_participant('francis').set_tip_to(alice, EUR('5.00'))
         ntips = lambda: self.db.one("SELECT count(*) FROM current_tips "
-                                    "WHERE tippee=%s AND amount > 0",
+                                    "WHERE tippee=%s AND renewal_mode > 0",
                                     (alice.id,))
         assert ntips() == 5
         with self.db.get_cursor() as cursor:
diff --git a/tests/py/test_goal.py b/tests/py/test_goal.py
--- a/tests/py/test_goal.py
+++ b/tests/py/test_goal.py
@@ -18,10 +18,6 @@ def change_goal(self, goal, goal_custom="", auth_as="alice", expect_success=Fals
             raise r
         return r
 
-    def test_changing_to_minus_1_asks_confirmation(self):
-        r = self.client.PxST('/alice/edit/goal', {'goal': '-1'}, auth_as=self.alice)
-        assert "Warning: Doing this will remove all the tips you are currently receiving." in r.text
-
     def test_wonky_custom_amounts_are_rejected(self):
         r = self.change_goal("custom", ",100,100.0")
         assert r.code == 400
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
@@ -63,7 +63,7 @@ def test_zero_cross_tip_doesnt_become_self_tip(self):
         alice.take_over(bob, have_confirmation=True)
         self.db.self_check()
 
-    def test_do_not_take_over_zero_tips_receiving(self):
+    def test_do_not_take_over_stopped_tips_receiving(self):
         alice = self.make_participant('alice')
         bob = self.make_participant('bob')
         carl = self.make_elsewhere('twitter', 3, 'carl')
@@ -81,7 +81,7 @@ def test_consolidated_tips_receiving(self):
         alice.set_tip_to(bob, EUR('1.00'))  # funded
         alice.set_tip_to(carl.participant, EUR('5.00'))  # not funded
         bob.take_over(carl, have_confirmation=True)
-        tips = self.db.all("select * from tips where amount > 0 order by id asc")
+        tips = self.db.all("select * from tips where renewal_mode > 0 order by id asc")
         assert len(tips) == 3
         assert tips[-1].amount == 5
         assert tips[-1].is_funded is False
diff --git a/tests/py/test_tip_json.py b/tests/py/test_tip_json.py
--- a/tests/py/test_tip_json.py
+++ b/tests/py/test_tip_json.py
@@ -79,7 +79,7 @@ def test_tip_to_unclaimed(self):
         response = self.tip(bob, alice.participant.username, "0.00")
         data = json.loads(response.text)
         assert response.code == 200
-        assert data['amount'] == {"amount": "0.00", "currency": "EUR"}
+        assert data['renewal_mode'] == 0
         assert "alice" in data['msg']
 
     def test_set_tip_standard_amount(self):