import pytest import httpx from typing import List, Dict, Any from decimal import Decimal from app.models import ( User, Group, Expense, ExpenseSplit, SettlementActivity, UserRoleEnum, SplitTypeEnum, ExpenseOverallStatusEnum, ExpenseSplitStatusEnum ) from app.schemas.cost import GroupBalanceSummary, UserBalanceDetail from app.schemas.settlement_activity import SettlementActivityCreate # For creating test data from app.core.config import settings # Assume db_session, client are provided by conftest.py or similar setup @pytest.fixture async def test_user1_api_costs(db_session, client: httpx.AsyncClient) -> Dict[str, Any]: user = User(email="costs.user1@example.com", name="Costs API User 1", hashed_password="password1") db_session.add(user) await db_session.commit() await db_session.refresh(user) return {"user": user, "headers": {"Authorization": f"Bearer token-for-costs-{user.id}"}} @pytest.fixture async def test_user2_api_costs(db_session, client: httpx.AsyncClient) -> Dict[str, Any]: user = User(email="costs.user2@example.com", name="Costs API User 2", hashed_password="password2") db_session.add(user) await db_session.commit() await db_session.refresh(user) return {"user": user, "headers": {"Authorization": f"Bearer token-for-costs-{user.id}"}} @pytest.fixture async def test_user3_api_costs(db_session, client: httpx.AsyncClient) -> Dict[str, Any]: user = User(email="costs.user3@example.com", name="Costs API User 3", hashed_password="password3") db_session.add(user) await db_session.commit() await db_session.refresh(user) return {"user": user, "headers": {"Authorization": f"Bearer token-for-costs-{user.id}"}} @pytest.fixture async def test_group_api_costs( db_session, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any] ) -> Group: user1 = test_user1_api_costs["user"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = Group(name="Costs API Test Group", created_by_id=user1.id) db_session.add(group) await db_session.flush() # Get group.id from app.models import UserGroup members = [ UserGroup(user_id=user1.id, group_id=group.id, role=UserRoleEnum.owner), UserGroup(user_id=user2.id, group_id=group.id, role=UserRoleEnum.member), UserGroup(user_id=user3.id, group_id=group.id, role=UserRoleEnum.member), ] db_session.add_all(members) await db_session.commit() await db_session.refresh(group) return group @pytest.fixture async def test_user4_api_costs(db_session, client: httpx.AsyncClient) -> Dict[str, Any]: user = User(email="costs.user4@example.com", name="Costs API User 4", hashed_password="password4") db_session.add(user) await db_session.commit() await db_session.refresh(user) # Note: Token/headers for this user might be needed if they perform actions. # For now, just returning the user object. return {"user": user, "headers": {"Authorization": f"Bearer token-for-costs-{user.id}"}} @pytest.fixture async def test_group_api_costs_2_users( db_session, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any] ) -> Dict[str, Any]: user1 = test_user1_api_costs["user"] user2 = test_user2_api_costs["user"] group = Group(name="Costs API Test Group - 2 Users", created_by_id=user1.id) db_session.add(group) await db_session.flush() from app.models import UserGroup members_assoc = [ UserGroup(user_id=user1.id, group_id=group.id, role=UserRoleEnum.owner), UserGroup(user_id=user2.id, group_id=group.id, role=UserRoleEnum.member), ] db_session.add_all(members_assoc) await db_session.commit() await db_session.refresh(group) # Return group and its members for convenience in tests return {"group": group, "members": [user1, user2]} @pytest.fixture async def test_group_api_costs_4_users( db_session, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_user4_api_costs: Dict[str, Any] ) -> Dict[str, Any]: user1 = test_user1_api_costs["user"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] user4 = test_user4_api_costs["user"] group = Group(name="Costs API Test Group - 4 Users", created_by_id=user1.id) db_session.add(group) await db_session.flush() from app.models import UserGroup members_assoc = [ UserGroup(user_id=user1.id, group_id=group.id, role=UserRoleEnum.owner), UserGroup(user_id=user2.id, group_id=group.id, role=UserRoleEnum.member), UserGroup(user_id=user3.id, group_id=group.id, role=UserRoleEnum.member), UserGroup(user_id=user4.id, group_id=group.id, role=UserRoleEnum.member), ] db_session.add_all(members_assoc) await db_session.commit() await db_session.refresh(group) return {"group": group, "members": [user1, user2, user3, user4]} @pytest.fixture async def test_expense_for_balance_summary( db_session, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group ) -> Dict[str, Any]: user1 = test_user1_api_costs["user"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense = Expense( description="Group Dinner for Balance Test", total_amount=Decimal("100.00"), currency="USD", group_id=group.id, paid_by_user_id=user1.id, created_by_user_id=user1.id, split_type=SplitTypeEnum.EQUAL, overall_settlement_status=ExpenseOverallStatusEnum.unpaid ) db_session.add(expense) await db_session.flush() # Get expense.id # Equal splits: 100 / 3 = 33.33, 33.33, 33.34 (approx) split_amount1 = Decimal("33.33") split_amount2 = Decimal("33.33") split_amount3 = expense.total_amount - split_amount1 - split_amount2 # 33.34 splits_data = [ {"user_id": user1.id, "owed_amount": split_amount1}, {"user_id": user2.id, "owed_amount": split_amount2}, {"user_id": user3.id, "owed_amount": split_amount3}, ] created_splits = {} for data in splits_data: split = ExpenseSplit( expense_id=expense.id, user_id=data["user_id"], owed_amount=data["owed_amount"], status=ExpenseSplitStatusEnum.unpaid ) db_session.add(split) created_splits[data["user_id"]] = split await db_session.commit() for split_obj in created_splits.values(): await db_session.refresh(split_obj) await db_session.refresh(expense) return {"expense": expense, "splits": created_splits} @pytest.mark.asyncio async def test_group_balance_summary_with_settlement_activity( client: httpx.AsyncClient, db_session: AsyncSession, # For direct DB manipulation/verification if needed test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, test_expense_for_balance_summary: Dict[str, Any] # Contains expense and splits ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] # Used to call the balance summary endpoint user2 = test_user2_api_costs["user"] user2_headers = test_user2_api_costs["headers"] # User2 will make a settlement user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_data = test_expense_for_balance_summary expense = expense_data["expense"] user2_split = expense_data["splits"][user2.id] # User 2 pays their full share of 33.33 via a SettlementActivity settlement_payload = SettlementActivityCreate( expense_split_id=user2_split.id, paid_by_user_id=user2.id, amount_paid=user2_split.owed_amount ) # Use the financial API to record this settlement (simulates real usage) # This requires the financials API to be up and running with the test client settle_response = await client.post( f"{settings.API_V1_STR}/expense_splits/{user2_split.id}/settle", json=settlement_payload.model_dump(mode='json'), headers=user2_headers # User2 records their own payment ) assert settle_response.status_code == 201 # Now, get the group balance summary response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers # User1 (group member) requests the summary ) assert response.status_code == 200 summary_data = response.json() assert summary_data["group_id"] == group.id user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # User1: Paid 100. Own share 33.33. # User2 paid their 33.33 share back (to User1 effectively). # User3 owes 33.34. # Expected balances: # User1: Paid 100, Share 33.33. Received 33.33 from User2 via settlement activity (indirectly). # Net = (PaidForExpenses + SettlementsReceived) - (ShareOfExpenses + SettlementsPaid) # Net = (100 + 0) - (33.33 + 0) = 66.67 (this is what User1 is 'up' before User3 pays) # The group balance calculation should show User1 as creditor for User3's share. # User2: Paid 0 for expenses. Share 33.33. Paid 33.33 via settlement activity. # Net = (0 + 0) - (33.33 + 33.33) = -66.66 -- This is wrong. # Correct: total_settlements_paid includes the 33.33. # Net = (PaidForExpenses + SettlementsReceived) - (ShareOfExpenses + SettlementsPaid) # Net = (0 + 0) - (33.33 + 33.33) => This should be (0) - (33.33 - 33.33) = 0 # The API calculates net_balance = (total_paid_for_expenses + total_settlements_received) - (total_share_of_expenses + total_settlements_paid) # For User2: (0 + 0) - (33.33 + 33.33) = -66.66. This is if settlement activity increases debt. This is not right. # SettlementActivity means user *paid* their share. So it should reduce their effective debt. # The cost.py logic adds SettlementActivity.amount_paid to UserBalanceDetail.total_settlements_paid. # So for User2: total_paid_for_expenses=0, total_share_of_expenses=33.33, total_settlements_paid=33.33, total_settlements_received=0 # User2 Net = (0 + 0) - (33.33 + 33.33) = -66.66. This logic is flawed in the interpretation. # # Let's re-evaluate `total_settlements_paid` for UserBalanceDetail. # A settlement_activity where user_id is paid_by_user_id means they *paid* that amount. # This amount reduces what they owe OR counts towards what they are owed if they overpaid or paid for others. # The current calculation: Net = (Money_User_Put_In) - (Money_User_Should_Have_Put_In_Or_Took_Out) # Money_User_Put_In = total_paid_for_expenses + total_settlements_received (generic settlements) # Money_User_Should_Have_Put_In_Or_Took_Out = total_share_of_expenses + total_settlements_paid (generic settlements + settlement_activities) # # If User2 pays 33.33 (activity): # total_paid_for_expenses (User2) = 0 # total_share_of_expenses (User2) = 33.33 # total_settlements_paid (User2) = 33.33 (from activity) # total_settlements_received (User2) = 0 # User2 Net Balance = (0 + 0) - (33.33 + 33.33) = -66.66. This is still incorrect. # # The `SettlementActivity` means User2 *cleared* a part of their `total_share_of_expenses`. # It should not be added to `total_settlements_paid` in the same way a generic `Settlement` is, # because a generic settlement might be User2 paying User1 *outside* of an expense context, # whereas SettlementActivity is directly paying off an expense share. # # The `costs.py` logic was: # user_balances_data[activity.paid_by_user_id].total_settlements_paid += activity.amount_paid # This means if User2 pays an activity, their `total_settlements_paid` increases. # # If total_share_of_expenses = 33.33 (what User2 is responsible for) # And User2 pays a SettlementActivity of 33.33. # User2's net should be 0. # (0_paid_exp + 0_recv_settle) - (33.33_share + 33.33_paid_activity_as_settlement) = -66.66. # # The issue might be semantic: `total_settlements_paid` perhaps should only be for generic settlements. # Or, the `SettlementActivity` should directly reduce `total_share_of_expenses` effectively, # or be accounted for on the "money user put in" side. # # If a `SettlementActivity` by User2 means User1 (payer of expense) effectively got that money back, # then User1's "received" should increase. But `SettlementActivity` doesn't have a `paid_to_user_id`. # It just marks a split as paid. # # Let's assume the current `costs.py` logic is what we test. # User1: paid_exp=100, share=33.33, paid_settle=0, recv_settle=0. Net = 100 - 33.33 = 66.67 # User2: paid_exp=0, share=33.33, paid_settle=33.33 (from activity), recv_settle=0. Net = 0 - (33.33 + 33.33) = -66.66 # User3: paid_exp=0, share=33.34, paid_settle=0, recv_settle=0. Net = 0 - 33.34 = -33.34 # Sum of net balances: 66.67 - 66.66 - 33.34 = -33.33. This is not zero. Balances must sum to zero. # # The problem is that `SettlementActivity` by User2 for their share means User1 (who paid the expense) # is effectively "reimbursed". The money User1 put out (100) is reduced by User2's payment (33.33). # # The `SettlementActivity` logic in `costs.py` seems to be misinterpreting the effect of a settlement activity. # A `SettlementActivity` reduces the effective amount a user owes for their expense shares. # It's not a "settlement paid" in the sense of a separate P2P settlement. # # Correct approach for `costs.py` would be: # For each user, calculate `effective_share = total_share_of_expenses - sum_of_their_settlement_activities_paid`. # Then, `net_balance = total_paid_for_expenses - effective_share`. (Ignoring generic settlements for a moment). # # User1: paid_exp=100, share=33.33, activities_paid_by_user1=0. Effective_share=33.33. Net = 100 - 33.33 = 66.67 # User2: paid_exp=0, share=33.33, activities_paid_by_user2=33.33. Effective_share=0. Net = 0 - 0 = 0 # User3: paid_exp=0, share=33.34, activities_paid_by_user3=0. Effective_share=33.34. Net = 0 - 33.34 = -33.34 # Sum of net balances: 66.67 + 0 - 33.34 = 33.33. Still not zero. # # This is because the expense total is 100. User1 paid it. So the system has +100 from User1. # User1 is responsible for 33.33. User2 for 33.33. User3 for 33.34. # User2 paid their 33.33 (via activity). So User2 is settled (0). # User3 still owes 33.34. # User1 is owed 33.34 by User3. User1 is also "owed" their own initial outlay less their share (100 - 33.33 = 66.67), # but has been effectively reimbursed by User2. So User1 should be a creditor of 33.34. # # Net for User1 = (Amount they paid for others) - (Amount others paid for them) # User1 paid 100. User1's share is 33.33. So User1 effectively lent out 100 - 33.33 = 66.67. # User2 owed 33.33 and paid it (via activity). So User2's debt to User1 is cleared. # User3 owed 33.34 and has not paid. So User3 owes 33.34 to User1. # User1's net balance = 33.34 (creditor) # User2's net balance = 0 # User3's net balance = -33.34 (debtor) # Sum = 0. This is correct. # Let's test against the *current* implementation in costs.py, even if it seems flawed. # The task is to test the change *I* made, which was adding activities to total_settlements_paid. # With the corrected logic in costs.py: # User1: Paid 100. Share 33.33. User2 paid their 33.33 share via SettlementActivity. # User1 is effectively owed 33.34 by User3. # total_paid_for_expenses = 100.00 # initial_total_share_of_expenses = 33.33 (User1's own share) # total_amount_paid_via_settlement_activities = 0 (User1 made no such payments) # adjusted_total_share_of_expenses = 33.33 # total_generic_settlements_paid = 0 # total_generic_settlements_received = 0 # Net Balance = (100.00 + 0.00) - (33.33 + 0.00) = 66.67. # Wait, this is not reflecting that User2's payment effectively reimburses User1. # The net_balance for User1 should be 33.34 (creditor). # The `total_paid_for_expenses` for User1 is 100. # The `adjusted_total_share_of_expenses` for User1 is 33.33. # The `total_generic_settlements_paid` for User1 is 0. # The `total_generic_settlements_received` for User1 is 0. # The formula is: (total_paid_for_expenses + total_generic_settlements_received) - (adjusted_total_share_of_expenses + total_generic_settlements_paid) # So, for User1: (100.00 + 0.00) - (33.33 + 0.00) = 66.67. This is if User1 is the only one involved. # The suggested settlements are what balances this out. User2 is 0, User3 owes 33.34. So User1 is owed 33.34 overall. # The sum of net balances must be zero. U1(X) + U2(0) + U3(-33.34) = 0 => X = 33.34. # User1: assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "33.33" # User1's own share assert user_balances[user1.id]["total_settlements_paid"] == "0.00" # User1 made no generic settlements assert user_balances[user1.id]["total_settlements_received"] == "0.00" # User1 received no generic settlements assert user_balances[user1.id]["net_balance"] == "33.34" # User2: Paid their share of 33.33 via SettlementActivity. # total_paid_for_expenses = 0.00 # initial_total_share_of_expenses = 33.33 # total_amount_paid_via_settlement_activities = 33.33 # adjusted_total_share_of_expenses = 33.33 - 33.33 = 0.00 # total_generic_settlements_paid = 0.00 # total_generic_settlements_received = 0.00 # Net Balance = (0.00 + 0.00) - (0.00 + 0.00) = 0.00 assert user_balances[user2.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user2.id]["total_share_of_expenses"] == "0.00" # Initial 33.33 minus 33.33 from activity assert user_balances[user2.id]["total_settlements_paid"] == "0.00" # The activity is not a generic settlement assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "0.00" # User3: Owes their share of 33.34. # total_paid_for_expenses = 0.00 # initial_total_share_of_expenses = 33.34 # total_amount_paid_via_settlement_activities = 0.00 # adjusted_total_share_of_expenses = 33.34 # total_generic_settlements_paid = 0.00 # total_generic_settlements_received = 0.00 # Net Balance = (0.00 + 0.00) - (33.34 + 0.00) = -33.34 assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "33.34" assert user_balances[user3.id]["total_settlements_paid"] == "0.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "-33.34" # Verify sum of net balances is zero sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements should reflect the corrected balances: User3 pays User1 33.34 suggested_settlements = summary_data["suggested_settlements"] assert isinstance(suggested_settlements, list) if suggested_settlements: # Check if the list is not empty assert len(suggested_settlements) == 1 settlement = suggested_settlements[0] assert settlement["from_user_id"] == user3.id assert settlement["to_user_id"] == user1.id assert settlement["amount"] == "33.34" else: # If all balances are zero, no settlements are suggested. # This case should not happen here as User3 owes User1. assert False, "Suggested settlements should not be empty in this scenario." # Ensure overall_total_expenses and overall_total_settlements are present in GroupBalanceSummary # These fields were added in a later version of the schema in the problem description. # For now, just ensure the response structure for user_balances and suggested_settlements is correct. # If these fields (`overall_total_expenses`, `overall_total_settlements`) are indeed part of the # GroupBalanceSummary model being used by the endpoint, the test should ideally check them. # However, the primary focus here is the UserBalanceDetail and resulting suggested settlements. # The fixture data does not include generic settlements, so overall_total_settlements would be 0. # overall_total_expenses would be 100.00 assert "overall_total_expenses" in summary_data assert "overall_total_settlements" in summary_data if "overall_total_expenses" in summary_data: assert summary_data["overall_total_expenses"] == "100.00" # From the single expense if "overall_total_settlements" in summary_data: assert summary_data["overall_total_settlements"] == "0.00" # No generic settlements in this test @pytest.mark.asyncio class TestEqualSplitExpenses: async def test_equal_split_basic( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Basic Equal Split Dinner", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EQUAL.value, # For EQUAL split, splits_in is typically not provided, # relies on group members. } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "100.00" assert created_expense_data["split_type"] == SplitTypeEnum.EQUAL.value assert created_expense_data["paid_by_user_id"] == user1.id # Verify splits expense_id = created_expense_data["id"] # Fetch expense details to check splits (assuming GET endpoint returns splits) detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 3 # Expected: 33.33, 33.33, 33.34 (sorted by user_id, assuming user1, user2, user3 have ascending ids) # The order of users in test_group_api_costs is user1, user2, user3. # Their IDs will be ascending. expected_amounts = ["33.33", "33.33", "33.34"] user_ids = sorted([user1.id, user2.id, user3.id]) for i, split in enumerate(splits): assert split["user_id"] == user_ids[i] assert split["owed_amount"] == expected_amounts[i] assert split["status"] == ExpenseSplitStatusEnum.unpaid.value sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("100.00") async def test_equal_split_divisible( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], # For headers test_group_api_costs_2_users: Dict[str, Any], ): user1_headers = test_user1_api_costs["headers"] # Payer will be user1 group_data = test_group_api_costs_2_users group_object = group_data["group"] group_members = group_data["members"] assert len(group_members) == 2 # Ensure user1 (whose headers are used) is part of this group for valid payment payer = test_user1_api_costs["user"] assert any(member.id == payer.id for member in group_members), "Payer (user1) must be in the 2-user group" expense_payload = { "description": "Divisible Equal Split Lunch", "total_amount": "50.00", "currency": "USD", "group_id": group_object.id, "paid_by_user_id": payer.id, "split_type": SplitTypeEnum.EQUAL.value, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "50.00" assert created_expense_data["split_type"] == SplitTypeEnum.EQUAL.value expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 2 user_ids_in_group = sorted([m.id for m in group_members]) for split in splits: assert split["owed_amount"] == "25.00" assert split["status"] == ExpenseSplitStatusEnum.unpaid.value assert split["user_id"] in user_ids_in_group sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("50.00") async def test_equal_split_with_remainder_distribution( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], # For headers test_group_api_costs_4_users: Dict[str, Any], ): user1_headers = test_user1_api_costs["headers"] # Payer will be user1 group_data = test_group_api_costs_4_users group_object = group_data["group"] group_members = group_data["members"] assert len(group_members) == 4 # Ensure user1 (whose headers are used) is part of this group for valid payment payer = test_user1_api_costs["user"] assert any(member.id == payer.id for member in group_members), "Payer (user1) must be in the 4-user group" expense_payload = { "description": "Remainder Test", "total_amount": "101.00", "currency": "USD", "group_id": group_object.id, "paid_by_user_id": payer.id, "split_type": SplitTypeEnum.EQUAL.value, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, # User1 is the payer ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "101.00" expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 4 # 101.00 / 4 = 25.25. Pennies should be distributed. # The penny distribution (e.g. first N users get an extra penny) depends on implementation. # Let's assume it's 25.25 for all due to ROUND_HALF_UP or similar for base, and then pennies added. # Or, 3 users get 25.25 and one gets 25.25, or some get X and some X+0.01 # The crud function uses: base_share = (total_amount / num_splits).quantize(Decimal("0.01"), rounding=ROUND_DOWN) # Then distributes pennies. So for 101/4 = 25.25, ROUND_DOWN is 25.25. No pennies to distribute. # All should be 25.25. # If amount was 100.00 / 3 = 33.333... # base_share = 33.33. Remaining = 0.01. First user gets 33.34. # For 101.00 / 4 = 25.25. # base_share = 25.25. total_rounded = 25.25 * 4 = 101.00. Remainder = 0. # So all should be 25.25. for split in splits: assert split["owed_amount"] == "25.25" assert split["status"] == ExpenseSplitStatusEnum.unpaid.value sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("101.00") async def test_equal_split_single_user_in_group( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] # Create a new group with only user1 single_user_group = Group(name="Single User Group for Equal Split", created_by_id=user1.id) db_session.add(single_user_group) await db_session.flush() from app.models import UserGroup # Ensure import user_group_assoc = UserGroup(user_id=user1.id, group_id=single_user_group.id, role=UserRoleEnum.owner) db_session.add(user_group_assoc) await db_session.commit() await db_session.refresh(single_user_group) expense_payload = { "description": "Single User Equal Split", "total_amount": "75.00", "currency": "USD", "group_id": single_user_group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EQUAL.value, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "75.00" expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = expense_details["splits"] assert len(splits) == 1 split = splits[0] assert split["user_id"] == user1.id assert split["owed_amount"] == "75.00" assert split["status"] == ExpenseSplitStatusEnum.unpaid.value sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("75.00") # Need to import selectinload for the group member fetching in divisible/remainder tests from sqlalchemy.orm import selectinload # Need AsyncSession for type hinting db_session from sqlalchemy.ext.asyncio import AsyncSession @pytest.mark.asyncio class TestExactAmountsSplitExpenses: async def test_exact_amounts_basic( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Exact Amounts Split Event", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, "splits_in": [ {"user_id": user1.id, "owed_amount": "20.00"}, {"user_id": user2.id, "owed_amount": "30.00"}, {"user_id": user3.id, "owed_amount": "50.00"}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "100.00" assert created_expense_data["split_type"] == SplitTypeEnum.EXACT_AMOUNTS.value assert created_expense_data["paid_by_user_id"] == user1.id expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 3 expected_splits = { user1.id: "20.00", user2.id: "30.00", user3.id: "50.00", } for split in splits: assert split["owed_amount"] == expected_splits[split["user_id"]] assert split["status"] == ExpenseSplitStatusEnum.unpaid.value sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("100.00") async def test_exact_amounts_validation_sum_mismatch( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Sum Mismatch Test", "total_amount": "100.00", # Total amount "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, "splits_in": [ {"user_id": user1.id, "owed_amount": "20.00"}, {"user_id": user2.id, "owed_amount": "30.00"}, # Sums to 50, not 100 ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) # Expecting 400 due to sum mismatch from crud_expense validation assert response.status_code == 400 error_detail = response.json()["detail"] assert "Sum of split amounts must equal total expense amount" in error_detail async def test_exact_amounts_validation_negative_amount( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] group = test_group_api_costs expense_payload = { "description": "Negative Amount Test", "total_amount": "10.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, "splits_in": [ {"user_id": user1.id, "owed_amount": "-5.00"}, # Negative amount ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) # Pydantic validation on SplitCreate should catch negative owed_amount assert response.status_code == 422 # Example check, details might vary based on Pydantic error formatting assert "value_error" in response.json()["detail"][0]["type"] assert "must be greater than 0" in response.json()["detail"][0]["msg"] async def test_exact_amounts_validation_missing_user_in_split( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] group = test_group_api_costs non_existent_user_id = 99999 expense_payload = { "description": "Missing User in Split Test", "total_amount": "50.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, "splits_in": [ {"user_id": user1.id, "owed_amount": "25.00"}, {"user_id": non_existent_user_id, "owed_amount": "25.00"}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) # crud_expense._validate_and_get_users_for_splitting should catch this assert response.status_code == 404 error_detail = response.json()["detail"] assert f"User with ID {non_existent_user_id} not found or not part of group {group.id}" in error_detail @pytest.mark.asyncio class TestPercentageSplitExpenses: async def test_percentage_basic( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Basic Percentage Split", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.PERCENTAGE.value, "splits_in": [ {"user_id": user1.id, "percentage": "25.00"}, {"user_id": user2.id, "percentage": "25.00"}, {"user_id": user3.id, "percentage": "50.00"}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "100.00" assert created_expense_data["split_type"] == SplitTypeEnum.PERCENTAGE.value expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 3 expected_amounts = { user1.id: "25.00", user2.id: "25.00", user3.id: "50.00", } for split in splits: assert split["owed_amount"] == expected_amounts[split["user_id"]] sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("100.00") async def test_percentage_with_rounding( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Percentage Split with Rounding", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.PERCENTAGE.value, "splits_in": [ {"user_id": user1.id, "percentage": "33.33"}, {"user_id": user2.id, "percentage": "33.33"}, {"user_id": user3.id, "percentage": "33.34"}, # Sums to 100% ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 3 # Based on current penny distribution (first user gets remainder) # User1: 100 * 0.3333 = 33.33 # User2: 100 * 0.3333 = 33.33 # User3: 100 * 0.3334 = 33.34 # Sum = 100.00. The crud logic for percentage splits distributes pennies. user_ids_sorted = sorted([user1.id, user2.id, user3.id]) expected_amounts = { user_ids_sorted[0]: "33.33", # User1 user_ids_sorted[1]: "33.33", # User2 user_ids_sorted[2]: "33.34", # User3 } # Check if penny went to the one with higher percentage if not perfectly distributed by input already # The current logic in `_create_percentage_splits` calculates shares and then distributes pennies # similar to equal split. It doesn't necessarily assign the remainder based on the input percentages # if those percentages themselves don't perfectly sum to 100.00 after calculation. # However, if percentages sum to 100, the calculated amounts should match. # Let's verify the actual amounts from the test: # User1 gets 33.33. User2 gets 33.33. User3 gets 33.34. # This matches the input percentages directly applied. # The penny distribution logic in `_distribute_remainder` might adjust if the sum of calculated shares is off. for split in splits: assert split["owed_amount"] == expected_amounts[split["user_id"]] sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("100.00") async def test_percentage_validation_sum_not_100( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Percentage Sum Not 100", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.PERCENTAGE.value, "splits_in": [ {"user_id": user1.id, "percentage": "20.00"}, {"user_id": user2.id, "percentage": "30.00"}, # Sums to 50% ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 400 error_detail = response.json()["detail"] assert "Percentages must sum to 100" in error_detail async def test_percentage_validation_invalid_percentage( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] group = test_group_api_costs expense_payload = { "description": "Invalid Percentage Value", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.PERCENTAGE.value, "splits_in": [ {"user_id": user1.id, "percentage": "110.00"}, # Invalid percentage ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 422 # Pydantic validation on percentage value (0-100) # Example check, details might vary assert "value_error" in response.json()["detail"][0]["type"] assert "Percentage must be between 0 and 100" in response.json()["detail"][0]["msg"] @pytest.mark.asyncio class TestSharesSplitExpenses: async def test_shares_basic( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Basic Shares Split", "total_amount": "60.00", # Total 6 shares (1+2+3), 60/6 = 10 per share "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.SHARES.value, "splits_in": [ {"user_id": user1.id, "shares": 1}, {"user_id": user2.id, "shares": 2}, {"user_id": user3.id, "shares": 3}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "60.00" assert created_expense_data["split_type"] == SplitTypeEnum.SHARES.value expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 3 expected_amounts = { user1.id: "10.00", # 1 share * 10 user2.id: "20.00", # 2 shares * 10 user3.id: "30.00", # 3 shares * 10 } for split in splits: assert split["owed_amount"] == expected_amounts[split["user_id"]] sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("60.00") async def test_shares_with_rounding( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Shares Split with Rounding", "total_amount": "100.00", # Total 3 shares (1+1+1) "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.SHARES.value, "splits_in": [ {"user_id": user1.id, "shares": 1}, {"user_id": user2.id, "shares": 1}, {"user_id": user3.id, "shares": 1}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) assert len(splits) == 3 # 100.00 / 3 shares = 33.333... per share. # Expected: 33.33, 33.33, 33.34 (penny distribution applies) user_ids_sorted = sorted([user1.id, user2.id, user3.id]) expected_amounts = { user_ids_sorted[0]: "33.33", user_ids_sorted[1]: "33.33", user_ids_sorted[2]: "33.34", } for split in splits: assert split["owed_amount"] == expected_amounts[split["user_id"]] sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("100.00") async def test_shares_validation_zero_total_shares( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] group = test_group_api_costs expense_payload = { "description": "Zero Total Shares", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.SHARES.value, "splits_in": [ {"user_id": user1.id, "shares": 0}, {"user_id": user2.id, "shares": 0}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 400 error_detail = response.json()["detail"] assert "Total shares must be greater than zero" in error_detail async def test_shares_validation_negative_shares( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] group = test_group_api_costs expense_payload = { "description": "Invalid Negative Shares", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.SHARES.value, "splits_in": [ {"user_id": user1.id, "shares": -1}, ], } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 422 # Pydantic validation on shares value (>=0) # Example check, details might vary assert "value_error" in response.json()["detail"][0]["type"] assert "Shares must be non-negative" in response.json()["detail"][0]["msg"] # Adjusted expected message @pytest.mark.asyncio class TestItemBasedSplitExpenses: @pytest.fixture async def test_list_for_item_based_split( self, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, ) -> ListModel: user1 = test_user1_api_costs["user"] group = test_group_api_costs shopping_list = ListModel( name="Shopping List for Item-Based Split", group_id=group.id, created_by_id=user1.id, ) db_session.add(shopping_list) await db_session.commit() await db_session.refresh(shopping_list) return shopping_list async def test_item_based_split_basic( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_group_api_costs: Group, test_list_for_item_based_split: ListModel, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] shopping_list = test_list_for_item_based_split # Add items to the list items_data = [ {"description": "Item 1", "price": Decimal("10.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, {"description": "Item 2", "price": Decimal("20.00"), "added_by_user_id": user2.id, "list_id": shopping_list.id}, {"description": "Item 3", "price": Decimal("30.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, ] for item_data in items_data: item = ItemModel(**item_data) db_session.add(item) await db_session.commit() expense_payload = { "description": "Item-Based Split Dinner", "total_amount": "60.00", # Sum of item prices (10+20+30) "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "list_id": shopping_list.id, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "60.00" assert created_expense_data["split_type"] == SplitTypeEnum.ITEM_BASED.value assert created_expense_data["paid_by_user_id"] == user1.id expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = sorted(expense_details["splits"], key=lambda s: s["user_id"]) # User3 is in the group but added no items, so should not have a split. assert len(splits) == 2 expected_splits = { user1.id: "40.00", # 10 + 30 user2.id: "20.00", } for split in splits: assert split["owed_amount"] == expected_splits[split["user_id"]] assert split["status"] == ExpenseSplitStatusEnum.unpaid.value sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("60.00") async def test_item_based_split_single_user_adds_all_items( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, # Group contains user1, user2, user3 test_list_for_item_based_split: ListModel, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] shopping_list = test_list_for_item_based_split items_data = [ {"description": "Item A", "price": Decimal("15.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, {"description": "Item B", "price": Decimal("25.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, ] for item_data in items_data: item = ItemModel(**item_data) db_session.add(item) await db_session.commit() expense_payload = { "description": "Single User All Items", "total_amount": "40.00", "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "list_id": shopping_list.id, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "40.00" expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = expense_details["splits"] assert len(splits) == 1 split = splits[0] assert split["user_id"] == user1.id assert split["owed_amount"] == "40.00" assert split["status"] == ExpenseSplitStatusEnum.unpaid.value sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("40.00") async def test_item_based_split_with_zero_price_items( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], # User2 adds zero price item test_group_api_costs: Group, test_list_for_item_based_split: ListModel, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] shopping_list = test_list_for_item_based_split items_data = [ {"description": "Item X1", "price": Decimal("10.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, {"description": "Item X2", "price": Decimal("0.00"), "added_by_user_id": user2.id, "list_id": shopping_list.id}, {"description": "Item X3", "price": Decimal("5.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, ] for item_data in items_data: item = ItemModel(**item_data) db_session.add(item) await db_session.commit() expense_payload = { "description": "Zero Price Items Test", "total_amount": "15.00", # 10 + 5 "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "list_id": shopping_list.id, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "15.00" expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = expense_details["splits"] assert len(splits) == 1 # Only User1 has splits from priced items split = splits[0] assert split["user_id"] == user1.id assert split["owed_amount"] == "15.00" sum_of_splits = sum(Decimal(s["owed_amount"]) for s in splits) assert sum_of_splits == Decimal("15.00") async def test_item_based_split_list_with_no_priced_items( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_group_api_costs: Group, test_list_for_item_based_split: ListModel, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] shopping_list = test_list_for_item_based_split items_data = [ {"description": "NoPrice 1", "price": Decimal("0.00"), "added_by_user_id": user1.id, "list_id": shopping_list.id}, {"description": "NoPrice 2", "added_by_user_id": user2.id, "list_id": shopping_list.id}, # price is None ] for item_data in items_data: item = ItemModel(**item_data) db_session.add(item) await db_session.commit() expense_payload = { "description": "No Priced Items Test", "total_amount": "0.00", # This will be the sum from items "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "list_id": shopping_list.id, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) # crud_expense.py: _create_item_based_splits raises ValueError if no relevant items or total is zero. # This translates to a 400 error. assert response.status_code == 400 error_detail = response.json()["detail"] assert "No items with a positive price found for this list to create an item-based split" in error_detail \ or "Total amount for item-based split must be greater than zero" in error_detail async def test_item_based_split_linked_to_specific_item( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_group_api_costs: Group, test_list_for_item_based_split: ListModel, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] shopping_list = test_list_for_item_based_split item_a = ItemModel(description="Item A", price=Decimal("50.00"), added_by_user_id=user1.id, list_id=shopping_list.id) item_b = ItemModel(description="Item B", price=Decimal("70.00"), added_by_user_id=user2.id, list_id=shopping_list.id) db_session.add_all([item_a, item_b]) await db_session.commit() await db_session.refresh(item_a) await db_session.refresh(item_b) # Scenario 1: Correct amount linked to item_a expense_payload_item_a = { "description": "Expense for Item A", "total_amount": "50.00", "currency": "USD", "group_id": test_group_api_costs.id, # Group ID is still relevant for context "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "item_id": item_a.id, # Linking to specific item } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload_item_a, headers=user1_headers, ) assert response.status_code == 201 created_expense_data = response.json() assert created_expense_data["total_amount"] == "50.00" assert created_expense_data["item_id"] == item_a.id expense_id = created_expense_data["id"] detail_response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert detail_response.status_code == 200 expense_details = detail_response.json() splits = expense_details["splits"] assert len(splits) == 1 assert splits[0]["user_id"] == user1.id assert splits[0]["owed_amount"] == "50.00" # Scenario 2: Mismatched total_amount for item_a expense_payload_item_a_mismatch = { "description": "Expense for Item A Mismatch", "total_amount": "40.00", # Incorrect amount "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "item_id": item_a.id, } response_mismatch = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload_item_a_mismatch, headers=user1_headers, ) # crud_expense.py validation: "Total amount for item-based split on a specific item must match item's price." assert response_mismatch.status_code == 400 assert "must match item's price" in response_mismatch.json()["detail"] async def test_item_based_split_validation_list_not_found( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] non_existent_list_id = 9999 expense_payload = { "description": "List Not Found Test", "total_amount": "10.00", "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "list_id": non_existent_list_id, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) # crud_expense.py raises ListNotFoundError if list_id is provided but not found. assert response.status_code == 404 assert f"List with ID {non_existent_list_id} not found" in response.json()["detail"] async def test_item_based_split_validation_total_amount_mismatch_with_list_items( self, client: httpx.AsyncClient, db_session: AsyncSession, test_user1_api_costs: Dict[str, Any], test_group_api_costs: Group, test_list_for_item_based_split: ListModel, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] shopping_list = test_list_for_item_based_split # Items sum to 50.00 item1 = ItemModel(description="Item C1", price=Decimal("20.00"), added_by_user_id=user1.id, list_id=shopping_list.id) item2 = ItemModel(description="Item C2", price=Decimal("30.00"), added_by_user_id=user1.id, list_id=shopping_list.id) db_session.add_all([item1, item2]) await db_session.commit() expense_payload = { "description": "Total Amount Mismatch with List Items", "total_amount": "45.00", # Mismatched amount "currency": "USD", "group_id": test_group_api_costs.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.ITEM_BASED.value, "list_id": shopping_list.id, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers, ) # crud_expense.py validation: "Total amount for item-based split must equal the sum of relevant item prices from the list." assert response.status_code == 400 assert "must equal the sum of relevant item prices" in response.json()["detail"] # Need to import ListModel and ItemModel from app.models import ListModel, ItemModel, Settlement as SettlementModel @pytest.mark.asyncio class TestGroupBalanceSummaryCalculations: async def _get_expense_splits(self, client: httpx.AsyncClient, expense_id: int, headers: Dict[str, str]) -> List[Dict[str, Any]]: """Helper to fetch expense details and return its splits.""" response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=headers) response.raise_for_status() return response.json()["splits"] async def _create_settlement_activity( self, client: httpx.AsyncClient, expense_split_id: int, paid_by_user_id: int, amount: str, headers: Dict[str, str] ): """Helper to create a settlement activity.""" payload = { "expense_split_id": expense_split_id, "paid_by_user_id": paid_by_user_id, "amount_paid": amount, } response = await client.post( f"{settings.API_V1_STR}/expense_splits/{expense_split_id}/settle", json=payload, headers=headers, ) response.raise_for_status() return response.json() async def _create_generic_settlement( self, client: httpx.AsyncClient, group_id: int, paid_by_user_id: int, paid_to_user_id: int, amount: str, headers: Dict[str, str] ): """Helper to create a generic settlement.""" payload = { "group_id": group_id, "paid_by_user_id": paid_by_user_id, "paid_to_user_id": paid_to_user_id, "amount": amount, "description": "Generic settlement" } response = await client.post( f"{settings.API_V1_STR}/settlements/", json=payload, headers=headers ) response.raise_for_status() return response.json() async def test_balance_summary_no_settlement_activities( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, # Group with U1, U2, U3 ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user3 = test_user3_api_costs["user"] group = test_group_api_costs # Create an expense: User1 paid 100, split EQUAL among User1, User2, User3 expense_payload = { "description": "Dinner - No Settlements", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EQUAL.value, } response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers ) assert response.status_code == 201 # Get balance summary summary_response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers ) assert summary_response.status_code == 200 summary_data = summary_response.json() user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # Expected: # User1: paid 100, share 33.33. Net: 100 - 33.33 = +66.67 # User2: paid 0, share 33.33. Net: 0 - 33.33 = -33.33 # User3: paid 0, share 33.34. Net: 0 - 33.34 = -33.34 # Sum = 0 # User1 assertions assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "33.33" # Adjusted share is initial share here assert user_balances[user1.id]["total_settlements_paid"] == "0.00" assert user_balances[user1.id]["total_settlements_received"] == "0.00" assert user_balances[user1.id]["net_balance"] == "66.67" # User2 assertions assert user_balances[user2.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user2.id]["total_share_of_expenses"] == "33.33" assert user_balances[user2.id]["total_settlements_paid"] == "0.00" assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "-33.33" # User3 assertions assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "33.34" assert user_balances[user3.id]["total_settlements_paid"] == "0.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "-33.34" # Sum of net balances sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements # User2 pays User1 33.33. User3 pays User1 33.34. suggested = sorted(summary_data["suggested_settlements"], key=lambda s: s["from_user_id"]) assert len(suggested) == 2 assert suggested[0]["from_user_id"] == user2.id assert suggested[0]["to_user_id"] == user1.id assert suggested[0]["amount"] == "33.33" assert suggested[1]["from_user_id"] == user3.id assert suggested[1]["to_user_id"] == user1.id assert suggested[1]["amount"] == "33.34" async def test_balance_summary_multiple_settlement_activities_different_expenses( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, # Group with U1, U2, U3 ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user2_headers = test_user2_api_costs["headers"] user3 = test_user3_api_costs["user"] user3_headers = test_user3_api_costs["headers"] # For User3 to make a settlement group = test_group_api_costs # Expense 1: User1 pays 100, split U1, U2 (50, 50) # Forcing specific users in EQUAL split by creating a temporary specific split definition exp1_payload = { "description": "Expense 1 - U1, U2", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, # Use EXACT to specify participants easily "splits_in": [ {"user_id": user1.id, "owed_amount": "50.00"}, {"user_id": user2.id, "owed_amount": "50.00"}, ] } exp1_response = await client.post( f"{settings.API_V1_STR}/expenses/", json=exp1_payload, headers=user1_headers ) assert exp1_response.status_code == 201 expense1_id = exp1_response.json()["id"] exp1_splits = await self._get_expense_splits(client, expense1_id, user1_headers) user2_exp1_split = next(s for s in exp1_splits if s["user_id"] == user2.id) # User2 pays their 50 for Expense 1 via SA await self._create_settlement_activity( client, user2_exp1_split["id"], user2.id, "50.00", user2_headers ) # Expense 2: User2 pays 60, split U2, U3 (30, 30) exp2_payload = { "description": "Expense 2 - U2, U3", "total_amount": "60.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user2.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, # Use EXACT "splits_in": [ {"user_id": user2.id, "owed_amount": "30.00"}, {"user_id": user3.id, "owed_amount": "30.00"}, ] } exp2_response = await client.post( f"{settings.API_V1_STR}/expenses/", json=exp2_payload, headers=user2_headers # User2 creates ) assert exp2_response.status_code == 201 expense2_id = exp2_response.json()["id"] exp2_splits = await self._get_expense_splits(client, expense2_id, user2_headers) user3_exp2_split = next(s for s in exp2_splits if s["user_id"] == user3.id) # User3 pays their 30 for Expense 2 via SA await self._create_settlement_activity( client, user3_exp2_split["id"], user3.id, "30.00", user3_headers ) # Get balance summary summary_response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers ) assert summary_response.status_code == 200 summary_data = summary_response.json() user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # Expected balances: All should be 0.00 # User1: Exp1(paid 100, share 50, U2 SA covered their part). Exp2(not involved). Net = 0. # total_paid_for_expenses = 100.00 # initial_total_share_of_expenses = 50.00 (from Exp1) # total_amount_paid_via_settlement_activities = 0 (U1 made no SA payments) # adjusted_total_share_of_expenses = 50.00 # net_balance = (100 + 0) - (50 + 0) = 50. This logic is for U1 in isolation. # Considering U2 paid U1 50, U1 is 0. # User2: Exp1(share 50, paid 50 SA). Exp2(paid 60, share 30, U3 SA covered their part). Net = 0. # total_paid_for_expenses = 60.00 (from Exp2) # initial_total_share_of_expenses = 50.00 (Exp1) + 30.00 (Exp2) = 80.00 # total_amount_paid_via_settlement_activities = 50.00 (for Exp1) # adjusted_total_share_of_expenses = 80.00 - 50.00 = 30.00 # net_balance = (60 + 0) - (30 + 0) = 30. This is if U3 hadn't paid U2. # Considering U3 paid U2 30, U2 is 0. # User3: Exp1(not involved). Exp2(share 30, paid 30 SA). Net = 0. # total_paid_for_expenses = 0.00 # initial_total_share_of_expenses = 30.00 (from Exp2) # total_amount_paid_via_settlement_activities = 30.00 (for Exp2) # adjusted_total_share_of_expenses = 30.00 - 30.00 = 0.00 # net_balance = (0 + 0) - (0 + 0) = 0.00 # User1 assertions assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "50.00" assert user_balances[user1.id]["total_settlements_paid"] == "0.00" assert user_balances[user1.id]["total_settlements_received"] == "0.00" assert user_balances[user1.id]["net_balance"] == "0.00" # User2 assertions assert user_balances[user2.id]["total_paid_for_expenses"] == "60.00" assert user_balances[user2.id]["total_share_of_expenses"] == "30.00" # (50 from E1 - 50 SA) + (30 from E2) = 30 assert user_balances[user2.id]["total_settlements_paid"] == "0.00" assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "0.00" # User3 assertions assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "0.00" # (30 from E2 - 30 SA) = 0 assert user_balances[user3.id]["total_settlements_paid"] == "0.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "0.00" sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements: Should be empty suggested = summary_data["suggested_settlements"] assert len(suggested) == 0 async def test_balance_summary_interaction_with_generic_settlements( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user2_headers = test_user2_api_costs["headers"] user3 = test_user3_api_costs["user"] user3_headers = test_user3_api_costs["headers"] # For User3 to make a generic settlement group = test_group_api_costs # Expense: User1 paid 100, split EQUAL among User1, User2, User3 expense_payload = { "description": "Dinner - SA and Generic Settlement", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EQUAL.value, } exp_response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers ) assert exp_response.status_code == 201 expense_id = exp_response.json()["id"] splits = await self._get_expense_splits(client, expense_id, user1_headers) user2_split = next(s for s in splits if s["user_id"] == user2.id) # User2 pays their full share (33.33) via SettlementActivity await self._create_settlement_activity( client, user2_split["id"], user2.id, user2_split["owed_amount"], user2_headers ) # User3 then pays User1 30.00 via a generic Settlement await self._create_generic_settlement( client, group.id, user3.id, user1.id, "30.00", user3_headers ) summary_response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers ) assert summary_response.status_code == 200 summary_data = summary_response.json() user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # Expected balances: # User1: Paid 100. Share 33.33. U2 SA clears U2's debt to U1. U3 pays U1 30 (Generic). # total_paid_for_expenses = 100.00 # total_share_of_expenses = 33.33 # total_settlements_paid (generic) = 0.00 # total_settlements_received (generic) = 30.00 # Net: (100 + 30) - (33.33 + 0) = 130 - 33.33 = 96.67. This is U1's position. # Overall group balance: U1 is owed 3.34 by U3. So U1 net is +3.34. # User2: Share 33.33. Paid 33.33 via SA. Net = 0. # total_paid_for_expenses = 0.00 # total_share_of_expenses = 0.00 (33.33 initial - 33.33 SA) # total_settlements_paid (generic) = 0.00 # total_settlements_received (generic) = 0.00 # Net: 0.00 # User3: Share 33.34. Paid 30.00 via Generic Settlement to U1. # total_paid_for_expenses = 0.00 # total_share_of_expenses = 33.34 # total_settlements_paid (generic) = 30.00 # total_settlements_received (generic) = 0.00 # Net: (0 + 0) - (33.34 + 30.00) = -63.34. This is U3's position. # Overall group balance: U3 owes U1 3.34. So U3 net is -3.34. # Sum = 3.34 + 0 - 3.34 = 0 # User1 assertions assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "33.33" assert user_balances[user1.id]["total_settlements_paid"] == "0.00" assert user_balances[user1.id]["total_settlements_received"] == "30.00" assert user_balances[user1.id]["net_balance"] == "3.34" # (100+30) - (33.33) = 96.67 ... error in manual calc for net. # Net = (paid_exp + generic_recv) - (adj_share + generic_paid) # U1: (100 + 30) - (33.33 + 0) = 96.67. This is if there were no other users. # The suggested settlement re-balances this. # Net effect: U1 paid 100 for an expense. U1's share was 33.33. U2 paid their 33.33 share (to U1). # U3's share was 33.34. U3 paid U1 30.00. So U3 still owes U1 3.34. # So U1 is +3.34. # User2 assertions assert user_balances[user2.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user2.id]["total_share_of_expenses"] == "0.00" assert user_balances[user2.id]["total_settlements_paid"] == "0.00" assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "0.00" # User3 assertions assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "33.34" assert user_balances[user3.id]["total_settlements_paid"] == "30.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "-3.34" # (0+0) - (33.34+30) = -63.34. # U3 owes 33.34 for expense. U3 paid U1 30. So U3 owes 3.34. sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements: User3 pays User1 3.34 suggested = summary_data["suggested_settlements"] assert len(suggested) == 1 assert suggested[0]["from_user_id"] == user3.id assert suggested[0]["to_user_id"] == user1.id assert suggested[0]["amount"] == "3.34" async def test_balance_summary_complex_scenario( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user2_headers = test_user2_api_costs["headers"] user3 = test_user3_api_costs["user"] user3_headers = test_user3_api_costs["headers"] group = test_group_api_costs # Expense 1: User1 pays 100. Split: U1=20, U2=40, U3=40. exp1_payload = { "description": "Complex Exp1", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, "splits_in": [ {"user_id": user1.id, "owed_amount": "20.00"}, {"user_id": user2.id, "owed_amount": "40.00"}, {"user_id": user3.id, "owed_amount": "40.00"}, ] } exp1_res = await client.post(f"{settings.API_V1_STR}/expenses/", json=exp1_payload, headers=user1_headers) assert exp1_res.status_code == 201 exp1_id = exp1_res.json()["id"] exp1_splits = await self._get_expense_splits(client, exp1_id, user1_headers) u2_exp1_split = next(s for s in exp1_splits if s["user_id"] == user2.id) u3_exp1_split = next(s for s in exp1_splits if s["user_id"] == user3.id) # Expense 2: User2 pays 50. Split: U1=10, U2=20, U3=20. exp2_payload = { "description": "Complex Exp2", "total_amount": "50.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user2.id, "split_type": SplitTypeEnum.EXACT_AMOUNTS.value, "splits_in": [ {"user_id": user1.id, "owed_amount": "10.00"}, {"user_id": user2.id, "owed_amount": "20.00"}, {"user_id": user3.id, "owed_amount": "20.00"}, ] } exp2_res = await client.post(f"{settings.API_V1_STR}/expenses/", json=exp2_payload, headers=user2_headers) assert exp2_res.status_code == 201 exp2_id = exp2_res.json()["id"] exp2_splits = await self._get_expense_splits(client, exp2_id, user2_headers) u1_exp2_split = next(s for s in exp2_splits if s["user_id"] == user1.id) # Settlement Activities # User2 pays 10 towards their share of Exp1 via SA. await self._create_settlement_activity(client, u2_exp1_split["id"], user2.id, "10.00", user2_headers) # User3 pays 20 towards their share of Exp1 via SA. await self._create_settlement_activity(client, u3_exp1_split["id"], user3.id, "20.00", user3_headers) # User1 pays 5 towards their share of Exp2 via SA. await self._create_settlement_activity(client, u1_exp2_split["id"], user1.id, "5.00", user1_headers) # Get balance summary summary_response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers ) assert summary_response.status_code == 200 summary_data = summary_response.json() user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # User1: total_paid_for_expenses=100. Initial Share (20+10)=30. SA paid by U1 for Exp2=5. Adjusted Share = 30-5=25. Net=+45 assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "25.00" assert user_balances[user1.id]["total_settlements_paid"] == "0.00" assert user_balances[user1.id]["total_settlements_received"] == "0.00" assert user_balances[user1.id]["net_balance"] == "45.00" # User2: total_paid_for_expenses=50. Initial Share (40+20)=60. SA paid by U2 for Exp1=10. Adjusted Share = 60-10=50. Net=-5 assert user_balances[user2.id]["total_paid_for_expenses"] == "50.00" assert user_balances[user2.id]["total_share_of_expenses"] == "50.00" assert user_balances[user2.id]["total_settlements_paid"] == "0.00" assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "-5.00" # Corrected from 0 to -5 from manual calc # User3: total_paid_for_expenses=0. Initial Share (40+20)=60. SA paid by U3 for Exp1=20. Adjusted Share = 60-20=40. Net=-40 assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "40.00" assert user_balances[user3.id]["total_settlements_paid"] == "0.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "-40.00" sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements: User2 pays User1 5.00, User3 pays User1 40.00 suggested = sorted(summary_data["suggested_settlements"], key=lambda s: s["from_user_id"]) assert len(suggested) == 2 assert suggested[0]["from_user_id"] == user2.id assert suggested[0]["to_user_id"] == user1.id assert suggested[0]["amount"] == "5.00" assert suggested[1]["from_user_id"] == user3.id assert suggested[1]["to_user_id"] == user1.id assert suggested[1]["amount"] == "40.00" async def test_balance_summary_full_settlement_activity_one_user( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user2_headers = test_user2_api_costs["headers"] # For User2 to make a settlement user3 = test_user3_api_costs["user"] group = test_group_api_costs # Create an expense: User1 paid 100, split EQUAL among User1, User2, User3 expense_payload = { "description": "Dinner - Full SA by User2", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EQUAL.value, } exp_response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers ) assert exp_response.status_code == 201 expense_id = exp_response.json()["id"] # Get splits to find User2's split_id splits = await self._get_expense_splits(client, expense_id, user1_headers) user2_split = next(s for s in splits if s["user_id"] == user2.id) assert user2_split["owed_amount"] == "33.33" # From EQUAL split # User2 pays their full share via SettlementActivity await self._create_settlement_activity( client, user2_split["id"], user2.id, user2_split["owed_amount"], user2_headers ) # Get balance summary summary_response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers ) assert summary_response.status_code == 200 summary_data = summary_response.json() user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # Expected: # User1: Paid 100. Initial Share 33.33. User2 paid their 33.33. U1 is now owed 33.34 by U3. # total_paid_for_expenses = 100.00 # adjusted_total_share_of_expenses = 33.33 (own share, not affected by U2's SA payment) # net_balance = (100.00 + 0) - (33.33 + 0) = 66.67. This is before considering who owes whom. # The system calculates: User1 is owed 33.34 by User3. User2 is settled. So User1 is +33.34. # User2: Paid 0 for expenses. Initial Share 33.33. Paid 33.33 via SA. # total_paid_for_expenses = 0.00 # adjusted_total_share_of_expenses = 33.33 - 33.33 = 0.00 # net_balance = (0 + 0) - (0 + 0) = 0.00 # User3: Paid 0 for expenses. Initial Share 33.34. Paid 0. # total_paid_for_expenses = 0.00 # adjusted_total_share_of_expenses = 33.34 # net_balance = (0 + 0) - (33.34 + 0) = -33.34 # Sum = 0 # User1 assertions assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "33.33" assert user_balances[user1.id]["total_settlements_paid"] == "0.00" assert user_balances[user1.id]["total_settlements_received"] == "0.00" assert user_balances[user1.id]["net_balance"] == "33.34" # User2 assertions assert user_balances[user2.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user2.id]["total_share_of_expenses"] == "0.00" assert user_balances[user2.id]["total_settlements_paid"] == "0.00" assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "0.00" # User3 assertions assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "33.34" assert user_balances[user3.id]["total_settlements_paid"] == "0.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "-33.34" sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements: User3 pays User1 33.34 suggested = summary_data["suggested_settlements"] assert len(suggested) == 1 assert suggested[0]["from_user_id"] == user3.id assert suggested[0]["to_user_id"] == user1.id assert suggested[0]["amount"] == "33.34" async def test_balance_summary_partial_settlement_activity( self, client: httpx.AsyncClient, test_user1_api_costs: Dict[str, Any], test_user2_api_costs: Dict[str, Any], test_user3_api_costs: Dict[str, Any], test_group_api_costs: Group, ): user1 = test_user1_api_costs["user"] user1_headers = test_user1_api_costs["headers"] user2 = test_user2_api_costs["user"] user2_headers = test_user2_api_costs["headers"] user3 = test_user3_api_costs["user"] group = test_group_api_costs # Create an expense: User1 paid 100, split EQUAL among User1, User2, User3 expense_payload = { "description": "Dinner - Partial SA by User2", "total_amount": "100.00", "currency": "USD", "group_id": group.id, "paid_by_user_id": user1.id, "split_type": SplitTypeEnum.EQUAL.value, } exp_response = await client.post( f"{settings.API_V1_STR}/expenses/", json=expense_payload, headers=user1_headers ) assert exp_response.status_code == 201 expense_id = exp_response.json()["id"] splits = await self._get_expense_splits(client, expense_id, user1_headers) user2_split = next(s for s in splits if s["user_id"] == user2.id) assert user2_split["owed_amount"] == "33.33" # User2 pays 10.00 of their 33.33 share via SettlementActivity await self._create_settlement_activity( client, user2_split["id"], user2.id, "10.00", user2_headers ) summary_response = await client.get( f"{settings.API_V1_STR}/groups/{group.id}/balance-summary", headers=user1_headers ) assert summary_response.status_code == 200 summary_data = summary_response.json() user_balances = {ub["user_id"]: ub for ub in summary_data["user_balances"]} # Expected balances: # User1: Paid 100. Share 33.33. Effectively received 10 from User2. Net: +56.67 # User2: Share 33.33. Paid 10.00 via SA. Adjusted Share: 23.33. Net: -23.33 # User3: Share 33.34. Paid 0. Net: -33.34 # Sum = 56.67 - 23.33 - 33.34 = 0 # User1 assertions assert user_balances[user1.id]["total_paid_for_expenses"] == "100.00" assert user_balances[user1.id]["total_share_of_expenses"] == "33.33" assert user_balances[user1.id]["total_settlements_paid"] == "0.00" assert user_balances[user1.id]["total_settlements_received"] == "0.00" assert user_balances[user1.id]["net_balance"] == "56.67" # User2 assertions assert user_balances[user2.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user2.id]["total_share_of_expenses"] == "23.33" # 33.33 initial - 10.00 SA assert user_balances[user2.id]["total_settlements_paid"] == "0.00" assert user_balances[user2.id]["total_settlements_received"] == "0.00" assert user_balances[user2.id]["net_balance"] == "-23.33" # User3 assertions assert user_balances[user3.id]["total_paid_for_expenses"] == "0.00" assert user_balances[user3.id]["total_share_of_expenses"] == "33.34" assert user_balances[user3.id]["total_settlements_paid"] == "0.00" assert user_balances[user3.id]["total_settlements_received"] == "0.00" assert user_balances[user3.id]["net_balance"] == "-33.34" sum_net_balances = sum(Decimal(ub["net_balance"]) for ub in user_balances.values()) assert sum_net_balances == Decimal("0.00") # Suggested settlements: User2 pays User1 23.33, User3 pays User1 33.34 suggested = sorted(summary_data["suggested_settlements"], key=lambda s: (s["from_user_id"], s["amount"])) assert len(suggested) == 2 assert suggested[0]["from_user_id"] == user2.id assert suggested[0]["to_user_id"] == user1.id assert suggested[0]["amount"] == "23.33" assert suggested[1]["from_user_id"] == user3.id assert suggested[1]["to_user_id"] == user1.id assert suggested[1]["amount"] == "33.34"