


@ 1,13 +1,110 @@





# py.test3 logclilevel=DEBUG v k test_share_income share.py





# py.test3 logclilevel=DEBUG vv share.py










import copy





import math





import logging





import itertools










#





# Implementation of the Hostea revenue sharing model





# See https://forum.hostea.org/t/decisionrevenuesharingmodel/92





#





# The members argument is a list of Hostea members, represented as a list:





# [





# [





# ID, # username in the https://gitea.hostea.org/Hostea/organization repository





# EXPENSE, # integer, expense elligible for payment





# INCOME, # integer, total available income





# ],





# ...





# ]





#





# The function returns which share of the pending expenses each member





# is allowed to redeem and, if necessary, how much to invoice other





# peer members to get the rest.





#





# share_income(members) => (shares, payments)





#





# The 'shares' are as follows:





#





# [





# [ID, EXPENSE, INCOME],





# ...





# }





#





# For instance in the if the 'members' argument is [['isabelle', 200, 10]], the





# shares will be [['isabelle', 10, 10]] where the 200 expense was reduced to a





# share of 10 because there only is 10 income available.





#





# The 'payments' maps member IDs to the amount other peer members must be invoiced:





#





# {





# ID: [(ID, integer), (ID, integer), ...],





# ...





# }





#





# For instance in the following marie will invoice isable for 200





# and john for 50 while lucie will invoice john for 10:





#





# {





# 'marie': [('isabelle', 200), ('john', 50)],





# 'lucie': [('john', 10)],





# }





#





# If a member has both income and expenses, they are not required to invoice





# themselves, it is implicit. For instance, if the 'payable' return value is





# [['isabelle', 10, 10]], the 'payments' return value will be empty and isabelle





# will be expected to reduce both her income and her expenses by 10.





#





def share_income(members):





payable = payable_expenses(members)





payments = {





member[ID]: paying_members





for member, paying_members in distribute(copy.deepcopy(payable))





}





return (payable, payments)















def test_share_income():





members = [["a", 10, 5], ["b", 450, 25], ["c", 0, 0]]





payable, payments = share_income(members)





assert payable == [["a", 10, 5], ["b", 20, 25]]





assert payments == {"a": [("b", 5)]}















######################################################################





#





# Structure layout and utilities





#





######################################################################










ID = 0





EXPENSE = 1





INCOME = 2





OPTIMUM_TRANSACTIONS = 2















def total(members, position):





return sum(member[position] for member in members)















def total_expense(members):





return total(members, EXPENSE)















def total_income(members):





return total(members, INCOME)















######################################################################





#





# Figure out the share each member is entitled to





#





######################################################################





#





# For a given income, return a list of the members who will not be paid in full





# and how much will remain in their expense balance.





#





# See https://forum.hostea.org/t/decisionrevenuesharingmodel/92





#





# The members list given in argument is a list of pairs of





#





# ['member', EXPENSE]




@ 22,12 +119,12 @@ EXPENSE = 1





# argument and only contains the members that cannot be paid in full





# with the income given in argument. For instance:





#





# share_income(3, [['a', 2], ['b', 2]]) == [['b', 1]]





# unpaid_expenses(3, [['a', 2], ['b', 2]]) == [['b', 1]]





#





# Means that with an income of 3, only member 'a' can be paid in full and





# the remaining expense for member 'b' is 1.





#





def share_income(income, members):





def unpaid_expenses(income, members):





if income <= 0:





#





# Recursion termination: no more income to share, none of the




@ 51,7 +148,9 @@ def share_income(income, members):





# income to give each member the lowest expense, it is divided





# evenly.





#





share = min(int(income / len(members)), min(members, key=lambda m: m[EXPENSE])[EXPENSE])





share = min(





int(income / len(members)), min(members, key=lambda m: m[EXPENSE])[EXPENSE]





)





count = len(members)





#





# remaining is the list of members that cannot be paid in full




@ 80,12 +179,298 @@ def share_income(income, members):





# case above) recurse, but only with the members that expect to be





# paid more.





#





return share_income(income  share * count, remaining)





return unpaid_expenses(income  share * count, remaining)










def test_share_income():





assert share_income(1, [['a', 1], ['b', 1]]) == [['b', 1]]





assert share_income(5, [['a', 10], ['b', 2]]) == [['a', 7]]





assert share_income(5, [['a', 2], ['b', 10], ['c', 1]]) == [['b', 8]]





assert share_income(5, [['a', 2], ['b', 10], ['c', 1], ['d', 40]]) == [['b', 9], ['d', 39]]





assert share_income(5, [['a', 2], ['b', 10], ['c', 1], ['d', 40], ['e', 3]]) == [['a', 1], ['b', 9], ['d', 39], ['e', 2]]





assert share_income(5, [['a', 2], ['b', 10], ['c', 1], ['d', 40], ['e', 3], ['f', 1]]) == [['a', 1], ['b', 9], ['d', 39], ['e', 2], ['f', 1]]










def test_unpaid_expenses():





