From c4fceaa8e0bc87ae1475ad5e8f698e6f16652fe5 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sun, 30 Apr 2017 14:43:50 +0200 Subject: [PATCH 1/4] convert payday function resolve_takes to python --- liberapay/billing/payday.py | 121 +++++++++++++++++------------------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py index 6da77274f8..b5cffa1d4f 100644 --- a/liberapay/billing/payday.py +++ b/liberapay/billing/payday.py @@ -283,68 +283,6 @@ def prepare(cursor, ts_start): $$ LANGUAGE plpgsql; - -- Create a function to resolve many-to-many donations (team takes) - - CREATE OR REPLACE FUNCTION resolve_takes(team_id bigint) RETURNS void AS $$ - DECLARE - total_income numeric(35,2); - total_takes numeric(35,2); - takes_ratio numeric; - tips_ratio numeric; - tip record; - take record; - transfer_amount numeric(35,2); - our_tips CURSOR FOR - 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 - AND p.new_balance >= t.amount; - BEGIN - WITH funded AS ( - UPDATE payday_tips - SET is_funded = true - FROM payday_participants p - WHERE p.id = tipper - AND tippee = team_id - AND p.new_balance >= amount - RETURNING amount - ) - SELECT COALESCE(sum(amount), 0) FROM funded INTO total_income; - total_takes := ( - SELECT COALESCE(sum(t.amount), 0) - FROM payday_takes t - WHERE t.team = team_id - ); - IF (total_income = 0 OR total_takes = 0) THEN RETURN; END IF; - takes_ratio := min(total_income / total_takes, 1::numeric); - tips_ratio := min(total_takes / total_income, 1::numeric); - - DROP TABLE IF EXISTS our_takes; - 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; - - FOR tip IN our_tips LOOP - FOR take IN (SELECT * FROM our_takes ORDER BY member) LOOP - IF (take.amount = 0 OR tip.tipper = take.member) THEN - CONTINUE; - END IF; - transfer_amount := min(tip.amount, take.amount); - EXECUTE transfer(tip.tipper, take.member, transfer_amount, 'take', team_id, NULL); - 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; - RETURN; - END; - $$ LANGUAGE plpgsql; - - -- Create a function to pay invoices CREATE OR REPLACE FUNCTION pay_invoices() RETURNS void AS $$ @@ -388,14 +326,68 @@ def prepare(cursor, ts_start): @staticmethod def transfer_virtually(cursor): + cursor.run("SELECT settle_tip_graph();") + teams = cursor.all(""" + SELECT id FROM payday_participants WHERE kind = 'group'; + """) + for team_id in teams: + Payday.resolve_takes(cursor, team_id) cursor.run(""" - SELECT settle_tip_graph(); - SELECT resolve_takes(id) FROM payday_participants WHERE kind = 'group'; SELECT settle_tip_graph(); UPDATE payday_tips SET is_funded = false WHERE is_funded IS NULL; SELECT pay_invoices(); """) + @staticmethod + def resolve_takes(cursor, team_id): + """Resolve many-to-many donations (team takes) + """ + args = dict(team_id=team_id) + total_income = cursor.one(""" + WITH funded AS ( + UPDATE payday_tips + SET is_funded = true + FROM payday_participants p + WHERE p.id = tipper + AND tippee = %(team_id)s + AND p.new_balance >= amount + RETURNING amount + ) + SELECT COALESCE(sum(amount), 0) FROM funded; + """, args) + total_takes = cursor.one(""" + SELECT COALESCE(sum(t.amount), 0) + FROM payday_takes t + WHERE t.team = %(team_id)s + """, args) + if total_income == 0 or total_takes == 0: + return + args['takes_ratio'] = min(total_income / total_takes, 1) + args['tips_ratio'] = min(total_takes / total_income, 1) + tips = [NS(t._asdict()) for t in cursor.all(""" + SELECT t.id, t.tipper, (round_up(t.amount * %(tips_ratio)s, 2)) AS amount + FROM payday_tips t + JOIN payday_participants p ON p.id = t.tipper + WHERE t.tippee = %(team_id)s + AND p.new_balance >= t.amount + """, args)] + takes = [NS(t._asdict()) for t in cursor.all(""" + SELECT t.member, (round_up(t.amount * %(takes_ratio)s, 2)) AS amount + FROM payday_takes t + WHERE t.team = %(team_id)s; + """, args)] + for tip in tips: + for take in takes: + if take.amount == 0 or tip.tipper == take.member: + continue + transfer_amount = min(tip.amount, take.amount) + cursor.run("SELECT transfer(%s, %s, %s, 'take', %s, NULL)", + (tip.tipper, take.member, transfer_amount, team_id)) + tip.amount -= transfer_amount + take.amount -= transfer_amount + if tip.amount == 0: + break + @staticmethod def check_balances(cursor): """Check that balances aren't becoming (more) negative @@ -432,7 +424,6 @@ def clean_up(self): DROP FUNCTION process_tip(); DROP FUNCTION settle_tip_graph(); DROP FUNCTION transfer(bigint, bigint, numeric, transfer_context, bigint, int); - DROP FUNCTION resolve_takes(bigint); DROP FUNCTION pay_invoices(); """) From 30834c7bbaf14d92e18770d2083c6ed1497dba33 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sun, 30 Apr 2017 17:36:23 +0200 Subject: [PATCH 2/4] convert payday function pay_invoices to python --- liberapay/billing/payday.py | 82 +++++++++++++++++-------------------- tests/py/test_payday.py | 6 +-- 2 files changed, 41 insertions(+), 47 deletions(-) diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py index b5cffa1d4f..4d87a687df 100644 --- a/liberapay/billing/payday.py +++ b/liberapay/billing/payday.py @@ -115,7 +115,7 @@ def shuffle(self, log_dir='.'): else: with self.db.get_cursor() as cursor: self.prepare(cursor, self.ts_start) - self.transfer_virtually(cursor) + self.transfer_virtually(cursor, self.ts_start) transfers = [NS(t._asdict()) for t in cursor.all(""" SELECT t.* , p.mangopay_user_id AS tipper_mango_id @@ -282,50 +282,11 @@ def prepare(cursor, ts_start): END; $$ LANGUAGE plpgsql; - - -- Create a function to pay invoices - - CREATE OR REPLACE FUNCTION pay_invoices() RETURNS void AS $$ - DECLARE - invoices_cursor CURSOR FOR - SELECT i.* - FROM invoices i - WHERE i.status = 'accepted' - AND ( SELECT ie.ts - FROM invoice_events ie - WHERE ie.invoice = i.id - ORDER BY ts DESC - LIMIT 1 - ) < %(ts_start)s; - payer_balance numeric(35,2); - BEGIN - FOR i IN invoices_cursor LOOP - payer_balance := ( - SELECT p.new_balance - FROM payday_participants p - WHERE id = i.addressee - ); - IF (payer_balance < i.amount) THEN - CONTINUE; - END IF; - EXECUTE transfer(i.addressee, i.sender, i.amount, - i.nature::text::transfer_context, - NULL, i.id); - UPDATE invoices - SET status = 'paid' - WHERE id = i.id; - INSERT INTO invoice_events - (invoice, participant, status) - VALUES (i.id, i.addressee, 'paid'); - END LOOP; - END; - $$ LANGUAGE plpgsql; - """, dict(ts_start=ts_start)) log("Prepared the DB.") @staticmethod - def transfer_virtually(cursor): + def transfer_virtually(cursor, ts_start): cursor.run("SELECT settle_tip_graph();") teams = cursor.all(""" SELECT id FROM payday_participants WHERE kind = 'group'; @@ -335,8 +296,8 @@ def transfer_virtually(cursor): cursor.run(""" SELECT settle_tip_graph(); UPDATE payday_tips SET is_funded = false WHERE is_funded IS NULL; - SELECT pay_invoices(); """) + Payday.pay_invoices(cursor, ts_start) @staticmethod def resolve_takes(cursor, team_id): @@ -388,6 +349,40 @@ def resolve_takes(cursor, team_id): if tip.amount == 0: break + @staticmethod + def pay_invoices(cursor, ts_start): + """Settle pending invoices + """ + invoices = cursor.all(""" + SELECT i.* + FROM invoices i + WHERE i.status = 'accepted' + AND ( SELECT ie.ts + FROM invoice_events ie + WHERE ie.invoice = i.id + ORDER BY ts DESC + LIMIT 1 + ) < %(ts_start)s; + """, dict(ts_start=ts_start)) + for i in invoices: + payer_balance = cursor.one(""" + SELECT p.new_balance + FROM payday_participants p + WHERE id = %s + """, (i.addressee,)) + if payer_balance < i.amount: + continue + cursor.run(""" + SELECT transfer(%(addressee)s, %(sender)s, %(amount)s, + %(nature)s::transfer_context, NULL, %(id)s); + UPDATE invoices + SET status = 'paid' + WHERE id = %(id)s; + INSERT INTO invoice_events + (invoice, participant, status) + VALUES (%(id)s, %(addressee)s, 'paid'); + """, i._asdict()) + @staticmethod def check_balances(cursor): """Check that balances aren't becoming (more) negative @@ -424,7 +419,6 @@ def clean_up(self): DROP FUNCTION process_tip(); DROP FUNCTION settle_tip_graph(); DROP FUNCTION transfer(bigint, bigint, numeric, transfer_context, bigint, int); - DROP FUNCTION pay_invoices(); """) @classmethod @@ -554,7 +548,7 @@ def update_cached_amounts(self): now = pando.utils.utcnow() with self.db.get_cursor() as cursor: self.prepare(cursor, now) - self.transfer_virtually(cursor) + self.transfer_virtually(cursor, now) cursor.run(""" UPDATE tips t diff --git a/tests/py/test_payday.py b/tests/py/test_payday.py index 42c522980d..e777e6d3a0 100644 --- a/tests/py/test_payday.py +++ b/tests/py/test_payday.py @@ -256,7 +256,7 @@ def test_payday_doesnt_process_tips_when_goal_is_negative(self): payday = Payday.start() with self.db.get_cursor() as cursor: payday.prepare(cursor, payday.ts_start) - payday.transfer_virtually(cursor) + payday.transfer_virtually(cursor, payday.ts_start) new_balances = self.get_new_balances(cursor) assert new_balances[self.janet.id] == 20 assert new_balances[self.homer.id] == 0 @@ -278,7 +278,7 @@ def test_transfer_tips(self): payday = Payday.start() with self.db.get_cursor() as cursor: payday.prepare(cursor, payday.ts_start) - payday.transfer_virtually(cursor) + payday.transfer_virtually(cursor, payday.ts_start) new_balances = self.get_new_balances(cursor) assert new_balances[self.david.id] == D('0.49') assert new_balances[self.janet.id] == D('0.51') @@ -296,7 +296,7 @@ def test_transfer_tips_whole_graph(self): payday = Payday.start() with self.db.get_cursor() as cursor: payday.prepare(cursor, payday.ts_start) - payday.transfer_virtually(cursor) + payday.transfer_virtually(cursor, payday.ts_start) new_balances = self.get_new_balances(cursor) assert new_balances[alice.id] == D('0') assert new_balances[self.homer.id] == D('30') From fa3ed3cc04bbe798338e735e5efa32a80518c872 Mon Sep 17 00:00:00 2001 From: Changaco Date: Fri, 26 May 2017 18:49:41 +0200 Subject: [PATCH 3/4] allow tests to skip parts of payday they don't need --- liberapay/billing/payday.py | 7 ++++--- tests/py/test_charts_json.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py index 4d87a687df..4e1ee7db96 100644 --- a/liberapay/billing/payday.py +++ b/liberapay/billing/payday.py @@ -67,7 +67,7 @@ def start(cls): payday.__dict__.update(d) return payday - def run(self, log_dir='.', keep_log=False): + def run(self, log_dir='.', keep_log=False, recompute_stats=10, update_cached_amounts=True): """This is the starting point for payday. It is structured such that it can be run again safely (with a @@ -83,8 +83,9 @@ def run(self, log_dir='.', keep_log=False): self.end() - self.recompute_stats(limit=10) - self.update_cached_amounts() + self.recompute_stats(limit=recompute_stats) + if update_cached_amounts: + self.update_cached_amounts() self.notify_participants() diff --git a/tests/py/test_charts_json.py b/tests/py/test_charts_json.py index 4218bac5b8..dd2b10361c 100644 --- a/tests/py/test_charts_json.py +++ b/tests/py/test_charts_json.py @@ -29,7 +29,7 @@ def setUp(self): self.bob.set_tip_to(self.carl, '2.00') def run_payday(self): - Payday.start().run() + Payday.start().run(recompute_stats=1) def test_no_payday_returns_empty_list(self): From ac2b967959d1fb3513787b1e25ad98deb6a52822 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sun, 28 May 2017 10:07:45 +0200 Subject: [PATCH 4/4] implement temporal symmetry for donations to teams --- liberapay/billing/payday.py | 78 +++++++++++- tests/py/test_payday.py | 236 ++++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 1 deletion(-) diff --git a/liberapay/billing/payday.py b/liberapay/billing/payday.py index 4e1ee7db96..0cd612a516 100644 --- a/liberapay/billing/payday.py +++ b/liberapay/billing/payday.py @@ -1,6 +1,9 @@ +# coding: utf8 + from __future__ import print_function, unicode_literals from datetime import date +from decimal import Decimal, ROUND_UP import os import os.path import pickle @@ -19,6 +22,10 @@ log = print +def round_up(d): + return d.quantize(constants.D_CENT, rounding=ROUND_UP) + + class NoPayday(Exception): __str__ = lambda self: "No payday found where one was expected." @@ -325,9 +332,18 @@ def resolve_takes(cursor, team_id): if total_income == 0 or total_takes == 0: return args['takes_ratio'] = min(total_income / total_takes, 1) - args['tips_ratio'] = min(total_takes / total_income, 1) + tips_ratio = args['tips_ratio'] = min(total_takes / total_income, 1) tips = [NS(t._asdict()) for t in cursor.all(""" SELECT t.id, t.tipper, (round_up(t.amount * %(tips_ratio)s, 2)) AS amount + , t.amount AS full_amount + , COALESCE(( + SELECT sum(tr.amount) + FROM transfers tr + WHERE tr.tipper = t.tipper + AND tr.team = %(team_id)s + AND tr.context = 'take' + AND tr.status = 'succeeded' + ), 0) AS past_transfers_sum FROM payday_tips t JOIN payday_participants p ON p.id = t.tipper WHERE t.tippee = %(team_id)s @@ -338,7 +354,67 @@ def resolve_takes(cursor, team_id): FROM payday_takes t WHERE t.team = %(team_id)s; """, args)] + adjust_tips = tips_ratio != 1 + if adjust_tips: + # The team has a leftover, so donation amounts can be adjusted. + # In the following loop we compute the "weeks" count of each tip. + # For example the `weeks` value is 2.5 for a donation currently at + # 10€/week which has distributed 25€ in the past. + for tip in tips: + tip.weeks = round_up(tip.past_transfers_sum / tip.full_amount) + max_weeks = max(tip.weeks for tip in tips) + min_weeks = min(tip.weeks for tip in tips) + adjust_tips = max_weeks != min_weeks + if adjust_tips: + # Some donors have given fewer weeks worth of money than others, + # we want to adjust the amounts so that the weeks count will + # eventually be the same for every donation. + min_tip_ratio = tips_ratio * Decimal('0.1') + # Loop: compute how many "weeks" each tip is behind the "oldest" + # tip, as well as a naive ratio and amount based on that number + # of weeks + for tip in tips: + tip.weeks_to_catch_up = max_weeks - tip.weeks + tip.ratio = min(min_tip_ratio + tip.weeks_to_catch_up, 1) + tip.amount = round_up(tip.full_amount * tip.ratio) + naive_amounts_sum = sum(tip.amount for tip in tips) + total_to_transfer = min(total_takes, total_income) + delta = total_to_transfer - naive_amounts_sum + if delta == 0: + # The sum of the naive amounts computed in the previous loop + # matches the end target, we got very lucky and no further + # adjustments are required + adjust_tips = False + else: + # Loop: compute the "leeway" of each tip, i.e. how much it + # can be increased or decreased to fill the `delta` gap + if delta < 0: + # The naive amounts are too high: we want to lower the + # amounts of the tips that have a "high" ratio, leaving + # untouched the ones that are already low + for tip in tips: + if tip.ratio > min_tip_ratio: + min_tip_amount = round_up(tip.full_amount * min_tip_ratio) + tip.leeway = min_tip_amount - tip.amount + else: + tip.leeway = 0 + else: + # The naive amounts are too low: we can raise all the + # tips that aren't already at their maximum + for tip in tips: + tip.leeway = tip.full_amount - tip.amount + leeway = sum(tip.leeway for tip in tips) + leeway_ratio = min(delta / leeway, 1) + tips = sorted(tips, key=lambda tip: (-tip.weeks_to_catch_up, tip.id)) + # Loop: compute the adjusted donation amounts, and do the transfers for tip in tips: + if adjust_tips: + tip_amount = round_up(tip.amount + tip.leeway * leeway_ratio) + if tip_amount == 0: + continue + assert tip_amount > 0 + assert tip_amount <= tip.full_amount + tip.amount = tip_amount for take in takes: if take.amount == 0 or tip.tipper == take.member: continue diff --git a/tests/py/test_payday.py b/tests/py/test_payday.py index e777e6d3a0..4c69e8c9e1 100644 --- a/tests/py/test_payday.py +++ b/tests/py/test_payday.py @@ -386,6 +386,242 @@ def test_wellfunded_team(self): } assert d == expected + def test_wellfunded_team_with_early_donor(self): + self.clear_tables() + team = self.make_participant('team', kind='group') + alice = self.make_participant('alice') + team.set_take_for(alice, D('0.79'), team) + bob = self.make_participant('bob') + team.set_take_for(bob, D('0.21'), team) + charlie = self.make_participant('charlie', balance=10) + charlie.set_tip_to(team, D('2.00')) + + print("> Step 1: three weeks of donations from charlie only") + print() + for i in range(3): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 3, + 'bob': D('0.21') * 3, + 'charlie': D('7.00'), + 'team': D('0.00'), + } + assert d == expected + + print("> Step 2: dan joins the party, charlie's donation is automatically " + "reduced while dan catches up") + print() + dan = self.make_participant('dan', balance=10) + dan.set_tip_to(team, D('2.00')) + + for i in range(6): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 9, + 'bob': D('0.21') * 9, + 'charlie': D('5.50'), + 'dan': D('5.50'), + 'team': D('0.00'), + } + assert d == expected + + print("> Step 3: dan has caught up with charlie, they will now both give 0.50") + print() + for i in range(3): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 12, + 'bob': D('0.21') * 12, + 'charlie': D('4.00'), + 'dan': D('4.00'), + 'team': D('0.00'), + } + assert d == expected + + def test_wellfunded_team_with_two_early_donors(self): + self.clear_tables() + team = self.make_participant('team', kind='group') + alice = self.make_participant('alice') + team.set_take_for(alice, D('0.79'), team) + bob = self.make_participant('bob') + team.set_take_for(bob, D('0.21'), team) + charlie = self.make_participant('charlie', balance=10) + charlie.set_tip_to(team, D('1.00')) + dan = self.make_participant('dan', balance=10) + dan.set_tip_to(team, D('3.00')) + + print("> Step 1: three weeks of donations from early donors") + print() + for i in range(3): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 3, + 'bob': D('0.21') * 3, + 'charlie': D('9.25'), + 'dan': D('7.75'), + 'team': D('0.00'), + } + assert d == expected + + print("> Step 2: a new donor appears, the contributions of the early " + "donors automatically decrease while the new donor catches up") + print() + emma = self.make_participant('emma', balance=10) + emma.set_tip_to(team, D('1.00')) + + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 4, + 'bob': D('0.21') * 4, + 'charlie': D('9.19'), + 'dan': D('7.59'), + 'emma': D('9.22'), + 'team': D('0.00'), + } + assert d == expected + + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 5, + 'bob': D('0.21') * 5, + 'charlie': D('8.99'), + 'dan': D('7.01'), + 'emma': D('9.00'), + 'team': D('0.00'), + } + assert d == expected + + print("> Step 3: emma has caught up with the early donors") + print() + + for i in range(2): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.79') * 7, + 'bob': D('0.21') * 7, + 'charlie': D('8.60'), + 'dan': D('5.80'), + 'emma': D('8.60'), + 'team': D('0.00'), + } + assert d == expected + + def test_wellfunded_team_with_two_early_donors_and_low_amounts(self): + self.clear_tables() + team = self.make_participant('team', kind='group') + alice = self.make_participant('alice') + team.set_take_for(alice, D('0.01'), team) + bob = self.make_participant('bob') + team.set_take_for(bob, D('0.01'), team) + charlie = self.make_participant('charlie', balance=10) + charlie.set_tip_to(team, D('0.02')) + dan = self.make_participant('dan', balance=10) + dan.set_tip_to(team, D('0.02')) + + print("> Step 1: three weeks of donations from early donors") + print() + for i in range(3): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.01') * 3, + 'bob': D('0.01') * 3, + 'charlie': D('9.97'), + 'dan': D('9.97'), + 'team': D('0.00'), + } + assert d == expected + + print("> Step 2: a new donor appears, the contributions of the early " + "donors automatically decrease while the new donor catches up") + print() + emma = self.make_participant('emma', balance=10) + emma.set_tip_to(team, D('0.02')) + + for i in range(6): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.01') * 9, + 'bob': D('0.01') * 9, + 'charlie': D('9.94'), + 'dan': D('9.94'), + 'emma': D('9.94'), + 'team': D('0.00'), + } + assert d == expected + + def test_wellfunded_team_with_early_donor_and_small_leftover(self): + self.clear_tables() + team = self.make_participant('team', kind='group') + alice = self.make_participant('alice') + team.set_take_for(alice, D('0.50'), team) + bob = self.make_participant('bob') + team.set_take_for(bob, D('0.50'), team) + charlie = self.make_participant('charlie', balance=10) + charlie.set_tip_to(team, D('0.52')) + + print("> Step 1: three weeks of donations from early donor") + print() + for i in range(3): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.26') * 3, + 'bob': D('0.26') * 3, + 'charlie': D('8.44'), + 'team': D('0.00'), + } + assert d == expected + + print("> Step 2: a new donor appears, the contribution of the early " + "donor automatically decreases while the new donor catches up, " + "but the leftover is small so the adjustments are limited") + print() + dan = self.make_participant('dan', balance=10) + dan.set_tip_to(team, D('0.52')) + + for i in range(3): + Payday.start().run(recompute_stats=0, update_cached_amounts=False) + print() + + d = dict(self.db.all("SELECT username, balance FROM participants")) + expected = { + 'alice': D('0.26') * 3 + D('0.50') * 3, + 'bob': D('0.26') * 3 + D('0.50') * 3, + 'charlie': D('7.00'), + 'dan': D('8.44'), + 'team': D('0.00'), + } + assert d == expected + def test_mutual_tipping_through_teams(self): self.clear_tables() team = self.make_participant('team', kind='group')