
- 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.
2283 lines
100 KiB
Python
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"
|