assert unpaid_expenses(1, [["a", 1], ["b", 1]]) == [["b", 1]]





assert unpaid_expenses(5, [["a", 10], ["b", 2]]) == [["a", 7]]





assert unpaid_expenses(5, [["a", 2], ["b", 10], ["c", 1]]) == [["b", 8]]





assert unpaid_expenses(5, [["a", 2], ["b", 10], ["c", 1], ["d", 40]]) == [





["b", 9],





["d", 39],





]





assert unpaid_expenses(5, [["a", 2], ["b", 10], ["c", 1], ["d", 40], ["e", 3]]) == [





["a", 1],





["b", 9],





["d", 39],





["e", 2],





]





assert unpaid_expenses(





5, [["a", 2], ["b", 10], ["c", 1], ["d", 40], ["e", 3], ["f", 1]]





) == [["a", 1], ["b", 9], ["d", 39], ["e", 2], ["f", 1]]















def payable_expenses(members):





members = copy.deepcopy(members)





id2members = {m[ID]: m for m in members}





for unpaid in unpaid_expenses(





min(total_expense(members), total_income(members)), copy.deepcopy(members)





):





id2members[unpaid[ID]][EXPENSE] = unpaid[EXPENSE]





return [m for m in id2members.values() if m[EXPENSE] > 0 or m[INCOME] > 0]















def test_payable_expenses():





members = [["a", 1, 0], ["b", 1, 1], ["c", 0, 0]]





expected = [["a", 1, 0], ["b", 0, 1]]





assert payable_expenses(members) == expected















######################################################################





#





# Distribute the income from senders to receivers, nothing tricky here





#





######################################################################















def distribute_to_receivers(receivers, senders):





result = []





for receiver in receivers:





paying_members, senders = get_paid(receiver, senders)





result.append((receiver, paying_members))





return result















def test_distribute_to_receivers():





receivers = [["a", 5, 0], ["b", 10, 0]]





senders = [["c", 0, 12], ["d", 0, 3]]





assert distribute_to_receivers(receivers, senders) == [





(["a", 0, 0], [("c", 5)]),





(["b", 0, 0], [("c", 7), ("d", 3)]),





]










receivers = [["b", 12, 0], ["a", 5, 0]]





senders = [["c", 0, 10], ["d", 0, 5], ["e", 0, 1], ["f", 0, 1]]





expected = [





(["b", 0, 0], [("c", 10), ("d", 2)]),





(["a", 0, 0], [("d", 3), ("e", 1), ("f", 1)]),





]





assert distribute_to_receivers(receivers, senders) == expected










receivers = [["a", 5, 0], ["b", 12, 0]]





senders = [["c", 0, 10], ["d", 0, 5], ["e", 0, 1], ["f", 0, 1]]





expected = [





(["a", 0, 0], [("c", 5)]),





(["b", 0, 0], [("c", 5), ("d", 5), ("e", 1), ("f", 1)]),





]





assert distribute_to_receivers(receivers, senders) == expected















def get_paid(who, members):





paying_members = []





result = []





for member in sorted(members, key=lambda m: m[INCOME], reverse=True):





pay = min(member[INCOME], who[EXPENSE])





if pay > 0:





paying_members.append((member[ID], pay))





member[INCOME] = pay





who[EXPENSE] = pay





result.append(member)





assert who[EXPENSE] == 0





return (paying_members, result)















def test_get_paid():





orig = [["a", 10, 0], ["c", 0, 6], ["b", 0, 10]]





paying_members, modified = get_paid(orig[0], orig)





assert paying_members == [("b", 10)]





assert modified == [["b", 0, 0], ["c", 0, 6], ["a", 0, 0]]










orig = [["a", 10, 0], ["c", 0, 6], ["b", 0, 4]]





paying_members, modified = get_paid(orig[0], orig)





assert paying_members == [("c", 6), ("b", 4)]





assert modified == [["c", 0, 0], ["b", 0, 0], ["a", 0, 0]]










orig = [["a", 10, 0], ["c", 0, 8], ["b", 0, 4], ["d", 2, 0]]





paying_members, modified = get_paid(orig[0], orig)





assert paying_members == [("c", 8), ("b", 2)]





assert modified == [["c", 0, 0], ["b", 0, 2], ["a", 0, 0], ["d", 2, 0]]





paying_members, modified = get_paid(orig[3], orig)





assert paying_members == [("b", 2)]





assert modified == [["b", 0, 0], ["a", 0, 0], ["c", 0, 0], ["d", 0, 0]]















def self_get_paid(members):





for member in members:





pay = min(member[INCOME], member[EXPENSE])





member[INCOME] = pay





member[EXPENSE] = pay





return [m for m in members if m[EXPENSE] > 0 or m[INCOME] > 0]















def test_self_get_paid():





members = [["a", 5, 10], ["b", 20, 10], ["c", 0, 30], ["d", 40, 0]]





expected = [["a", 0, 5], ["b", 10, 0], ["c", 0, 30], ["d", 40, 0]]





