From 29e42b8d8055d1bc0ada37ac4db7bae27835106f Mon Sep 17 00:00:00 2001 From: Mohamad Date: Sat, 21 Jun 2025 00:53:03 +0200 Subject: [PATCH] feat: Enhance cost management features with new endpoints and services This commit introduces significant updates to the cost management functionality, including: - New endpoints for retrieving cost summaries and generating expenses from lists, improving user interaction with financial data. - Implementation of a service layer for cost-related logic, encapsulating the core business rules for calculating cost summaries and managing expenses. - Introduction of a financial activity endpoint to consolidate user expenses and settlements, enhancing the user experience by providing a comprehensive view of financial activities. - Refactoring of existing code to improve maintainability and clarity, including the addition of new exception handling for financial conflicts. These changes aim to streamline cost management processes and enhance the overall functionality of the application. --- be/app/api/v1/endpoints/costs.py | 437 +++++--------------------- be/app/api/v1/endpoints/financials.py | 27 +- be/app/core/exceptions.py | 12 +- be/app/crud/audit.py | 3 +- be/app/crud/settlement_activity.py | 51 ++- be/app/jobs/recurring_expenses.py | 96 ++++-- be/app/schemas/expense.py | 6 +- be/app/schemas/financials.py | 9 + be/app/schemas/recurrence.py | 35 +++ be/app/services/costs_service.py | 343 ++++++++++++++++++++ be/app/services/financials_service.py | 31 ++ fe/src/pages/ListsPage.vue | 4 +- fe/src/services/api.ts | 16 +- 13 files changed, 653 insertions(+), 417 deletions(-) create mode 100644 be/app/schemas/financials.py create mode 100644 be/app/schemas/recurrence.py create mode 100644 be/app/services/costs_service.py create mode 100644 be/app/services/financials_service.py diff --git a/be/app/api/v1/endpoints/costs.py b/be/app/api/v1/endpoints/costs.py index f7adb00..81490f1 100644 --- a/be/app/api/v1/endpoints/costs.py +++ b/be/app/api/v1/endpoints/costs.py @@ -1,113 +1,24 @@ - import logging from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Session, selectinload -from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN -from typing import List from app.database import get_transactional_session from app.auth import current_active_user -from app.models import ( - User as UserModel, - Group as GroupModel, - List as ListModel, - Expense as ExpenseModel, - Item as ItemModel, - UserGroup as UserGroupModel, - SplitTypeEnum, - ExpenseSplit as ExpenseSplitModel, - SettlementActivity as SettlementActivityModel, - Settlement as SettlementModel +from app.models import User as UserModel, Expense as ExpenseModel +from app.schemas.cost import ListCostSummary, GroupBalanceSummary +from app.schemas.expense import ExpensePublic +from app.services import costs_service +from app.core.exceptions import ( + ListNotFoundError, + ListPermissionError, + GroupNotFoundError, + GroupPermissionError, + InvalidOperationError ) -from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement -from app.schemas.expense import ExpenseCreate -from app.crud import list as crud_list -from app.crud import expense as crud_expense -from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError logger = logging.getLogger(__name__) router = APIRouter() -def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> List[SuggestedSettlement]: - """ - Calculate suggested settlements to balance the finances within a group. - - This function takes the current balances of all users and suggests optimal settlements - to minimize the number of transactions needed to settle all debts. - - Args: - user_balances: List of UserBalanceDetail objects with their current balances - - Returns: - List of SuggestedSettlement objects representing the suggested payments - """ - # Create list of users who owe money (negative balance) and who are owed money (positive balance) - debtors = [] # Users who owe money (negative balance) - creditors = [] # Users who are owed money (positive balance) - - # Threshold to consider a balance as zero due to floating point precision - epsilon = Decimal('0.01') - - # Sort users into debtors and creditors - for user in user_balances: - # Skip users with zero balance (or very close to zero) - if abs(user.net_balance) < epsilon: - continue - - if user.net_balance < Decimal('0'): - # User owes money - debtors.append({ - 'user_id': user.user_id, - 'user_identifier': user.user_identifier, - 'amount': -user.net_balance # Convert to positive amount - }) - else: - # User is owed money - creditors.append({ - 'user_id': user.user_id, - 'user_identifier': user.user_identifier, - 'amount': user.net_balance - }) - - # Sort by amount (descending) to handle largest debts first - debtors.sort(key=lambda x: x['amount'], reverse=True) - creditors.sort(key=lambda x: x['amount'], reverse=True) - - settlements = [] - - # Iterate through debtors and match them with creditors - while debtors and creditors: - debtor = debtors[0] - creditor = creditors[0] - - # Determine the settlement amount (the smaller of the two amounts) - amount = min(debtor['amount'], creditor['amount']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - - # Create settlement record - if amount > Decimal('0'): - settlements.append( - SuggestedSettlement( - from_user_id=debtor['user_id'], - from_user_identifier=debtor['user_identifier'], - to_user_id=creditor['user_id'], - to_user_identifier=creditor['user_identifier'], - amount=amount - ) - ) - - # Update balances - debtor['amount'] -= amount - creditor['amount'] -= amount - - # Remove users who have settled their debts/credits - if debtor['amount'] < epsilon: - debtors.pop(0) - if creditor['amount'] < epsilon: - creditors.pop(0) - - return settlements @router.get( "/lists/{list_id}/cost-summary", @@ -116,8 +27,8 @@ def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> L tags=["Costs"], responses={ status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"}, - status.HTTP_404_NOT_FOUND: {"description": "List or associated user not found"} - } + status.HTTP_404_NOT_FOUND: {"description": "List not found"}, + }, ) async def get_list_cost_summary( list_id: int, @@ -125,151 +36,62 @@ async def get_list_cost_summary( current_user: UserModel = Depends(current_active_user), ): """ - Retrieves a calculated cost summary for a specific list, detailing total costs, - equal shares per user, and individual user balances based on their contributions. - - The user must have access to the list to view its cost summary. - Costs are split among group members if the list belongs to a group, or just for - the creator if it's a personal list. All users who added items with prices are - included in the calculation. + Retrieves a calculated cost summary for a specific list. + If an expense has been generated for this list, the summary will be based on that. + Otherwise, it will be a basic summary of item prices. + This endpoint is idempotent and does not create any data. """ logger.info(f"User {current_user.email} requesting cost summary for list {list_id}") - - # 1. Verify user has access to the target list try: - await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + return await costs_service.get_list_cost_summary_logic( + db=db, list_id=list_id, current_user_id=current_user.id + ) except ListPermissionError as e: logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}") - raise + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) except ListNotFoundError as e: - logger.warning(f"List {list_id} not found when checking permissions for cost summary: {str(e)}") - raise + logger.warning(f"List {list_id} not found when getting cost summary: {str(e)}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - # 2. Get the list with its items and users - list_result = await db.execute( - select(ListModel) - .options( - selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)), - selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))), - selectinload(ListModel.creator) + +@router.post( + "/lists/{list_id}/cost-summary", + response_model=ExpensePublic, + status_code=status.HTTP_201_CREATED, + summary="Generate and Get Expense from List Summary", + tags=["Costs"], + responses={ + status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"}, + status.HTTP_404_NOT_FOUND: {"description": "List not found"}, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid operation (e.g., no items to expense, or expense already exists)"}, + }, +) +async def generate_expense_from_list_summary( + list_id: int, + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Creates an ITEM_BASED expense from the items in a given list. + This should be called to finalize the costs for a shopping list and turn it into a formal expense. + It will fail if an expense for this list already exists. + """ + logger.info(f"User {current_user.email} requesting to generate expense from list {list_id}") + try: + expense = await costs_service.generate_expense_from_list_logic( + db=db, list_id=list_id, current_user_id=current_user.id ) - .where(ListModel.id == list_id) - ) - db_list = list_result.scalars().first() - if not db_list: - raise ListNotFoundError(list_id) + return expense + except (ListPermissionError, GroupPermissionError) as e: + logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except (ListNotFoundError, GroupNotFoundError) as e: + logger.warning(f"Resource not found for list {list_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except InvalidOperationError as e: + logger.warning(f"Invalid operation for list {list_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - # 3. Get or create an expense for this list - expense_result = await db.execute( - select(ExpenseModel) - .where(ExpenseModel.list_id == list_id) - .options(selectinload(ExpenseModel.splits)) - ) - db_expense = expense_result.scalars().first() - - if not db_expense: - # Create a new expense for this list - total_amount = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0")) - if total_amount == Decimal("0"): - return ListCostSummary( - list_id=db_list.id, - list_name=db_list.name, - total_list_cost=Decimal("0.00"), - num_participating_users=0, - equal_share_per_user=Decimal("0.00"), - user_balances=[] - ) - - # Create expense with ITEM_BASED split type - expense_in = ExpenseCreate( - description=f"Cost summary for list {db_list.name}", - total_amount=total_amount, - list_id=list_id, - split_type=SplitTypeEnum.ITEM_BASED, - paid_by_user_id=db_list.creator.id - ) - db_expense = await crud_expense.create_expense(db=db, expense_in=expense_in, current_user_id=current_user.id) - - # 4. Calculate cost summary from expense splits - participating_users = set() - user_items_added_value = {} - total_list_cost = Decimal("0.00") - - # Get all users who added items - for item in db_list.items: - if item.price is not None and item.price > Decimal("0") and item.added_by_user: - participating_users.add(item.added_by_user) - user_items_added_value[item.added_by_user.id] = user_items_added_value.get(item.added_by_user.id, Decimal("0.00")) + item.price - total_list_cost += item.price - - # Get all users from expense splits - for split in db_expense.splits: - if split.user: - participating_users.add(split.user) - - num_participating_users = len(participating_users) - if num_participating_users == 0: - return ListCostSummary( - list_id=db_list.id, - list_name=db_list.name, - total_list_cost=Decimal("0.00"), - num_participating_users=0, - equal_share_per_user=Decimal("0.00"), - user_balances=[] - ) - - # This is the ideal equal share, returned in the summary - equal_share_per_user_for_response = (total_list_cost / Decimal(num_participating_users)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - # Sort users for deterministic remainder distribution - sorted_participating_users = sorted(list(participating_users), key=lambda u: u.id) - - user_final_shares = {} - if num_participating_users > 0: - base_share_unrounded = total_list_cost / Decimal(num_participating_users) - - # Calculate initial share for each user, rounding down - for user in sorted_participating_users: - user_final_shares[user.id] = base_share_unrounded.quantize(Decimal("0.01"), rounding=ROUND_DOWN) - - # Calculate sum of rounded down shares - sum_of_rounded_shares = sum(user_final_shares.values()) - - # Calculate remaining pennies to be distributed - remaining_pennies = int(((total_list_cost - sum_of_rounded_shares) * Decimal("100")).to_integral_value(rounding=ROUND_HALF_UP)) - - # Distribute remaining pennies one by one to sorted users - for i in range(remaining_pennies): - user_to_adjust = sorted_participating_users[i % num_participating_users] - user_final_shares[user_to_adjust.id] += Decimal("0.01") - - user_balances = [] - for user in sorted_participating_users: # Iterate over sorted users - items_added = user_items_added_value.get(user.id, Decimal("0.00")) - # current_user_share is now the precisely calculated share for this user - current_user_share = user_final_shares.get(user.id, Decimal("0.00")) - - balance = items_added - current_user_share - user_identifier = user.name if user.name else user.email - user_balances.append( - UserCostShare( - user_id=user.id, - user_identifier=user_identifier, - items_added_value=items_added.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - amount_due=current_user_share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - balance=balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - ) - ) - - user_balances.sort(key=lambda x: x.user_identifier) - return ListCostSummary( - list_id=db_list.id, - list_name=db_list.name, - total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - num_participating_users=num_participating_users, - equal_share_per_user=equal_share_per_user_for_response, # Use the ideal share for the response field - user_balances=user_balances - ) @router.get( "/groups/{group_id}/balance-summary", @@ -278,8 +100,8 @@ async def get_list_cost_summary( tags=["Costs", "Groups"], responses={ status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this group"}, - status.HTTP_404_NOT_FOUND: {"description": "Group not found"} - } + status.HTTP_404_NOT_FOUND: {"description": "Group not found"}, + }, ) async def get_group_balance_summary( group_id: int, @@ -292,132 +114,13 @@ async def get_group_balance_summary( The user must be a member of the group to view its balance summary. """ logger.info(f"User {current_user.email} requesting balance summary for group {group_id}") - - # 1. Verify user is a member of the target group - group_check = await db.execute( - select(GroupModel) - .options(selectinload(GroupModel.member_associations)) - .where(GroupModel.id == group_id) - ) - db_group_for_check = group_check.scalars().first() - - if not db_group_for_check: - raise GroupNotFoundError(group_id) - - user_is_member = any(assoc.user_id == current_user.id for assoc in db_group_for_check.member_associations) - if not user_is_member: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User not a member of group {group_id}") - - # 2. Get all expenses and settlements for the group - expenses_result = await db.execute( - select(ExpenseModel) - .where(ExpenseModel.group_id == group_id) - .options(selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user)) - ) - expenses = expenses_result.scalars().all() - - settlements_result = await db.execute( - select(SettlementModel) - .where(SettlementModel.group_id == group_id) - .options( - selectinload(SettlementModel.paid_by_user), - selectinload(SettlementModel.paid_to_user) + try: + return await costs_service.get_group_balance_summary_logic( + db=db, group_id=group_id, current_user_id=current_user.id ) - ) - settlements = settlements_result.scalars().all() - - # Fetch SettlementActivities related to the group's expenses - # This requires joining SettlementActivity -> ExpenseSplit -> Expense - settlement_activities_result = await db.execute( - select(SettlementActivityModel) - .join(ExpenseSplitModel, SettlementActivityModel.expense_split_id == ExpenseSplitModel.id) - .join(ExpenseModel, ExpenseSplitModel.expense_id == ExpenseModel.id) - .where(ExpenseModel.group_id == group_id) - .options(selectinload(SettlementActivityModel.payer)) # Optional: if you need payer details directly - ) - settlement_activities = settlement_activities_result.scalars().all() - - # 3. Calculate user balances - user_balances_data = {} - # Initialize UserBalanceDetail for each group member - for assoc in db_group_for_check.member_associations: - if assoc.user: - user_balances_data[assoc.user.id] = { - "user_id": assoc.user.id, - "user_identifier": assoc.user.name if assoc.user.name else assoc.user.email, - "total_paid_for_expenses": Decimal("0.00"), - "initial_total_share_of_expenses": Decimal("0.00"), - "total_amount_paid_via_settlement_activities": Decimal("0.00"), - "total_generic_settlements_paid": Decimal("0.00"), - "total_generic_settlements_received": Decimal("0.00"), - } - - # Process Expenses - for expense in expenses: - if expense.paid_by_user_id in user_balances_data: - user_balances_data[expense.paid_by_user_id]["total_paid_for_expenses"] += expense.total_amount - - for split in expense.splits: - if split.user_id in user_balances_data: - user_balances_data[split.user_id]["initial_total_share_of_expenses"] += split.owed_amount - - # Process Settlement Activities (SettlementActivityModel) - for activity in settlement_activities: - if activity.paid_by_user_id in user_balances_data: - user_balances_data[activity.paid_by_user_id]["total_amount_paid_via_settlement_activities"] += activity.amount_paid - - # Process Generic Settlements (SettlementModel) - for settlement in settlements: - if settlement.paid_by_user_id in user_balances_data: - user_balances_data[settlement.paid_by_user_id]["total_generic_settlements_paid"] += settlement.amount - if settlement.paid_to_user_id in user_balances_data: - user_balances_data[settlement.paid_to_user_id]["total_generic_settlements_received"] += settlement.amount - - # Calculate Final Balances - final_user_balances = [] - for user_id, data in user_balances_data.items(): - initial_total_share_of_expenses = data["initial_total_share_of_expenses"] - total_amount_paid_via_settlement_activities = data["total_amount_paid_via_settlement_activities"] - - adjusted_total_share_of_expenses = initial_total_share_of_expenses - total_amount_paid_via_settlement_activities - - total_paid_for_expenses = data["total_paid_for_expenses"] - total_generic_settlements_received = data["total_generic_settlements_received"] - total_generic_settlements_paid = data["total_generic_settlements_paid"] - - net_balance = ( - total_paid_for_expenses + total_generic_settlements_received - ) - (adjusted_total_share_of_expenses + total_generic_settlements_paid) - - # Quantize all final values for UserBalanceDetail schema - user_detail = UserBalanceDetail( - user_id=data["user_id"], - user_identifier=data["user_identifier"], - total_paid_for_expenses=total_paid_for_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - # Store adjusted_total_share_of_expenses in total_share_of_expenses - total_share_of_expenses=adjusted_total_share_of_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - # Store total_generic_settlements_paid in total_settlements_paid - total_settlements_paid=total_generic_settlements_paid.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - total_settlements_received=total_generic_settlements_received.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - net_balance=net_balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - ) - final_user_balances.append(user_detail) - - # Sort by user identifier - final_user_balances.sort(key=lambda x: x.user_identifier) - - # Calculate suggested settlements - suggested_settlements = calculate_suggested_settlements(final_user_balances) - - # Calculate overall totals for the group - overall_total_expenses = sum(expense.total_amount for expense in expenses) - overall_total_settlements = sum(settlement.amount for settlement in settlements) - - return GroupBalanceSummary( - group_id=db_group_for_check.id, - group_name=db_group_for_check.name, - overall_total_expenses=overall_total_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - overall_total_settlements=overall_total_settlements.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), - user_balances=final_user_balances, - suggested_settlements=suggested_settlements - ) \ No newline at end of file + except GroupPermissionError as e: + logger.warning(f"Permission denied for user {current_user.email} on group {group_id}: {str(e)}") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + except GroupNotFoundError as e: + logger.warning(f"Group {group_id} not found when getting balance summary: {str(e)}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/financials.py b/be/app/api/v1/endpoints/financials.py index 0013e41..2acac2f 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import joinedload -from typing import List as PyList, Optional, Sequence +from typing import List as PyList, Optional, Sequence, Union from app.database import get_transactional_session from app.auth import current_active_user @@ -14,13 +14,16 @@ from app.models import ( List as ListModel, UserGroup as UserGroupModel, UserRoleEnum, - ExpenseSplit as ExpenseSplitModel + ExpenseSplit as ExpenseSplitModel, + Expense as ExpenseModel, + Settlement as SettlementModel ) from app.schemas.expense import ( ExpenseCreate, ExpensePublic, SettlementCreate, SettlementPublic, ExpenseUpdate, SettlementUpdate ) +from app.schemas.financials import FinancialActivityResponse from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added from app.crud import expense as crud_expense from app.crud import settlement as crud_settlement @@ -32,6 +35,7 @@ from app.core.exceptions import ( InvalidOperationError, GroupPermissionError, ListPermissionError, ItemNotFoundError, GroupMembershipError ) +from app.services import financials_service logger = logging.getLogger(__name__) router = APIRouter() @@ -655,4 +659,21 @@ async def delete_settlement_record( logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") - return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status_code=status.HTTP_204_NO_CONTENT) + +@router.get("/users/me/financial-activity", response_model=FinancialActivityResponse, summary="Get User's Financial Activity", tags=["Users", "Expenses", "Settlements"]) +async def get_user_financial_activity( + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Retrieves a consolidated and chronologically sorted list of all financial activities + for the current user, including expenses they are part of and settlements they have + made or received. + """ + logger.info(f"User {current_user.email} requesting their financial activity feed.") + activities = await financials_service.get_user_financial_activity(db=db, user_id=current_user.id) + + # The service returns a mix of ExpenseModel and SettlementModel objects. + # We need to wrap it in our response schema. Pydantic will handle the Union type. + return FinancialActivityResponse(activities=activities) \ No newline at end of file diff --git a/be/app/core/exceptions.py b/be/app/core/exceptions.py index 0b5f936..fb12a62 100644 --- a/be/app/core/exceptions.py +++ b/be/app/core/exceptions.py @@ -258,14 +258,22 @@ class InviteOperationError(HTTPException): class SettlementOperationError(HTTPException): """Raised when a settlement operation fails.""" - def __init__(self, detail: str): + def __init__(self, detail: str = "An error occurred during a settlement operation."): super().__init__( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail ) +class FinancialConflictError(HTTPException): + """Raised when a financial conflict occurs.""" + def __init__(self, detail: str): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + detail=detail + ) + class ConflictError(HTTPException): - """Raised when an optimistic lock version conflict occurs.""" + """Raised when a conflict occurs.""" def __init__(self, detail: str): super().__init__( status_code=status.HTTP_409_CONFLICT, diff --git a/be/app/crud/audit.py b/be/app/crud/audit.py index 5165722..bc842b5 100644 --- a/be/app/crud/audit.py +++ b/be/app/crud/audit.py @@ -22,8 +22,7 @@ async def create_financial_audit_log( ) log_entry = FinancialAuditLog(**log_entry_data.dict()) db.add(log_entry) - await db.commit() - await db.refresh(log_entry) + await db.flush() return log_entry async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]: diff --git a/be/app/crud/settlement_activity.py b/be/app/crud/settlement_activity.py index 7c51bc2..bf55f7a 100644 --- a/be/app/crud/settlement_activity.py +++ b/be/app/crud/settlement_activity.py @@ -16,6 +16,8 @@ from app.models import ( ) from pydantic import BaseModel from app.crud.audit import create_financial_audit_log +from app.schemas.settlement_activity import SettlementActivityCreate +from app.core.exceptions import UserNotFoundError, InvalidOperationError, FinancialConflictError class SettlementActivityCreatePlaceholder(BaseModel): @@ -114,21 +116,34 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op async def create_settlement_activity( db: AsyncSession, - settlement_activity_in: SettlementActivityCreatePlaceholder, + settlement_activity_in: SettlementActivityCreate, current_user_id: int -) -> Optional[SettlementActivity]: +) -> SettlementActivity: """ Creates a new settlement activity, then updates the parent expense split and expense statuses. + Uses pessimistic locking on the ExpenseSplit row to prevent race conditions. + Relies on the calling context (e.g., transactional session dependency) for the transaction. """ - split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id)) + # Lock the expense split row for the duration of the transaction + split_stmt = ( + select(ExpenseSplit) + .where(ExpenseSplit.id == settlement_activity_in.expense_split_id) + .with_for_update() + ) + split_result = await db.execute(split_stmt) expense_split = split_result.scalar_one_or_none() + if not expense_split: - return None + raise InvalidOperationError(f"Expense split with ID {settlement_activity_in.expense_split_id} not found.") + # Check if the split is already fully paid + if expense_split.status == ExpenseSplitStatusEnum.paid: + raise FinancialConflictError(f"Expense split {expense_split.id} is already fully paid.") + + # Validate that the user paying exists user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id)) - paid_by_user = user_result.scalar_one_or_none() - if not paid_by_user: - return None # User not found + if not user_result.scalar_one_or_none(): + raise UserNotFoundError(user_id=settlement_activity_in.paid_by_user_id) db_settlement_activity = SettlementActivity( expense_split_id=settlement_activity_in.expense_split_id, @@ -148,14 +163,28 @@ async def create_settlement_activity( entity=db_settlement_activity, ) - # Update statuses + # Update statuses within the same transaction updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id) if updated_split and updated_split.expense_id: await update_expense_overall_status(db, expense_id=updated_split.expense_id) - else: - pass - return db_settlement_activity + # Re-fetch the object with all relationships loaded to prevent lazy-loading issues during serialization + stmt = ( + select(SettlementActivity) + .where(SettlementActivity.id == db_settlement_activity.id) + .options( + selectinload(SettlementActivity.payer), + selectinload(SettlementActivity.creator) + ) + ) + result = await db.execute(stmt) + loaded_activity = result.scalar_one_or_none() + + if not loaded_activity: + # This should not happen in a normal flow + raise InvalidOperationError("Failed to load settlement activity after creation.") + + return loaded_activity async def get_settlement_activity_by_id( diff --git a/be/app/jobs/recurring_expenses.py b/be/app/jobs/recurring_expenses.py index c0328a9..4e47652 100644 --- a/be/app/jobs/recurring_expenses.py +++ b/be/app/jobs/recurring_expenses.py @@ -1,9 +1,10 @@ from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ -from app.models import Expense, RecurrencePattern +from sqlalchemy.orm import selectinload +from app.models import Expense, RecurrencePattern, SplitTypeEnum from app.crud.expense import create_expense -from app.schemas.expense import ExpenseCreate +from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate import logging from typing import Optional @@ -29,6 +30,8 @@ async def generate_recurring_expenses(db: AsyncSession) -> None: (RecurrencePattern.end_date > now) ) ) + ).options( + selectinload(Expense.splits) # Eager load splits to use as a template ) result = await db.execute(query) @@ -49,11 +52,31 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None: """Generate the next occurrence of a recurring expense.""" pattern = expense.recurrence_pattern if not pattern: + logger.warning(f"Recurring expense {expense.id} is missing its recurrence pattern.") return next_date = _calculate_next_occurrence(expense.next_occurrence, pattern) if not next_date: + logger.info(f"No next occurrence date for expense {expense.id}, stopping recurrence.") + expense.is_recurring = False # Stop future processing + await db.flush() return + + # Recreate splits from the template expense if needed + splits_data = None + if expense.split_type not in [SplitTypeEnum.EQUAL, SplitTypeEnum.ITEM_BASED]: + if not expense.splits: + logger.error(f"Cannot generate next occurrence for expense {expense.id} with split type {expense.split_type.value} because it has no splits to use as a template.") + return + + splits_data = [ + ExpenseSplitCreate( + user_id=split.user_id, + owed_amount=split.owed_amount, + share_percentage=split.share_percentage, + share_units=split.share_units, + ) for split in expense.splits + ] new_expense = ExpenseCreate( description=expense.description, @@ -65,18 +88,28 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None: group_id=expense.group_id, item_id=expense.item_id, paid_by_user_id=expense.paid_by_user_id, - is_recurring=False, - splits_in=None + is_recurring=False, # The new expense is a single occurrence, not a recurring template + splits_in=splits_data ) + # We pass the original creator's ID created_expense = await create_expense(db, new_expense, expense.created_by_user_id) + logger.info(f"Generated new expense {created_expense.id} from recurring expense {expense.id}.") + # Update the template expense for the next run expense.last_occurrence = next_date - expense.next_occurrence = _calculate_next_occurrence(next_date, pattern) + next_next_date = _calculate_next_occurrence(next_date, pattern) - if pattern.max_occurrences: + # Decrement occurrence count if it exists + if pattern.max_occurrences is not None: pattern.max_occurrences -= 1 - + if pattern.max_occurrences <= 0: + next_next_date = None # Stop recurrence + + expense.next_occurrence = next_next_date + if not expense.next_occurrence: + expense.is_recurring = False # End the recurrence + await db.flush() def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]: @@ -84,27 +117,46 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter if not current_date: return None + next_date = None if pattern.type == 'daily': - return current_date + timedelta(days=pattern.interval) + next_date = current_date + timedelta(days=pattern.interval) elif pattern.type == 'weekly': if not pattern.days_of_week: - return current_date + timedelta(weeks=pattern.interval) - - current_weekday = current_date.weekday() - next_weekday = min((d for d in pattern.days_of_week if d > current_weekday), - default=min(pattern.days_of_week)) - days_ahead = next_weekday - current_weekday - if days_ahead <= 0: - days_ahead += 7 - return current_date + timedelta(days=days_ahead) - + next_date = current_date + timedelta(weeks=pattern.interval) + else: + current_weekday = current_date.weekday() + # Find the next valid weekday + next_days = sorted([d for d in pattern.days_of_week if d > current_weekday]) + if next_days: + # Next occurrence is in the same week + days_ahead = next_days[0] - current_weekday + next_date = current_date + timedelta(days=days_ahead) + else: + # Next occurrence is in the following week(s) + days_ahead = (7 - current_weekday) + min(pattern.days_of_week) + next_date = current_date + timedelta(days=days_ahead) + if pattern.interval > 1: + next_date += timedelta(weeks=pattern.interval - 1) + elif pattern.type == 'monthly': year = current_date.year + (current_date.month + pattern.interval - 1) // 12 month = (current_date.month + pattern.interval - 1) % 12 + 1 - return current_date.replace(year=year, month=month) - + # Handle cases where the day is invalid for the new month (e.g., 31st) + try: + next_date = current_date.replace(year=year, month=month) + except ValueError: + # Go to the last day of the new month + next_date = (current_date.replace(year=year, month=month, day=1) + timedelta(days=31)).replace(day=1) - timedelta(days=1) + elif pattern.type == 'yearly': - return current_date.replace(year=current_date.year + pattern.interval) + try: + next_date = current_date.replace(year=current_date.year + pattern.interval) + except ValueError: # Leap year case (Feb 29) + next_date = current_date.replace(year=current_date.year + pattern.interval, day=28) + + # Check against end_date + if pattern.end_date and next_date and next_date > pattern.end_date: + return None - return None \ No newline at end of file + return next_date \ No newline at end of file diff --git a/be/app/schemas/expense.py b/be/app/schemas/expense.py index 7b26f47..4383f16 100644 --- a/be/app/schemas/expense.py +++ b/be/app/schemas/expense.py @@ -5,12 +5,14 @@ from datetime import datetime from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum from app.schemas.user import UserPublic from app.schemas.settlement_activity import SettlementActivityPublic +from app.schemas.recurrence import RecurrencePatternCreate, RecurrencePatternPublic class ExpenseSplitBase(BaseModel): user_id: int - owed_amount: Decimal + owed_amount: Optional[Decimal] = None share_percentage: Optional[Decimal] = None share_units: Optional[int] = None + # Note: Status is handled by the backend, not in create/update payloads class ExpenseSplitCreate(ExpenseSplitBase): pass @@ -18,10 +20,10 @@ class ExpenseSplitCreate(ExpenseSplitBase): class ExpenseSplitPublic(ExpenseSplitBase): id: int expense_id: int + status: ExpenseSplitStatusEnum user: Optional[UserPublic] = None created_at: datetime updated_at: datetime - status: ExpenseSplitStatusEnum paid_at: Optional[datetime] = None settlement_activities: List[SettlementActivityPublic] = [] model_config = ConfigDict(from_attributes=True) diff --git a/be/app/schemas/financials.py b/be/app/schemas/financials.py new file mode 100644 index 0000000..3d8cb40 --- /dev/null +++ b/be/app/schemas/financials.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel +from typing import Union, List +from .expense import ExpensePublic, SettlementPublic + +class FinancialActivityResponse(BaseModel): + activities: List[Union[ExpensePublic, SettlementPublic]] + + class Config: + orm_mode = True \ No newline at end of file diff --git a/be/app/schemas/recurrence.py b/be/app/schemas/recurrence.py new file mode 100644 index 0000000..e1b65dc --- /dev/null +++ b/be/app/schemas/recurrence.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, validator +from typing import Optional, List +from datetime import datetime + +class RecurrencePatternBase(BaseModel): + type: str + interval: int = 1 + days_of_week: Optional[List[int]] = None + end_date: Optional[datetime] = None + max_occurrences: Optional[int] = None + + @validator('type') + def type_must_be_valid(cls, v): + if v not in ['daily', 'weekly', 'monthly', 'yearly']: + raise ValueError("type must be one of 'daily', 'weekly', 'monthly', 'yearly'") + return v + + @validator('days_of_week') + def days_of_week_must_be_valid(cls, v): + if v: + for day in v: + if not 0 <= day <= 6: + raise ValueError("days_of_week must be between 0 and 6") + return v + +class RecurrencePatternCreate(RecurrencePatternBase): + pass + +class RecurrencePatternPublic(RecurrencePatternBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True \ No newline at end of file diff --git a/be/app/services/costs_service.py b/be/app/services/costs_service.py new file mode 100644 index 0000000..df74adf --- /dev/null +++ b/be/app/services/costs_service.py @@ -0,0 +1,343 @@ +# be/app/services/costs_service.py +import logging +from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN +from typing import List + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import ( + User as UserModel, + Group as GroupModel, + List as ListModel, + Expense as ExpenseModel, + Item as ItemModel, + UserGroup as UserGroupModel, + SplitTypeEnum, + ExpenseSplit as ExpenseSplitModel, + SettlementActivity as SettlementActivityModel, + Settlement as SettlementModel +) +from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement +from app.schemas.expense import ExpenseCreate, ExpensePublic +from app.crud import list as crud_list +from app.crud import expense as crud_expense +from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError, GroupPermissionError, InvalidOperationError + +logger = logging.getLogger(__name__) + + +def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> List[SuggestedSettlement]: + """ + Calculate suggested settlements to balance the finances within a group. + + This function takes the current balances of all users and suggests optimal settlements + to minimize the number of transactions needed to settle all debts. + + Args: + user_balances: List of UserBalanceDetail objects with their current balances + + Returns: + List of SuggestedSettlement objects representing the suggested payments + """ + debtors = [] + creditors = [] + epsilon = Decimal('0.01') + + for user in user_balances: + if abs(user.net_balance) < epsilon: + continue + + if user.net_balance < Decimal('0'): + debtors.append({ + 'user_id': user.user_id, + 'user_identifier': user.user_identifier, + 'amount': -user.net_balance + }) + else: + creditors.append({ + 'user_id': user.user_id, + 'user_identifier': user.user_identifier, + 'amount': user.net_balance + }) + + debtors.sort(key=lambda x: x['amount'], reverse=True) + creditors.sort(key=lambda x: x['amount'], reverse=True) + + settlements = [] + + while debtors and creditors: + debtor = debtors[0] + creditor = creditors[0] + + amount = min(debtor['amount'], creditor['amount']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + if amount > Decimal('0'): + settlements.append( + SuggestedSettlement( + from_user_id=debtor['user_id'], + from_user_identifier=debtor['user_identifier'], + to_user_id=creditor['user_id'], + to_user_identifier=creditor['user_identifier'], + amount=amount + ) + ) + + debtor['amount'] -= amount + creditor['amount'] -= amount + + if debtor['amount'] < epsilon: + debtors.pop(0) + if creditor['amount'] < epsilon: + creditors.pop(0) + + return settlements + + +async def get_list_cost_summary_logic( + db: AsyncSession, list_id: int, current_user_id: int +) -> ListCostSummary: + """ + Core logic to retrieve a calculated cost summary for a specific list. + This version does NOT create an expense if one is not found. + """ + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user_id) + + list_result = await db.execute( + select(ListModel) + .options( + selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)), + selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))), + selectinload(ListModel.creator) + ) + .where(ListModel.id == list_id) + ) + db_list = list_result.scalars().first() + if not db_list: + raise ListNotFoundError(list_id) + + expense_result = await db.execute( + select(ExpenseModel) + .where(ExpenseModel.list_id == list_id) + .options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user))) + ) + db_expense = expense_result.scalars().first() + + total_list_cost = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0")) + + # If no expense exists or no items with cost, return a summary based on item prices alone. + if not db_expense: + return ListCostSummary( + list_id=db_list.id, + list_name=db_list.name, + total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + num_participating_users=0, + equal_share_per_user=Decimal("0.00"), + user_balances=[] + ) + + # --- Calculation logic based on existing expense --- + participating_users = set() + user_items_added_value = {} + + for item in db_list.items: + if item.price is not None and item.price > Decimal("0") and item.added_by_user: + participating_users.add(item.added_by_user) + user_items_added_value[item.added_by_user.id] = user_items_added_value.get(item.added_by_user.id, Decimal("0.00")) + item.price + + for split in db_expense.splits: + if split.user: + participating_users.add(split.user) + + num_participating_users = len(participating_users) + if num_participating_users == 0: + return ListCostSummary( + list_id=db_list.id, + list_name=db_list.name, + total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + num_participating_users=0, + equal_share_per_user=Decimal("0.00"), + user_balances=[] + ) + + equal_share_per_user_for_response = (db_expense.total_amount / Decimal(num_participating_users)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + sorted_participating_users = sorted(list(participating_users), key=lambda u: u.id) + user_final_shares = {} + + if num_participating_users > 0: + base_share_unrounded = db_expense.total_amount / Decimal(num_participating_users) + for user in sorted_participating_users: + user_final_shares[user.id] = base_share_unrounded.quantize(Decimal("0.01"), rounding=ROUND_DOWN) + + sum_of_rounded_shares = sum(user_final_shares.values()) + remaining_pennies = int(((db_expense.total_amount - sum_of_rounded_shares) * Decimal("100")).to_integral_value(rounding=ROUND_HALF_UP)) + + for i in range(remaining_pennies): + user_to_adjust = sorted_participating_users[i % num_participating_users] + user_final_shares[user_to_adjust.id] += Decimal("0.01") + + user_balances = [] + for user in sorted_participating_users: + items_added = user_items_added_value.get(user.id, Decimal("0.00")) + current_user_share = user_final_shares.get(user.id, Decimal("0.00")) + balance = items_added - current_user_share + user_identifier = user.name if user.name else user.email + user_balances.append( + UserCostShare( + user_id=user.id, + user_identifier=user_identifier, + items_added_value=items_added.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + amount_due=current_user_share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + balance=balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + ) + + user_balances.sort(key=lambda x: x.user_identifier) + return ListCostSummary( + list_id=db_list.id, + list_name=db_list.name, + total_list_cost=db_expense.total_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + num_participating_users=num_participating_users, + equal_share_per_user=equal_share_per_user_for_response, + user_balances=user_balances + ) + + +async def generate_expense_from_list_logic(db: AsyncSession, list_id: int, current_user_id: int) -> ExpenseModel: + """ + Generates and saves an ITEM_BASED expense from a list's items. + """ + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user_id) + + # Check if an expense already exists for this list + existing_expense_result = await db.execute( + select(ExpenseModel).where(ExpenseModel.list_id == list_id) + ) + if existing_expense_result.scalars().first(): + raise InvalidOperationError(f"An expense already exists for list {list_id}.") + + db_list = await db.get(ListModel, list_id, options=[selectinload(ListModel.items), selectinload(ListModel.creator)]) + if not db_list: + raise ListNotFoundError(list_id) + + total_amount = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0")) + if total_amount <= Decimal("0"): + raise InvalidOperationError("Cannot create an expense for a list with no priced items.") + + expense_in = ExpenseCreate( + description=f"Cost summary for list {db_list.name}", + total_amount=total_amount, + list_id=list_id, + split_type=SplitTypeEnum.ITEM_BASED, + paid_by_user_id=db_list.creator.id + ) + return await crud_expense.create_expense(db=db, expense_in=expense_in, current_user_id=current_user_id) + + +async def get_group_balance_summary_logic( + db: AsyncSession, group_id: int, current_user_id: int +) -> GroupBalanceSummary: + """ + Core logic to retrieve a detailed financial balance summary for a group. + """ + group_check_result = await db.execute( + select(GroupModel).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))) + .where(GroupModel.id == group_id) + ) + db_group = group_check_result.scalars().first() + + if not db_group: + raise GroupNotFoundError(group_id) + + if not any(assoc.user_id == current_user_id for assoc in db_group.member_associations): + raise GroupPermissionError(group_id, "view balance summary for") + + expenses_result = await db.execute( + select(ExpenseModel).where(ExpenseModel.group_id == group_id) + .options(selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user)) + ) + expenses = expenses_result.scalars().all() + + settlements_result = await db.execute( + select(SettlementModel).where(SettlementModel.group_id == group_id) + .options(selectinload(SettlementModel.paid_by_user), selectinload(SettlementModel.paid_to_user)) + ) + settlements = settlements_result.scalars().all() + + settlement_activities_result = await db.execute( + select(SettlementActivityModel) + .join(ExpenseSplitModel, SettlementActivityModel.expense_split_id == ExpenseSplitModel.id) + .join(ExpenseModel, ExpenseSplitModel.expense_id == ExpenseModel.id) + .where(ExpenseModel.group_id == group_id) + .options(selectinload(SettlementActivityModel.payer)) + ) + settlement_activities = settlement_activities_result.scalars().all() + + user_balances_data = {} + for assoc in db_group.member_associations: + if assoc.user: + user_balances_data[assoc.user.id] = { + "user_id": assoc.user.id, + "user_identifier": assoc.user.name if assoc.user.name else assoc.user.email, + "total_paid_for_expenses": Decimal("0.00"), + "initial_total_share_of_expenses": Decimal("0.00"), + "total_amount_paid_via_settlement_activities": Decimal("0.00"), + "total_generic_settlements_paid": Decimal("0.00"), + "total_generic_settlements_received": Decimal("0.00"), + } + + for expense in expenses: + if expense.paid_by_user_id in user_balances_data: + user_balances_data[expense.paid_by_user_id]["total_paid_for_expenses"] += expense.total_amount + for split in expense.splits: + if split.user_id in user_balances_data: + user_balances_data[split.user_id]["initial_total_share_of_expenses"] += split.owed_amount + + for activity in settlement_activities: + if activity.paid_by_user_id in user_balances_data: + user_balances_data[activity.paid_by_user_id]["total_amount_paid_via_settlement_activities"] += activity.amount_paid + + for settlement in settlements: + if settlement.paid_by_user_id in user_balances_data: + user_balances_data[settlement.paid_by_user_id]["total_generic_settlements_paid"] += settlement.amount + if settlement.paid_to_user_id in user_balances_data: + user_balances_data[settlement.paid_to_user_id]["total_generic_settlements_received"] += settlement.amount + + final_user_balances = [] + for user_id, data in user_balances_data.items(): + initial_total_share_of_expenses = data["initial_total_share_of_expenses"] + total_amount_paid_via_settlement_activities = data["total_amount_paid_via_settlement_activities"] + adjusted_total_share_of_expenses = initial_total_share_of_expenses - total_amount_paid_via_settlement_activities + total_paid_for_expenses = data["total_paid_for_expenses"] + total_generic_settlements_received = data["total_generic_settlements_received"] + total_generic_settlements_paid = data["total_generic_settlements_paid"] + net_balance = ( + total_paid_for_expenses + total_generic_settlements_received + ) - (adjusted_total_share_of_expenses + total_generic_settlements_paid) + + user_detail = UserBalanceDetail( + user_id=data["user_id"], + user_identifier=data["user_identifier"], + total_paid_for_expenses=total_paid_for_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + total_share_of_expenses=adjusted_total_share_of_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + total_settlements_paid=total_generic_settlements_paid.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + total_settlements_received=total_generic_settlements_received.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + net_balance=net_balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ) + final_user_balances.append(user_detail) + + final_user_balances.sort(key=lambda x: x.user_identifier) + suggested_settlements = calculate_suggested_settlements(final_user_balances) + overall_total_expenses = sum(expense.total_amount for expense in expenses) + overall_total_settlements = sum(settlement.amount for settlement in settlements) + + return GroupBalanceSummary( + group_id=db_group.id, + group_name=db_group.name, + overall_total_expenses=overall_total_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + overall_total_settlements=overall_total_settlements.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), + user_balances=final_user_balances, + suggested_settlements=suggested_settlements + ) \ No newline at end of file diff --git a/be/app/services/financials_service.py b/be/app/services/financials_service.py new file mode 100644 index 0000000..3c089f9 --- /dev/null +++ b/be/app/services/financials_service.py @@ -0,0 +1,31 @@ +import logging +from typing import List, Union +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from app.models import Expense as ExpenseModel, Settlement as SettlementModel +from app.crud import expense as crud_expense, settlement as crud_settlement + +logger = logging.getLogger(__name__) + +async def get_user_financial_activity( + db: AsyncSession, user_id: int +) -> List[Union[ExpenseModel, SettlementModel]]: + """ + Retrieves and merges all financial activities (expenses and settlements) for a user. + The combined list is sorted by date. + """ + # Fetch all accessible expenses + expenses = await crud_expense.get_user_accessible_expenses(db, user_id=user_id, limit=200) # Using a generous limit + + # Fetch all settlements involving the user + settlements = await crud_settlement.get_settlements_involving_user(db, user_id=user_id, limit=200) # Using a generous limit + + # Combine and sort the activities + # We use a lambda to get the primary date for sorting from either type of object + combined_activity = sorted( + expenses + settlements, + key=lambda x: x.expense_date if isinstance(x, ExpenseModel) else x.settlement_date, + reverse=True + ) + + return combined_activity \ No newline at end of file diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index 1051cbe..feaddd8 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -32,11 +32,11 @@ @touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
{{ list.name }} -
+
{{ list.description || t('listsPage.noDescription') }}