mitlist/be/tests/crud/test_settlement_activity.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

370 lines
16 KiB
Python

import pytest
from decimal import Decimal
from datetime import datetime, timezone
from typing import AsyncGenerator, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import (
User,
Group,
Expense,
ExpenseSplit,
SettlementActivity,
ExpenseSplitStatusEnum,
ExpenseOverallStatusEnum,
SplitTypeEnum,
UserRoleEnum
)
from app.crud.settlement_activity import (
create_settlement_activity,
get_settlement_activity_by_id,
get_settlement_activities_for_split,
update_expense_split_status, # For direct testing if needed
update_expense_overall_status # For direct testing if needed
)
from app.schemas.settlement_activity import SettlementActivityCreate as SettlementActivityCreateSchema
@pytest.fixture
async def test_user1(db_session: AsyncSession) -> User:
user = User(email="user1@example.com", name="Test User 1", hashed_password="password1")
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def test_user2(db_session: AsyncSession) -> User:
user = User(email="user2@example.com", name="Test User 2", hashed_password="password2")
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def test_group(db_session: AsyncSession, test_user1: User) -> Group:
group = Group(name="Test Group", created_by_id=test_user1.id)
db_session.add(group)
await db_session.commit()
# Add user1 as owner and user2 as member (can be done in specific tests if needed)
await db_session.refresh(group)
return group
@pytest.fixture
async def test_expense(db_session: AsyncSession, test_user1: User, test_group: Group) -> Expense:
expense = Expense(
description="Test Expense for Settlement",
total_amount=Decimal("20.00"),
currency="USD",
expense_date=datetime.now(timezone.utc),
split_type=SplitTypeEnum.EQUAL,
group_id=test_group.id,
paid_by_user_id=test_user1.id,
created_by_user_id=test_user1.id,
overall_settlement_status=ExpenseOverallStatusEnum.unpaid # Initial status
)
db_session.add(expense)
await db_session.commit()
await db_session.refresh(expense)
return expense
@pytest.fixture
async def test_expense_split_user2_owes(db_session: AsyncSession, test_expense: Expense, test_user2: User) -> ExpenseSplit:
# User2 owes 10.00 to User1 (who paid the expense)
split = ExpenseSplit(
expense_id=test_expense.id,
user_id=test_user2.id,
owed_amount=Decimal("10.00"),
status=ExpenseSplitStatusEnum.unpaid # Initial status
)
db_session.add(split)
await db_session.commit()
await db_session.refresh(split)
return split
@pytest.fixture
async def test_expense_split_user1_owes_self_for_completeness(db_session: AsyncSession, test_expense: Expense, test_user1: User) -> ExpenseSplit:
# User1's own share (owes 10.00 to self, effectively settled)
# This is often how splits are represented, even for the payer
split = ExpenseSplit(
expense_id=test_expense.id,
user_id=test_user1.id,
owed_amount=Decimal("10.00"), # User1's share of the 20.00 expense
status=ExpenseSplitStatusEnum.unpaid # Initial status, though payer's own share might be considered paid by some logic
)
db_session.add(split)
await db_session.commit()
await db_session.refresh(split)
return split
# --- Tests for create_settlement_activity ---
@pytest.mark.asyncio
async def test_create_settlement_activity_full_payment(
db_session: AsyncSession,
test_user1: User, # Creator of activity, Payer of expense
test_user2: User, # Payer of this settlement activity (settling their debt)
test_expense: Expense,
test_expense_split_user2_owes: ExpenseSplit,
test_expense_split_user1_owes_self_for_completeness: ExpenseSplit # User1's own share
):
# Scenario: User2 fully pays their 10.00 share.
# User1's share is also part of the expense. Let's assume it's 'paid' by default or handled separately.
# For this test, we focus on User2's split.
# To make overall expense paid, User1's split also needs to be considered paid.
# We can manually update User1's split status to paid for this test case.
test_expense_split_user1_owes_self_for_completeness.status = ExpenseSplitStatusEnum.paid
test_expense_split_user1_owes_self_for_completeness.paid_at = datetime.now(timezone.utc)
db_session.add(test_expense_split_user1_owes_self_for_completeness)
await db_session.commit()
await db_session.refresh(test_expense_split_user1_owes_self_for_completeness)
await db_session.refresh(test_expense) # Refresh expense to reflect split status change
activity_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id, # User2 is paying their share
amount_paid=Decimal("10.00")
)
created_activity = await create_settlement_activity(
db=db_session,
settlement_activity_in=activity_data,
current_user_id=test_user2.id # User2 is recording their own payment
)
assert created_activity is not None
assert created_activity.expense_split_id == test_expense_split_user2_owes.id
assert created_activity.paid_by_user_id == test_user2.id
assert created_activity.amount_paid == Decimal("10.00")
assert created_activity.created_by_user_id == test_user2.id
await db_session.refresh(test_expense_split_user2_owes)
await db_session.refresh(test_expense) # Refresh to get updated overall_status
assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.paid
assert test_expense_split_user2_owes.paid_at is not None
# Check parent expense status
# This depends on all splits being paid for the expense to be fully paid.
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.paid
@pytest.mark.asyncio
async def test_create_settlement_activity_partial_payment(
db_session: AsyncSession,
test_user1: User, # Creator of activity
test_user2: User, # Payer of this settlement activity
test_expense: Expense,
test_expense_split_user2_owes: ExpenseSplit
):
activity_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("5.00")
)
created_activity = await create_settlement_activity(
db=db_session,
settlement_activity_in=activity_data,
current_user_id=test_user2.id # User2 records their payment
)
assert created_activity is not None
assert created_activity.amount_paid == Decimal("5.00")
await db_session.refresh(test_expense_split_user2_owes)
await db_session.refresh(test_expense)
assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.partially_paid
assert test_expense_split_user2_owes.paid_at is None
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid # Assuming other splits are unpaid or partially paid
@pytest.mark.asyncio
async def test_create_settlement_activity_multiple_payments_to_full(
db_session: AsyncSession,
test_user1: User,
test_user2: User,
test_expense: Expense,
test_expense_split_user2_owes: ExpenseSplit,
test_expense_split_user1_owes_self_for_completeness: ExpenseSplit # User1's own share
):
# Assume user1's share is already 'paid' for overall expense status testing
test_expense_split_user1_owes_self_for_completeness.status = ExpenseSplitStatusEnum.paid
test_expense_split_user1_owes_self_for_completeness.paid_at = datetime.now(timezone.utc)
db_session.add(test_expense_split_user1_owes_self_for_completeness)
await db_session.commit()
# First partial payment
activity_data1 = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("3.00")
)
await create_settlement_activity(db=db_session, settlement_activity_in=activity_data1, current_user_id=test_user2.id)
await db_session.refresh(test_expense_split_user2_owes)
await db_session.refresh(test_expense)
assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.partially_paid
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid
# Second payment completing the amount
activity_data2 = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("7.00") # 3.00 + 7.00 = 10.00
)
await create_settlement_activity(db=db_session, settlement_activity_in=activity_data2, current_user_id=test_user2.id)
await db_session.refresh(test_expense_split_user2_owes)
await db_session.refresh(test_expense)
assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.paid
assert test_expense_split_user2_owes.paid_at is not None
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.paid
@pytest.mark.asyncio
async def test_create_settlement_activity_invalid_split_id(
db_session: AsyncSession, test_user1: User
):
activity_data = SettlementActivityCreateSchema(
expense_split_id=99999, # Non-existent
paid_by_user_id=test_user1.id,
amount_paid=Decimal("10.00")
)
# The CRUD function returns None for not found related objects
result = await create_settlement_activity(db=db_session, settlement_activity_in=activity_data, current_user_id=test_user1.id)
assert result is None
@pytest.mark.asyncio
async def test_create_settlement_activity_invalid_paid_by_user_id(
db_session: AsyncSession, test_user1: User, test_expense_split_user2_owes: ExpenseSplit
):
activity_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=99999, # Non-existent
amount_paid=Decimal("10.00")
)
result = await create_settlement_activity(db=db_session, settlement_activity_in=activity_data, current_user_id=test_user1.id)
assert result is None
# --- Tests for get_settlement_activity_by_id ---
@pytest.mark.asyncio
async def test_get_settlement_activity_by_id_found(
db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit
):
activity_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("5.00")
)
created = await create_settlement_activity(db=db_session, settlement_activity_in=activity_data, current_user_id=test_user2.id)
assert created is not None
fetched = await get_settlement_activity_by_id(db=db_session, settlement_activity_id=created.id)
assert fetched is not None
assert fetched.id == created.id
assert fetched.amount_paid == Decimal("5.00")
@pytest.mark.asyncio
async def test_get_settlement_activity_by_id_not_found(db_session: AsyncSession):
fetched = await get_settlement_activity_by_id(db=db_session, settlement_activity_id=99999)
assert fetched is None
# --- Tests for get_settlement_activities_for_split ---
@pytest.mark.asyncio
async def test_get_settlement_activities_for_split_multiple_found(
db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit
):
act1_data = SettlementActivityCreateSchema(expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("2.00"))
act2_data = SettlementActivityCreateSchema(expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("3.00"))
await create_settlement_activity(db=db_session, settlement_activity_in=act1_data, current_user_id=test_user2.id)
await create_settlement_activity(db=db_session, settlement_activity_in=act2_data, current_user_id=test_user2.id)
activities: List[SettlementActivity] = await get_settlement_activities_for_split(db=db_session, expense_split_id=test_expense_split_user2_owes.id)
assert len(activities) == 2
amounts = sorted([act.amount_paid for act in activities])
assert amounts == [Decimal("2.00"), Decimal("3.00")]
@pytest.mark.asyncio
async def test_get_settlement_activities_for_split_none_found(
db_session: AsyncSession, test_expense_split_user2_owes: ExpenseSplit # A split with no activities
):
activities: List[SettlementActivity] = await get_settlement_activities_for_split(db=db_session, expense_split_id=test_expense_split_user2_owes.id)
assert len(activities) == 0
# Note: Direct tests for helper functions update_expense_split_status and update_expense_overall_status
# could be added if complex logic within them isn't fully covered by create_settlement_activity tests.
# However, their effects are validated through the main CRUD function here.
# For example, to test update_expense_split_status directly:
# 1. Create an ExpenseSplit.
# 2. Create one or more SettlementActivity instances directly in the DB session for that split.
# 3. Call await update_expense_split_status(db_session, expense_split_id=split.id).
# 4. Assert the split.status and split.paid_at are as expected.
# Similar for update_expense_overall_status by setting up multiple splits.
# For now, relying on indirect testing via create_settlement_activity.
# More tests can be added for edge cases, such as:
# - Overpayment (current logic in update_expense_split_status treats >= owed_amount as 'paid').
# - Different users creating the activity vs. paying for it (permission aspects, though that's more for API tests).
# - Interactions with different expense split types if that affects status updates.
# - Ensuring `overall_settlement_status` correctly reflects if one split is paid, another is unpaid, etc.
# (e.g. test_expense_split_user1_owes_self_for_completeness is set to unpaid initially).
# A test case where one split becomes 'paid' but another remains 'unpaid' should result in 'partially_paid' for the expense.
@pytest.mark.asyncio
async def test_create_settlement_activity_overall_status_becomes_partially_paid(
db_session: AsyncSession,
test_user1: User,
test_user2: User,
test_expense: Expense, # Overall status is initially unpaid
test_expense_split_user2_owes: ExpenseSplit, # User2's split, initially unpaid
test_expense_split_user1_owes_self_for_completeness: ExpenseSplit # User1's split, also initially unpaid
):
# Sanity check: both splits and expense are unpaid initially
assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.unpaid
assert test_expense_split_user1_owes_self_for_completeness.status == ExpenseSplitStatusEnum.unpaid
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.unpaid
# User2 fully pays their 10.00 share.
activity_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id, # User2 is paying their share
amount_paid=Decimal("10.00")
)
await create_settlement_activity(
db=db_session,
settlement_activity_in=activity_data,
current_user_id=test_user2.id # User2 is recording their own payment
)
await db_session.refresh(test_expense_split_user2_owes)
await db_session.refresh(test_expense_split_user1_owes_self_for_completeness) # Ensure its status is current
await db_session.refresh(test_expense)
assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.paid
assert test_expense_split_user1_owes_self_for_completeness.status == ExpenseSplitStatusEnum.unpaid # User1's split is still unpaid
# Since one split is paid and the other is unpaid, the overall expense status should be partially_paid
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid
# Example of a placeholder for db_session fixture if not provided by conftest.py
# @pytest.fixture
# async def db_session() -> AsyncGenerator[AsyncSession, None]:
# # This needs to be implemented based on your test database setup
# # e.g., using a test-specific database and creating a new session per test
# # from app.database import SessionLocal # Assuming SessionLocal is your session factory
# # async with SessionLocal() as session:
# # async with session.begin(): # Start a transaction
# # yield session
# # # Transaction will be rolled back here after the test
# pass # Replace with actual implementation if needed