assert self_get_paid(members) == expected















######################################################################





#





# For each receiver (i.e. a member that will have expenses paid), set





# the number of transactions they would need in the ideal situation





# where they would get paid by the senders who have the most income





# before anyone else.





#





# It will be used to stop iterating as soon as an optimal situation





# is detected instead of exploring all permutations.





#





######################################################################















def set_optimum_transactions(receivers, senders):





result = []





for receiver in receivers:





expense = receiver[EXPENSE]





optimum = 0





for sender in sorted(senders, key=lambda m: m[INCOME], reverse=True):





optimum += 1





expense = sender[INCOME]





if expense <= 0:





break





assert expense <= 0





receiver[OPTIMUM_TRANSACTIONS] = optimum





result.append(receiver)





return result















def test_set_optimum_transactions():





receivers = [["a", 5, 0], ["b", 12, 0]]





senders = [["c", 0, 10], ["d", 0, 5]]





expected = [["a", 5, 1], ["b", 12, 2]]





modified = set_optimum_transactions(receivers, senders)





assert modified == expected















def is_optimal(result):





for receiver, paying_members in result:





if receiver[OPTIMUM_TRANSACTIONS] > len(paying_members):





return False





return True















######################################################################





#





# Computes which members must be invoiced to pay the expenses to other





# members, with a minimum number of transactions. The algorithm is based





# on the following assumptions:





#





# * The total of all expenses is not greater than the total of all





# incomes.





#





# * The members with are sorted with greater income first and





# permutations where a better solution could be found with a





# different ordering are ignored.





#





# * Iterating over the permutations of the list of members that are





# allowed to get a share will produce a solution that is good enough.





#





# * An optimal solution (see is_optimal) will be found before





# iterating over a significant part of the permutations when there





# is a large (>10) number of members.





#





# The members argument is a list of Hostea members, represented as a list:





# [





# [





# ID, # username in the https://gitea.hostea.org/Hostea/organization repository





# EXPENSE, # integer, expense elligible for payment





# INCOME, # integer, total available income





# ],





# ...





# ]





#





#





######################################################################















def distribute(members):





members = self_get_paid(members)





senders = [m for m in members if m[INCOME] > 0]





receivers = [m for m in members if m[EXPENSE] > 0]





receivers = set_optimum_transactions(receivers, senders)





logging.debug(





f"distribute {receivers} to {senders} with at most {math.factorial(len(receivers))} iterations"





)





results = []





iter = 0





for receivers in itertools.permutations(receivers):





iter += 1





result = distribute_to_receivers(receivers, senders)





#





# There may be other solutions but since this one is optimal,





# no need to continue





#





if is_optimal(result):





logging.debug(f"found optimal result after {iter} iterations")





return result





results.append(result)





return best_result(results)















def test_distribute():





members = [["a", 10, 5], ["b", 20, 25]]





assert distribute(members) == [(["a", 0, 1], [("b", 5)])]










members = [["a", 10, 5], ["b", 20, 28], ["c", 3, 0]]





assert distribute(members) == [(["a", 0, 1], [("b", 5)]), (["c", 0, 1], [("b", 3)])]










members = [["a", 10, 5], ["b", 20, 280], ["c", 3, 0]]





assert distribute(members) == [(["a", 0, 1], [("b", 5)]), (["c", 0, 1], [("b", 3)])]










members = [





["a", 10, 5],





["b", 20, 280],





["c", 3, 0],





["d", 3, 0],





["e", 3, 0],





["f", 3, 0],





["g", 3, 0],





["h", 3, 0],





["i", 3, 0],





["j", 3, 0],





["k", 3, 0],





]





expected = [





(["a", 0, 1], [("b", 5)]),





(["c", 0, 1], [("b", 3)]),





(["d", 0, 1], [("b", 3)]),





(["e", 0, 1], [("b", 3)]),





(["f", 0, 1], [("b", 3)]),





(["g", 0, 1], [("b", 3)]),





(["h", 0, 1], [("b", 3)]),





(["i", 0, 1], [("b", 3)]),





(["j", 0, 1], [("b", 3)]),





(["k", 0, 1], [("b", 3)]),





]





assert distribute(members) == expected















#





# The best result is the one with less transactions





#





def best_result(results):





best = None





best_transactions_count = None





for result in results:





transactions_count = sum(





[len(transactions) for receiver, transactions in result]





)





if (





best_transactions_count is None





or transactions_count < best_transactions_count





):





best_transactions_count = transactions_count





best = result





return best















def test_best_result():





result1 = [(["a", 0, 0], [("c", 5)]), (["b", 0, 0], [("c", 7), ("d", 3)])]





result2 = [(["a", 0, 0], [("c", 5)]), (["b", 0, 0], [("c", 7)])]





result3 = [(["a", 0, 0], [("c", 5)]), (["b", 0, 0], [("c", 6), ("d", 2), ("e", 2)])]





results = [





result1,





result2,





result3,





]





assert best_result(results) == result2




