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 %}
-
-
- {{ _("Who?") }} | {{ _("Paid") }} | {{ _("Spent") }} |
-
- {% for stat in members_stats|sort(attribute='member.name') %}
-
- {{ stat.member.name }} |
- {{ stat.paid|currency }} |
- {{ stat.spent|currency }} |
-
- {% endfor %}
-
-
-
{{ _("Expenses by Month") }}
-
- {{ _("Period") }} | {{ _("Spent") }} |
-
- {% for month in months %}
-
- {{ month|dateformat("MMMM yyyy") }} |
- {{ monthly_stats[month.year][month.month]|currency }} |
-
- {% endfor %}
-
-
-
+{% extends "sidebar_table_layout.html" %} {% block sidebar %}
+
+ {{ balance_table(show_weight=False, show_header=True) }}
+
+{% endblock %} {% block content %}
+
+
+
+
+ {{ _("Who?") }} |
+ {{ _("Paid") }} |
+ {{ _("Expenses") }} |
+ {{ _("Direct transfer") }} |
+ {{ _("Received") }} |
+
+
+
+ {% for stat in members_stats|sort(attribute='member.name') %}
+
+ {{ stat.member.name }} |
+ {{ stat.paid|currency }} |
+ {{ stat.spent|currency }} |
+ {{ stat.transferred|currency }} |
+ {{ stat.received|currency }} |
+
+ {% endfor %}
+
+
+
{{ _("Expenses by month") }}
+
+
+
+ {{ _("Period") }} |
+ {{ _("Expenses") }} |
+
+
+
+ {% for month in months %}
+
+ {{ month|dateformat("MMMM yyyy") }} |
+ {{ monthly_stats[month.year][month.month]|currency }} |
+
+ {% endfor %}
+
+
+
{% 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*Period | \s*"
- r"Spent | \s*
\s*\s*\s*\s*
"
- )
- assert re.search(regex, response.data.decode("utf-8"))
+ regex = (
+ r'\n'
+ r" \n"
+ r" \n"
+ r" Period | \n"
+ r" Expenses | \n"
+ r"
\n"
+ r" \n"
+ r" \n"
+ r" \n"
+ r" \n"
+ r"
"
+ )
+
+ 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"]