Skip to content

Commit

Permalink
implement temporal symmetry for donations to teams
Browse files Browse the repository at this point in the history
  • Loading branch information
Changaco committed May 28, 2017
1 parent fa3ed3c commit ac2b967
Show file tree
Hide file tree
Showing 2 changed files with 313 additions and 1 deletion.
78 changes: 77 additions & 1 deletion liberapay/billing/payday.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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."

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
236 changes: 236 additions & 0 deletions tests/py/test_payday.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit ac2b967

Please sign in to comment.