mitlist/be/tests/api/v1/test_financials.py
google-labs-jules[bot] f1152c5745 feat: Implement traceable expense splitting and settlement activities
Backend:
- Added `SettlementActivity` model to track payments against specific expense shares.
- Added `status` and `paid_at` to `ExpenseSplit` model.
- Added `overall_settlement_status` to `Expense` model.
- Implemented CRUD for `SettlementActivity`, including logic to update parent expense/split statuses.
- Updated `Expense` CRUD to initialize new status fields.
- Defined Pydantic schemas for `SettlementActivity` and updated `Expense/ExpenseSplit` schemas.
- Exposed API endpoints for creating/listing settlement activities and settling shares.
- Adjusted group balance summary logic to include settlement activities.
- Added comprehensive backend unit and API tests for new functionality.

Frontend (Foundation & TODOs due to my current capabilities):
- Created TypeScript interfaces for all new/updated models.
- Set up `listDetailStore.ts` with an action to handle `settleExpenseSplit` (API call is a placeholder) and refresh data.
- Created `SettleShareModal.vue` component for payment confirmation.
- Added unit tests for the new modal and store logic.
- Updated `ListDetailPage.vue` to display detailed expense/share statuses and settlement activities.
- `mitlist_doc.md` updated to reflect all backend changes and current frontend status.
- A `TODO.md` (implicitly within `mitlist_doc.md`'s new section) outlines necessary manual frontend integrations for `api.ts` and `ListDetailPage.vue` to complete the 'Settle Share' UI flow.

This set of changes provides the core backend infrastructure for precise expense share tracking and settlement, and lays the groundwork for full frontend integration.
2025-05-22 07:05:31 +00:00

412 lines
16 KiB
Python

import pytest
import httpx
from typing import List, Dict, Any
from decimal import Decimal
from datetime import datetime, timezone
from app.models import (
User,
Group,
Expense,
ExpenseSplit,
SettlementActivity,
UserRoleEnum,
SplitTypeEnum,
ExpenseOverallStatusEnum,
ExpenseSplitStatusEnum
)
from app.schemas.settlement_activity import SettlementActivityPublic, SettlementActivityCreate
from app.schemas.expense import ExpensePublic, ExpenseSplitPublic
from app.core.config import settings # For API prefix
# Assume db_session, event_loop, client are provided by conftest.py or similar setup
# For this example, I'll define basic user/auth fixtures if not assumed from conftest
@pytest.fixture
async def test_user1_api(db_session, client: httpx.AsyncClient) -> Dict[str, Any]:
user = User(email="api.user1@example.com", name="API User 1", hashed_password="password1")
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
# Simulate token login - in a real setup, you'd call a login endpoint
# For now, just returning user and headers directly for mock authentication
# This would typically be handled by a dependency override in tests
# For simplicity, we'll assume current_active_user dependency correctly resolves to this user
# when these headers are used (or mock the dependency).
return {"user": user, "headers": {"Authorization": f"Bearer token-for-{user.id}"}}
@pytest.fixture
async def test_user2_api(db_session, client: httpx.AsyncClient) -> Dict[str, Any]:
user = User(email="api.user2@example.com", name="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-{user.id}"}}
@pytest.fixture
async def test_group_user1_owner_api(db_session, test_user1_api: Dict[str, Any]) -> Group:
user1 = test_user1_api["user"]
group = Group(name="API Test Group", created_by_id=user1.id)
db_session.add(group)
await db_session.flush() # Get group.id
# Add user1 as owner
from app.models import UserGroup
user_group_assoc = UserGroup(user_id=user1.id, group_id=group.id, role=UserRoleEnum.owner)
db_session.add(user_group_assoc)
await db_session.commit()
await db_session.refresh(group)
return group
@pytest.fixture
async def test_expense_in_group_api(db_session, test_user1_api: Dict[str, Any], test_group_user1_owner_api: Group) -> Expense:
user1 = test_user1_api["user"]
expense = Expense(
description="Group API Expense",
total_amount=Decimal("50.00"),
currency="USD",
group_id=test_group_user1_owner_api.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.commit()
await db_session.refresh(expense)
return expense
@pytest.fixture
async def test_expense_split_for_user2_api(db_session, test_expense_in_group_api: Expense, test_user1_api: Dict[str, Any], test_user2_api: Dict[str, Any]) -> ExpenseSplit:
user1 = test_user1_api["user"]
user2 = test_user2_api["user"]
# Split for User 1 (payer)
split1 = ExpenseSplit(
expense_id=test_expense_in_group_api.id,
user_id=user1.id,
owed_amount=Decimal("25.00"),
status=ExpenseSplitStatusEnum.unpaid
)
# Split for User 2 (owes)
split2 = ExpenseSplit(
expense_id=test_expense_in_group_api.id,
user_id=user2.id,
owed_amount=Decimal("25.00"),
status=ExpenseSplitStatusEnum.unpaid
)
db_session.add_all([split1, split2])
# Add user2 to the group as a member for permission checks
from app.models import UserGroup
user_group_assoc = UserGroup(user_id=user2.id, group_id=test_expense_in_group_api.group_id, role=UserRoleEnum.member)
db_session.add(user_group_assoc)
await db_session.commit()
await db_session.refresh(split1)
await db_session.refresh(split2)
return split2 # Return the split that user2 owes
# --- Tests for POST /expense_splits/{expense_split_id}/settle ---
@pytest.mark.asyncio
async def test_settle_expense_split_by_self_success(
client: httpx.AsyncClient,
test_user2_api: Dict[str, Any], # User2 will settle their own split
test_expense_split_for_user2_api: ExpenseSplit,
db_session: AsyncSession # To verify db changes
):
user2 = test_user2_api["user"]
user2_headers = test_user2_api["headers"]
split_to_settle = test_expense_split_for_user2_api
payload = SettlementActivityCreate(
expense_split_id=split_to_settle.id,
paid_by_user_id=user2.id, # User2 is paying
amount_paid=split_to_settle.owed_amount # Full payment
)
response = await client.post(
f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle",
json=payload.model_dump(mode='json'), # Pydantic v2
headers=user2_headers
)
assert response.status_code == 201
activity_data = response.json()
assert activity_data["amount_paid"] == str(split_to_settle.owed_amount) # Compare as string due to JSON
assert activity_data["paid_by_user_id"] == user2.id
assert activity_data["expense_split_id"] == split_to_settle.id
assert "id" in activity_data
# Verify DB state
await db_session.refresh(split_to_settle)
assert split_to_settle.status == ExpenseSplitStatusEnum.paid
assert split_to_settle.paid_at is not None
# Verify parent expense status (this requires other splits to be paid too)
# For a focused test, we might need to ensure the other split (user1's share) is also paid.
# Or, accept 'partially_paid' if only this one is paid.
parent_expense_id = split_to_settle.expense_id
parent_expense = await db_session.get(Expense, parent_expense_id)
await db_session.refresh(parent_expense, attribute_names=['splits']) # Load splits to check status
all_splits_paid = all(s.status == ExpenseSplitStatusEnum.paid for s in parent_expense.splits)
if all_splits_paid:
assert parent_expense.overall_settlement_status == ExpenseOverallStatusEnum.paid
else:
assert parent_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid
@pytest.mark.asyncio
async def test_settle_expense_split_by_group_owner_success(
client: httpx.AsyncClient,
test_user1_api: Dict[str, Any], # User1 is group owner
test_user2_api: Dict[str, Any], # User2 owes the split
test_expense_split_for_user2_api: ExpenseSplit,
db_session: AsyncSession
):
user1_headers = test_user1_api["headers"]
user_who_owes = test_user2_api["user"]
split_to_settle = test_expense_split_for_user2_api
payload = SettlementActivityCreate(
expense_split_id=split_to_settle.id,
paid_by_user_id=user_who_owes.id, # User1 (owner) records that User2 has paid
amount_paid=split_to_settle.owed_amount
)
response = await client.post(
f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle",
json=payload.model_dump(mode='json'),
headers=user1_headers # Authenticated as group owner
)
assert response.status_code == 201
activity_data = response.json()
assert activity_data["paid_by_user_id"] == user_who_owes.id
assert activity_data["created_by_user_id"] == test_user1_api["user"].id # Activity created by owner
await db_session.refresh(split_to_settle)
assert split_to_settle.status == ExpenseSplitStatusEnum.paid
@pytest.mark.asyncio
async def test_settle_expense_split_path_body_id_mismatch(
client: httpx.AsyncClient, test_user2_api: Dict[str, Any], test_expense_split_for_user2_api: ExpenseSplit
):
user2_headers = test_user2_api["headers"]
split_to_settle = test_expense_split_for_user2_api
payload = SettlementActivityCreate(
expense_split_id=split_to_settle.id + 1, # Mismatch
paid_by_user_id=test_user2_api["user"].id,
amount_paid=split_to_settle.owed_amount
)
response = await client.post(
f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle",
json=payload.model_dump(mode='json'), headers=user2_headers
)
assert response.status_code == 400 # As per API endpoint logic
@pytest.mark.asyncio
async def test_settle_expense_split_not_found(
client: httpx.AsyncClient, test_user2_api: Dict[str, Any]
):
user2_headers = test_user2_api["headers"]
payload = SettlementActivityCreate(expense_split_id=9999, paid_by_user_id=test_user2_api["user"].id, amount_paid=Decimal("10.00"))
response = await client.post(
f"{settings.API_V1_STR}/expense_splits/9999/settle",
json=payload.model_dump(mode='json'), headers=user2_headers
)
assert response.status_code == 404 # ItemNotFoundError
@pytest.mark.asyncio
async def test_settle_expense_split_insufficient_permissions(
client: httpx.AsyncClient,
test_user1_api: Dict[str, Any], # User1 is not group owner for this setup, nor involved in split
test_user2_api: Dict[str, Any],
test_expense_split_for_user2_api: ExpenseSplit, # User2 owes this
db_session: AsyncSession
):
# Create a new user (user3) who is not involved and not an owner
user3 = User(email="api.user3@example.com", name="API User 3", hashed_password="password3")
db_session.add(user3)
await db_session.commit()
user3_headers = {"Authorization": f"Bearer token-for-{user3.id}"}
split_owner = test_user2_api["user"] # User2 owns the split
split_to_settle = test_expense_split_for_user2_api
payload = SettlementActivityCreate(
expense_split_id=split_to_settle.id,
paid_by_user_id=split_owner.id, # User2 is paying
amount_paid=split_to_settle.owed_amount
)
# User3 (neither payer nor group owner) tries to record User2's payment
response = await client.post(
f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle",
json=payload.model_dump(mode='json'),
headers=user3_headers # Authenticated as User3
)
assert response.status_code == 403
# --- Tests for GET /expense_splits/{expense_split_id}/settlement_activities ---
@pytest.mark.asyncio
async def test_get_settlement_activities_success(
client: httpx.AsyncClient,
test_user1_api: Dict[str, Any], # Group owner / expense creator
test_user2_api: Dict[str, Any], # User who owes and pays
test_expense_split_for_user2_api: ExpenseSplit,
db_session: AsyncSession
):
user1_headers = test_user1_api["headers"]
user2 = test_user2_api["user"]
split = test_expense_split_for_user2_api
# Create a settlement activity first
activity_payload = SettlementActivityCreate(expense_split_id=split.id, paid_by_user_id=user2.id, amount_paid=Decimal("10.00"))
await client.post(
f"{settings.API_V1_STR}/expense_splits/{split.id}/settle",
json=activity_payload.model_dump(mode='json'), headers=test_user2_api["headers"] # User2 settles
)
# User1 (group owner) fetches activities
response = await client.get(
f"{settings.API_V1_STR}/expense_splits/{split.id}/settlement_activities",
headers=user1_headers
)
assert response.status_code == 200
activities_data = response.json()
assert isinstance(activities_data, list)
assert len(activities_data) == 1
assert activities_data[0]["amount_paid"] == "10.00"
assert activities_data[0]["paid_by_user_id"] == user2.id
@pytest.mark.asyncio
async def test_get_settlement_activities_split_not_found(
client: httpx.AsyncClient, test_user1_api: Dict[str, Any]
):
user1_headers = test_user1_api["headers"]
response = await client.get(
f"{settings.API_V1_STR}/expense_splits/9999/settlement_activities",
headers=user1_headers
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_settlement_activities_no_permission(
client: httpx.AsyncClient,
test_expense_split_for_user2_api: ExpenseSplit, # Belongs to group of user1/user2
db_session: AsyncSession
):
# Create a new user (user3) who is not in the group
user3 = User(email="api.user3.other@example.com", name="API User 3 Other", hashed_password="password3")
db_session.add(user3)
await db_session.commit()
user3_headers = {"Authorization": f"Bearer token-for-{user3.id}"}
response = await client.get(
f"{settings.API_V1_STR}/expense_splits/{test_expense_split_for_user2_api.id}/settlement_activities",
headers=user3_headers # Authenticated as User3
)
assert response.status_code == 403
# --- Test existing expense endpoints for new fields ---
@pytest.mark.asyncio
async def test_get_expense_by_id_includes_new_fields(
client: httpx.AsyncClient,
test_user1_api: Dict[str, Any], # User in group
test_expense_in_group_api: Expense,
test_expense_split_for_user2_api: ExpenseSplit # one of the splits
):
user1_headers = test_user1_api["headers"]
expense_id = test_expense_in_group_api.id
response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers)
assert response.status_code == 200
expense_data = response.json()
assert "overall_settlement_status" in expense_data
assert expense_data["overall_settlement_status"] == ExpenseOverallStatusEnum.unpaid.value # Initial state
assert "splits" in expense_data
assert len(expense_data["splits"]) > 0
found_split = False
for split_json in expense_data["splits"]:
if split_json["id"] == test_expense_split_for_user2_api.id:
found_split = True
assert "status" in split_json
assert split_json["status"] == ExpenseSplitStatusEnum.unpaid.value # Initial state
assert "paid_at" in split_json # Should be null initially
assert split_json["paid_at"] is None
assert "settlement_activities" in split_json
assert isinstance(split_json["settlement_activities"], list)
assert len(split_json["settlement_activities"]) == 0 # No activities yet
break
assert found_split, "The specific test split was not found in the expense data."
# Placeholder for conftest.py content if needed for local execution understanding
"""
# conftest.py (example structure)
import pytest
import asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from app.main import app # Your FastAPI app
from app.database import Base, get_transactional_session # Your DB setup
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
engine = create_async_engine(TEST_DATABASE_URL, echo=True)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session", autouse=True)
async def setup_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def db_session() -> AsyncSession:
async with TestingSessionLocal() as session:
# Transaction is handled by get_transactional_session override or test logic
yield session
# Rollback changes after test if not using transactional tests per case
# await session.rollback() # Or rely on test isolation method
@pytest.fixture
async def client(db_session) -> AsyncClient: # Depends on db_session to ensure DB is ready
async def override_get_transactional_session():
# Provide the test session, potentially managing transactions per test
# This is a simplified version; real setup might involve nested transactions
# or ensuring each test runs in its own transaction that's rolled back.
try:
yield db_session
# await db_session.commit() # Or commit if test is meant to persist then rollback globally
except Exception:
# await db_session.rollback()
raise
# finally:
# await db_session.rollback() # Ensure rollback after each test using this fixture
app.dependency_overrides[get_transactional_session] = override_get_transactional_session
async with AsyncClient(app=app, base_url="http://test") as c:
yield c
del app.dependency_overrides[get_transactional_session] # Clean up
"""