diff --git a/ihatemoney/models.py b/ihatemoney/models.py index af21994d..9ef8780b 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -113,42 +113,57 @@ class Project(db.Model): @property def full_balance(self): - """Returns a triple of dicts: + """Returns a tuple of dicts: - - dict mapping each member to its balance + - dict mapping each member to its overall balance - - dict mapping each member to how much he/she should pay others - (i.e. how much he/she benefited from bills) + - dict mapping each member to its expenses (i.e. how much he/she + benefited from all bills, whoever actually paid) - - dict mapping each member to how much he/she should be paid by - others (i.e. how much he/she has paid for bills) + - dict mapping each member to how much he/she has paid for bills + + - dict mapping each member to how much he/she has transferred + money to other members + + - dict mapping each member to how much he/she has received money + from other members + + balance, spent, paid, transferred, received - balance spent paid """ - balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3)) + balances, spent, paid, transferred, received = ( + defaultdict(float) for _ in range(5) + ) for bill in self.get_bills_unordered().all(): total_weight = sum(ower.weight for ower in bill.owers) if bill.bill_type == BillType.EXPENSE: - should_receive[bill.payer.id] += bill.converted_amount + paid[bill.payer.id] += bill.converted_amount for ower in bill.owers: - should_pay[ower.id] += ( + spent[ower.id] += ower.weight * bill.converted_amount / total_weight + + if bill.bill_type == BillType.REIMBURSEMENT: + transferred[bill.payer.id] += bill.converted_amount + for ower in bill.owers: + received[ower.id] += ( ower.weight * bill.converted_amount / total_weight ) - if bill.bill_type == BillType.REIMBURSEMENT: - should_receive[bill.payer.id] += bill.converted_amount - for ower in bill.owers: - should_receive[ower.id] -= bill.converted_amount - for person in self.members: - balance = should_receive[person.id] - should_pay[person.id] + balance = ( + paid[person.id] + - spent[person.id] + + transferred[person.id] + - received[person.id] + ) balances[person.id] = balance return ( balances, - should_pay, - should_receive, + spent, + paid, + transferred, + received, ) @property @@ -157,17 +172,19 @@ class Project(db.Model): @property def members_stats(self): - """Compute what each participant has paid + """Compute what each participant has spent, paid, transferred and received :return: one stat dict per participant :rtype list: """ - balance, spent, paid = self.full_balance + balance, spent, paid, transferred, received = self.full_balance return [ { "member": member, + "spent": -1.0 * spent[member.id], "paid": paid[member.id], - "spent": spent[member.id], + "transferred": transferred[member.id], + "received": -1.0 * received[member.id], "balance": balance[member.id], } for member in self.active_members diff --git a/ihatemoney/templates/statistics.html b/ihatemoney/templates/statistics.html index 86f9cd42..e3e1b78f 100644 --- a/ihatemoney/templates/statistics.html +++ b/ihatemoney/templates/statistics.html @@ -1,38 +1,48 @@ -{% extends "sidebar_table_layout.html" %} - -{% block sidebar %} -
- {{ balance_table(show_weight=False, show_header=True) }} -
-{% endblock %} - - -{% block content %} -
- - - - {% for stat in members_stats|sort(attribute='member.name') %} - - - - - - {% endfor %} - -
{{ _("Who?") }}{{ _("Paid") }}{{ _("Spent") }}
{{ stat.member.name }}{{ stat.paid|currency }}{{ stat.spent|currency }}
-

{{ _("Expenses by Month") }}

- - - - {% for month in months %} - - - - - {% endfor %} - -
{{ _("Period") }}{{ _("Spent") }}
{{ month|dateformat("MMMM yyyy") }}{{ monthly_stats[month.year][month.month]|currency }}
-
+{% extends "sidebar_table_layout.html" %} {% block sidebar %} +
+ {{ balance_table(show_weight=False, show_header=True) }} +
+{% endblock %} {% block content %} +
+ + + + + + + + + + + + {% for stat in members_stats|sort(attribute='member.name') %} + + + + + + + + {% endfor %} + +
{{ _("Who?") }}{{ _("Paid") }}{{ _("Expenses") }}{{ _("Direct transfer") }}{{ _("Received") }}
{{ stat.member.name }}{{ stat.paid|currency }}{{ stat.spent|currency }}{{ stat.transferred|currency }}{{ stat.received|currency }}
+

{{ _("Expenses by month") }}

+ + + + + + + + + {% for month in months %} + + + + + {% endfor %} + +
{{ _("Period") }}{{ _("Expenses") }}
{{ month|dateformat("MMMM yyyy") }}{{ monthly_stats[month.year][month.month]|currency }}
+
{% endblock %} diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py index c676e3e3..40054f75 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -814,7 +814,8 @@ class TestAPI(IhatemoneyTestCase): "/api/projects/raclette/statistics", headers=self.get_auth("raclette") ) self.assertStatus(200, req) - assert [ + received_stats = json.loads(req.data.decode("utf-8")) + assert received_stats == [ { "balance": 12.5, "member": { @@ -824,7 +825,9 @@ class TestAPI(IhatemoneyTestCase): "weight": 1.0, }, "paid": 25.0, - "spent": 12.5, + "received": 0.0, + "spent": -12.5, + "transferred": 0.0, }, { "balance": -12.5, @@ -834,10 +837,12 @@ class TestAPI(IhatemoneyTestCase): "name": "jeanne", "weight": 1.0, }, - "paid": 0, - "spent": 12.5, + "paid": 0.0, + "received": 0.0, + "spent": -12.5, + "transferred": 0.0, }, - ] == json.loads(req.data.decode("utf-8")) + ] def test_username_xss(self): # create a project diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 732535bc..b541ecbd 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -790,15 +790,18 @@ class TestBudget(IhatemoneyTestCase): self.client.post("/rent/members/add", data={"name": "bob"}) self.client.post("/rent/members/add", data={"name": "alice"}) - members_ids = [m.id for m in self.get_project("rent").members] - # create a bill to test reimbursement + everybody = [m.id for m in self.get_project("rent").members] + bob = everybody[0] + alice = everybody[1] + + # create a bill self.client.post( "/rent/add", data={ "date": "2022-12-12", "what": "december rent", - "payer": members_ids[0], # bob - "payed_for": members_ids, # bob and alice + "payer": bob, + "payed_for": everybody, "bill_type": "Expense", "amount": "1000", }, @@ -806,32 +809,40 @@ class TestBudget(IhatemoneyTestCase): # check balance balance = self.get_project("rent").balance assert set(balance.values()), set([500 == -500]) - # check paid - bob_paid = self.get_project("rent").full_balance[2][members_ids[0]] - alice_paid = self.get_project("rent").full_balance[2][members_ids[1]] + + project = self.get_project("rent") + bob_paid = project.full_balance[2][bob] + alice_paid = project.full_balance[2][alice] assert bob_paid == 1000 assert alice_paid == 0 - # test reimbursement bill + # reimbursement bill self.client.post( "/rent/add", data={ "date": "2022-12-13", "what": "reimbursement for rent", - "payer": members_ids[1], # alice - "payed_for": members_ids[0], # bob + "payer": alice, + "payed_for": bob, "bill_type": "Reimbursement", "amount": "500", }, ) - balance = self.get_project("rent").balance + balance = project.balance assert set(balance.values()), set([0 == 0]) - # check paid - bob_paid = self.get_project("rent").full_balance[2][members_ids[0]] - alice_paid = self.get_project("rent").full_balance[2][members_ids[1]] - assert bob_paid == 500 - assert alice_paid == 500 + + # After the reimbursement, the full balance should be populated with + # transfer items + bob_paid = project.full_balance[2][bob] + alice_paid = project.full_balance[2][alice] + assert bob_paid == 1000 + assert alice_paid == 0 + + bob_received = project.full_balance[4][bob] + alice_transferred = project.full_balance[3][alice] + assert bob_received == 500 + assert alice_transferred == 500 def test_weighted_balance(self): self.post_project("raclette") @@ -1069,14 +1080,25 @@ class TestBudget(IhatemoneyTestCase): assert len(project.active_months_range()) == 0 assert len(project.monthly_stats) == 0 - # Check that the "monthly expenses" table is empty + # Check that the "monthly expenses" table exists + # and is empty. response = self.client.get("/raclette/statistics") - regex = ( - r"\s*\s*\s*\s*" - r"\s*\s*\s*\s*\s*
PeriodSpent
" - ) - assert re.search(regex, response.data.decode("utf-8")) + regex = ( + r'\n' + r" \n" + r" \n" + r" \n" + r" \n" + r" \n" + r" \n" + r" \n" + r" \n" + r" \n" + r"
PeriodExpenses
" + ) + + assert re.search(regex, response.data.decode("utf-8")) # create bills self.client.post( "/raclette/add", @@ -1115,22 +1137,29 @@ class TestBudget(IhatemoneyTestCase): ) response = self.client.get("/raclette/statistics") - regex = r"{}\s*{}\s*{}" - assert re.search( - regex.format("zorglub", r"\$20\.00", r"\$31\.67"), - response.data.decode("utf-8"), - ) - assert re.search( - regex.format("jeanne", r"\$20\.00", r"\$5\.83"), - response.data.decode("utf-8"), - ) - assert re.search( - regex.format("tata", r"\$0\.00", r"\$2\.50"), response.data.decode("utf-8") - ) - assert re.search( - regex.format("pépé", r"\$0\.00", r"\$0\.00"), response.data.decode("utf-8") - ) + html = response.data.decode("utf-8") + def stat_entry(name, paid, spent, transferred=None, received=None): + return ( + f'{name}\n' + f" {paid}\n" + f" {spent}\n" + # f" ${spent}\n" + # f" ${transferred}" + ) + + # set_trace() + + # regex = ( + # r'\s*{}\n' + # r"\s*{}\n" + # r"\s*{}\n" + # ) + + assert stat_entry("zorglub", "$20.00", "-$31.67") in html + assert stat_entry("jeanne", "$20.00", "-$5.83") in html + assert stat_entry("tata", "$0.00", "-$2.50") in html + assert stat_entry("pépé", "$0.00", "-$0.00") in html # Check that the order of participants in the sidebar table is the # same as in the main table. order = ["jeanne", "pépé", "tata", "zorglub"]