mitlist/be/tests/api/v1/test_costs.py
mohamad a0d67f6c66 feat: Add comprehensive notes and tasks for project stabilization and enhancements
- Introduced a new `notes.md` file to document critical tasks and progress for stabilizing the core functionality of the MitList application.
- Documented the status and findings for key tasks, including backend financial logic fixes, frontend expense split settlement implementation, and core authentication flow reviews.
- Outlined remaining work for production deployment, including secret management, CI/CD pipeline setup, and performance optimizations.
- Updated the logging configuration to change the log level to WARNING for production readiness.
- Enhanced the database connection settings to disable SQL query logging in production.
- Added a new endpoint to list all chores for improved user experience and optimized database queries.
- Implemented various CRUD operations for chore assignments, including creation, retrieval, updating, and deletion.
- Updated frontend components and services to support new chore assignment features and improved error handling.
- Enhanced the expense management system with new fields and improved API interactions for better user experience.
2025-05-24 21:36:57 +02:00

2283 lines
100 KiB
Python

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"