Repository: liberapay/liberapay.com

Issue:
Fix computation of team takes
https://github.com/liberapay/salon/issues/16#issuecomment-188231148

Fix payday IDs
https://github.com/liberapay/salon/issues/12#issuecomment-185116663



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
@@ -38,17 +38,22 @@ def start(cls):
         """
         try:
             d = cls.db.one("""
-                INSERT INTO paydays DEFAULT VALUES
+                INSERT INTO paydays (id) VALUES (COALESCE((
+                     SELECT id
+                       FROM paydays
+                   ORDER BY id DESC
+                      LIMIT 1
+                ), 0) + 1)
                 RETURNING id, (ts_start AT TIME ZONE 'UTC') AS ts_start
             """, back_as=dict)
-            log("Starting a new payday.")
+            log("Starting payday #%s." % d['id'])
         except IntegrityError:  # Collision, we have a Payday already.
             d = cls.db.one("""
                 SELECT id, (ts_start AT TIME ZONE 'UTC') AS ts_start
                   FROM paydays
                  WHERE ts_end='1970-01-01T00:00:00+00'::timestamptz
             """, back_as=dict)
-            log("Picking up with an existing payday.")
+            log("Picking up payday #%s." % d['id'])
 
         d['ts_start'] = d['ts_start'].replace(tzinfo=aspen.utils.utc)
 
@@ -235,11 +240,11 @@ def prepare(cursor, ts_start):
                 UPDATE payday_participants
                    SET new_balance = (new_balance - $3)
                  WHERE id = $1;
-                IF (NOT FOUND) THEN RAISE 'tipper not found'; END IF;
+                IF (NOT FOUND) THEN RAISE 'tipper %% not found', $1; END IF;
                 UPDATE payday_participants
                    SET new_balance = (new_balance + $3)
                  WHERE id = $2;
-                IF (NOT FOUND) THEN RAISE 'tippee not found'; END IF;
+                IF (NOT FOUND) THEN RAISE 'tippee %% not found', $2; END IF;
                 INSERT INTO payday_transfers
                             (tipper, tippee, amount, context, team)
                      VALUES ($1, $2, $3, $4, $5);
@@ -306,16 +311,16 @@ def prepare(cursor, ts_start):
             DECLARE
                 total_income numeric(35,2);
                 total_takes numeric(35,2);
-                ratio numeric;
+                takes_ratio numeric;
+                tips_ratio numeric;
                 tip record;
                 take record;
-                amount numeric(35,2);
+                transfer_amount numeric(35,2);
                 our_tips CURSOR FOR
-                    SELECT t.id, t.tipper, (round_up(t.amount * ratio, 2)) AS amount
+                    SELECT t.id, t.tipper, (round_up(t.amount * tips_ratio, 2)) AS amount
                       FROM payday_tips t
                       JOIN payday_participants p ON p.id = t.tipper
                      WHERE t.tippee = team_id;
-                our_takes refcursor;
             BEGIN
                 total_income := (
                     SELECT sum(t.amount)
@@ -330,27 +335,26 @@ def prepare(cursor, ts_start):
                      WHERE t.team = team_id
                 );
                 IF (total_income = 0 OR total_takes = 0) THEN RETURN; END IF;
-                ratio := min(total_income / total_takes, 1::numeric);
+                takes_ratio := min(total_income / total_takes, 1::numeric);
+                tips_ratio := min(total_takes / total_income, 1::numeric);
 
-                OPEN our_takes FOR
-                    SELECT t.member, (round_up(t.amount * ratio, 2)) AS amount
+                CREATE TEMPORARY TABLE our_takes ON COMMIT DROP AS
+                    SELECT t.member, (round_up(t.amount * takes_ratio, 2)) AS amount
                       FROM payday_takes t
-                     WHERE t.team = team_id
-                  ORDER BY t.member;
-                FETCH our_takes INTO take;
+                     WHERE t.team = team_id;
+
                 FOR tip IN our_tips LOOP
-                    LOOP
-                        IF (take.amount = 0) THEN
-                            FETCH our_takes INTO take;
-                            IF (take IS NULL) THEN RETURN; END IF;
+                    FOR take IN (SELECT * FROM our_takes ORDER BY member) LOOP
+                        IF (take.amount = 0 OR tip.tipper = take.member) THEN
+                            CONTINUE;
                         END IF;
-                        amount := min(tip.amount, take.amount);
-                        IF (tip.tipper <> take.member) THEN
-                            UPDATE payday_tips SET is_funded = true WHERE id = tip.id;
-                            EXECUTE transfer(tip.tipper, take.member, amount, 'take', team_id);
-                        END IF;
-                        tip.amount := tip.amount - amount;
-                        take.amount := take.amount - amount;
+                        transfer_amount := min(tip.amount, take.amount);
+                        UPDATE payday_tips SET is_funded = true WHERE id = tip.id;
+                        EXECUTE transfer(tip.tipper, take.member, transfer_amount, 'take', team_id);
+                        tip.amount := tip.amount - transfer_amount;
+                        UPDATE our_takes t
+                           SET amount = take.amount - transfer_amount
+                         WHERE t.member = take.member;
                         EXIT WHEN tip.amount = 0;
                     END LOOP;
                 END LOOP;
@@ -563,7 +567,7 @@ def notify_participants(self):
             p.notify('low_balance')
 
 
-if __name__ == '__main__':  # pragma: no cover
+def main():
     from os import environ
 
     from liberapay import wireup
@@ -589,3 +593,7 @@ def notify_participants(self):
     except:
         import traceback
         traceback.print_exc()
+
+
+if __name__ == '__main__':  # pragma: no cover
+    main()
diff --git a/liberapay/models/__init__.py b/liberapay/models/__init__.py
--- a/liberapay/models/__init__.py
+++ b/liberapay/models/__init__.py
@@ -1,6 +1,10 @@
+from __future__ import print_function
+
 from contextlib import contextmanager
+import re
 
 from postgres import Postgres
+from postgres.cursors import SimpleCursorBase
 
 
 @contextmanager
@@ -135,6 +139,27 @@ def run_migrations(db):
     return n - v
 
 
+def show_table(db, table):
+    assert re.match(r'^\w+$', table)
+    print('\n{:=^80}'.format(table))
+    data = db.all('select * from '+table)
+    if len(data) == 0:
+        return
+    widths = list(len(k) for k in data[0]._fields)
+    for row in data:
+        for i, v in enumerate(row):
+            widths[i] = max(widths[i], len(str(v)))
+    for k, w in zip(data[0]._fields, widths):
+        print("{0:{width}}".format(str(k), width=w), end=' | ')
+    print()
+    for row in data:
+        for v, w in zip(row, widths):
+            print("{0:{width}}".format(str(v), width=w), end=' | ')
+        print()
+
+SimpleCursorBase.show_table = show_table
+
+
 if __name__ == '__main__':
     from liberapay import wireup
     env = wireup.env()


Test patch:
diff --git a/liberapay/testing/__init__.py b/liberapay/testing/__init__.py
--- a/liberapay/testing/__init__.py
+++ b/liberapay/testing/__init__.py
@@ -163,24 +163,6 @@ def make_elsewhere(self, platform, user_id, user_name, **kw):
         return AccountElsewhere.upsert(info)
 
 
-    def show_table(self, table):
-        print('\n{:=^80}'.format(table))
-        data = self.db.all('select * from '+table, back_as='namedtuple')
-        if len(data) == 0:
-            return
-        widths = list(len(k) for k in data[0]._fields)
-        for row in data:
-            for i, v in enumerate(row):
-                widths[i] = max(widths[i], len(str(v)))
-        for k, w in zip(data[0]._fields, widths):
-            print("{0:{width}}".format(str(k), width=w), end=' | ')
-        print()
-        for row in data:
-            for v, w in zip(row, widths):
-                print("{0:{width}}".format(str(v), width=w), end=' | ')
-            print()
-
-
     def make_participant(self, username, **kw):
         platform = kw.pop('elsewhere', 'github')
         kw2 = {}
diff --git a/tests/py/test_billing_payday.py b/tests/py/test_billing_payday.py
--- a/tests/py/test_billing_payday.py
+++ b/tests/py/test_billing_payday.py
@@ -5,7 +5,7 @@
 
 import mock
 
-from liberapay.billing.payday import NoPayday, Payday
+from liberapay.billing.payday import main, NoPayday, Payday
 from liberapay.exceptions import NegativeBalance
 from liberapay.models.participant import Participant
 from liberapay.testing.mangopay import FakeTransfersHarness, MangopayHarness
@@ -14,6 +14,13 @@
 
 class TestPayday(EmailHarness, FakeTransfersHarness, MangopayHarness):
 
+    def test_payday_id_is_serial(self):
+        for i in range(1, 4):
+            self.db.run("SELECT nextval('paydays_id_seq')")
+            main()
+            id = self.db.one("SELECT id FROM paydays ORDER BY id DESC LIMIT 1")
+            assert id == i
+
     def test_payday_moves_money(self):
         self.janet.set_tip_to(self.homer, '6.00')  # under $10!
         self.make_exchange('mango-cc', 10, 0, self.janet)
@@ -127,7 +134,7 @@ def test_start_prepare(self, log):
             participants = get_participants(cursor)
 
         expected_logging_call_args = [
-            ('Starting a new payday.'),
+            ('Starting payday #1.'),
             ('Payday started at {}.'.format(ts_start)),
             ('Prepared the DB.'),
         ]
@@ -153,7 +160,7 @@ def test_start_prepare(self, log):
         assert participants == second_participants
 
         expected_logging_call_args = [
-            ('Picking up with an existing payday.'),
+            ('Picking up payday #1.'),
             ('Payday started at {}.'.format(second_ts_start)),
             ('Prepared the DB.'),
         ]
@@ -277,22 +284,72 @@ def test_transfer_takes(self):
             else:
                 assert p.balance == 0
 
+    def test_underfunded_team(self):
+        self.clear_tables()
+        team = self.make_participant('team', kind='group')
+        alice = self.make_participant('alice')
+        team.set_take_for(alice, D('1.00'), alice)
+        bob = self.make_participant('bob')
+        team.set_take_for(bob, D('1.00'), bob)
+        charlie = self.make_participant('charlie', balance=1000)
+        charlie.set_tip_to(team, D('0.26'))
+
+        Payday.start().run()
+
+        d = dict(self.db.all("SELECT username, balance FROM participants"))
+        expected = {
+            'alice': D('0.13'),
+            'bob': D('0.13'),
+            'charlie': D('999.74'),
+            'team': D('0.00'),
+        }
+        assert d == expected
+
+    def test_wellfunded_team(self):
+        """
+        This tests two properties:
+        - takes are maximums
+        - donors all pay their share, the first donor doesn't pay everything
+        """
+        self.clear_tables()
+        team = self.make_participant('team', kind='group')
+        alice = self.make_participant('alice')
+        team.set_take_for(alice, D('0.79'), alice)
+        bob = self.make_participant('bob')
+        team.set_take_for(bob, D('0.21'), bob)
+        charlie = self.make_participant('charlie', balance=10)
+        charlie.set_tip_to(team, D('5.00'))
+        dan = self.make_participant('dan', balance=10)
+        dan.set_tip_to(team, D('5.00'))
+
+        Payday.start().run()
+
+        d = dict(self.db.all("SELECT username, balance FROM participants"))
+        expected = {
+            'alice': D('0.79'),
+            'bob': D('0.21'),
+            'charlie': D('9.5'),
+            'dan': D('9.5'),
+            'team': D('0.00'),
+        }
+        assert d == expected
+
     def test_mutual_tipping_through_teams(self):
         self.clear_tables()
         team = self.make_participant('team', kind='group')
         alice = self.make_participant('alice', balance=8)
-        alice.set_tip_to(team, D('0.25'))
-        team.set_take_for(alice, D('1.00'), alice)
+        alice.set_tip_to(team, D('2.00'))
+        team.set_take_for(alice, D('0.25'), alice)
         bob = self.make_participant('bob', balance=10)
-        bob.set_tip_to(team, D('3.17'))
-        team.set_take_for(bob, D('0.48'), bob)
+        bob.set_tip_to(team, D('2.00'))
+        team.set_take_for(bob, D('0.75'), bob)
 
         Payday.start().run()
 
         d = dict(self.db.all("SELECT username, balance FROM participants"))
         expected = {
-            'alice': D('8.75'),  # 8 - 0.25 + 1.00
-            'bob': D('9.25'),  # 10 - 1.00 + 0.25
+            'alice': D('7.75'),  # 8 - 0.50 + 0.25
+            'bob': D('10.25'),  # 10 - 0.25 + 0.50
             'team': D('0.00'),
         }
         assert d == expected