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.
This commit is contained in:
Mohamad 2025-06-21 00:53:03 +02:00
parent 448a0705d2
commit 29e42b8d80
13 changed files with 653 additions and 417 deletions

View File

@ -1,113 +1,24 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import ( from app.models import User as UserModel, Expense as ExpenseModel
User as UserModel, from app.schemas.cost import ListCostSummary, GroupBalanceSummary
Group as GroupModel, from app.schemas.expense import ExpensePublic
List as ListModel, from app.services import costs_service
Expense as ExpenseModel, from app.core.exceptions import (
Item as ItemModel, ListNotFoundError,
UserGroup as UserGroupModel, ListPermissionError,
SplitTypeEnum, GroupNotFoundError,
ExpenseSplit as ExpenseSplitModel, GroupPermissionError,
SettlementActivity as SettlementActivityModel, InvalidOperationError
Settlement as SettlementModel
) )
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__) logger = logging.getLogger(__name__)
router = APIRouter() 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( @router.get(
"/lists/{list_id}/cost-summary", "/lists/{list_id}/cost-summary",
@ -116,8 +27,8 @@ def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> L
tags=["Costs"], tags=["Costs"],
responses={ responses={
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"}, 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( async def get_list_cost_summary(
list_id: int, list_id: int,
@ -125,151 +36,62 @@ async def get_list_cost_summary(
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
""" """
Retrieves a calculated cost summary for a specific list, detailing total costs, Retrieves a calculated cost summary for a specific list.
equal shares per user, and individual user balances based on their contributions. 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.
The user must have access to the list to view its cost summary. This endpoint is idempotent and does not create any data.
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.
""" """
logger.info(f"User {current_user.email} requesting cost summary for list {list_id}") logger.info(f"User {current_user.email} requesting cost summary for list {list_id}")
# 1. Verify user has access to the target list
try: 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: except ListPermissionError as e:
logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(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: except ListNotFoundError as e:
logger.warning(f"List {list_id} not found when checking permissions for cost summary: {str(e)}") logger.warning(f"List {list_id} not found when getting cost summary: {str(e)}")
raise 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( @router.post(
select(ListModel) "/lists/{list_id}/cost-summary",
.options( response_model=ExpensePublic,
selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)), status_code=status.HTTP_201_CREATED,
selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))), summary="Generate and Get Expense from List Summary",
selectinload(ListModel.creator) 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) return expense
) except (ListPermissionError, GroupPermissionError) as e:
db_list = list_result.scalars().first() logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}")
if not db_list: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
raise ListNotFoundError(list_id) 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( @router.get(
"/groups/{group_id}/balance-summary", "/groups/{group_id}/balance-summary",
@ -278,8 +100,8 @@ async def get_list_cost_summary(
tags=["Costs", "Groups"], tags=["Costs", "Groups"],
responses={ responses={
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this group"}, 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( async def get_group_balance_summary(
group_id: int, 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. 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}") logger.info(f"User {current_user.email} requesting balance summary for group {group_id}")
try:
# 1. Verify user is a member of the target group return await costs_service.get_group_balance_summary_logic(
group_check = await db.execute( db=db, group_id=group_id, current_user_id=current_user.id
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)
) )
) except GroupPermissionError as e:
settlements = settlements_result.scalars().all() 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))
# Fetch SettlementActivities related to the group's expenses except GroupNotFoundError as e:
# This requires joining SettlementActivity -> ExpenseSplit -> Expense logger.warning(f"Group {group_id} not found when getting balance summary: {str(e)}")
settlement_activities_result = await db.execute( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
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
)

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload 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.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
@ -14,13 +14,16 @@ from app.models import (
List as ListModel, List as ListModel,
UserGroup as UserGroupModel, UserGroup as UserGroupModel,
UserRoleEnum, UserRoleEnum,
ExpenseSplit as ExpenseSplitModel ExpenseSplit as ExpenseSplitModel,
Expense as ExpenseModel,
Settlement as SettlementModel
) )
from app.schemas.expense import ( from app.schemas.expense import (
ExpenseCreate, ExpensePublic, ExpenseCreate, ExpensePublic,
SettlementCreate, SettlementPublic, SettlementCreate, SettlementPublic,
ExpenseUpdate, SettlementUpdate ExpenseUpdate, SettlementUpdate
) )
from app.schemas.financials import FinancialActivityResponse
from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added
from app.crud import expense as crud_expense from app.crud import expense as crud_expense
from app.crud import settlement as crud_settlement from app.crud import settlement as crud_settlement
@ -32,6 +35,7 @@ from app.core.exceptions import (
InvalidOperationError, GroupPermissionError, ListPermissionError, InvalidOperationError, GroupPermissionError, ListPermissionError,
ItemNotFoundError, GroupMembershipError ItemNotFoundError, GroupMembershipError
) )
from app.services import financials_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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) 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.") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
return Response(status_code=status.HTTP_204_NO_CONTENT) 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)

View File

@ -258,14 +258,22 @@ class InviteOperationError(HTTPException):
class SettlementOperationError(HTTPException): class SettlementOperationError(HTTPException):
"""Raised when a settlement operation fails.""" """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__( super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail 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): class ConflictError(HTTPException):
"""Raised when an optimistic lock version conflict occurs.""" """Raised when a conflict occurs."""
def __init__(self, detail: str): def __init__(self, detail: str):
super().__init__( super().__init__(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,

View File

@ -22,8 +22,7 @@ async def create_financial_audit_log(
) )
log_entry = FinancialAuditLog(**log_entry_data.dict()) log_entry = FinancialAuditLog(**log_entry_data.dict())
db.add(log_entry) db.add(log_entry)
await db.commit() await db.flush()
await db.refresh(log_entry)
return log_entry return log_entry
async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]: async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:

View File

@ -16,6 +16,8 @@ from app.models import (
) )
from pydantic import BaseModel from pydantic import BaseModel
from app.crud.audit import create_financial_audit_log 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): class SettlementActivityCreatePlaceholder(BaseModel):
@ -114,21 +116,34 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
async def create_settlement_activity( async def create_settlement_activity(
db: AsyncSession, db: AsyncSession,
settlement_activity_in: SettlementActivityCreatePlaceholder, settlement_activity_in: SettlementActivityCreate,
current_user_id: int current_user_id: int
) -> Optional[SettlementActivity]: ) -> SettlementActivity:
""" """
Creates a new settlement activity, then updates the parent expense split and expense statuses. 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() expense_split = split_result.scalar_one_or_none()
if not expense_split: 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)) 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 user_result.scalar_one_or_none():
if not paid_by_user: raise UserNotFoundError(user_id=settlement_activity_in.paid_by_user_id)
return None # User not found
db_settlement_activity = SettlementActivity( db_settlement_activity = SettlementActivity(
expense_split_id=settlement_activity_in.expense_split_id, expense_split_id=settlement_activity_in.expense_split_id,
@ -148,14 +163,28 @@ async def create_settlement_activity(
entity=db_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) 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: if updated_split and updated_split.expense_id:
await update_expense_overall_status(db, expense_id=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( async def get_settlement_activity_by_id(

View File

@ -1,9 +1,10 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ 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.crud.expense import create_expense
from app.schemas.expense import ExpenseCreate from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate
import logging import logging
from typing import Optional from typing import Optional
@ -29,6 +30,8 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
(RecurrencePattern.end_date > now) (RecurrencePattern.end_date > now)
) )
) )
).options(
selectinload(Expense.splits) # Eager load splits to use as a template
) )
result = await db.execute(query) 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.""" """Generate the next occurrence of a recurring expense."""
pattern = expense.recurrence_pattern pattern = expense.recurrence_pattern
if not pattern: if not pattern:
logger.warning(f"Recurring expense {expense.id} is missing its recurrence pattern.")
return return
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern) next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
if not next_date: 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 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( new_expense = ExpenseCreate(
description=expense.description, description=expense.description,
@ -65,18 +88,28 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
group_id=expense.group_id, group_id=expense.group_id,
item_id=expense.item_id, item_id=expense.item_id,
paid_by_user_id=expense.paid_by_user_id, paid_by_user_id=expense.paid_by_user_id,
is_recurring=False, is_recurring=False, # The new expense is a single occurrence, not a recurring template
splits_in=None splits_in=splits_data
) )
# We pass the original creator's ID
created_expense = await create_expense(db, new_expense, expense.created_by_user_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.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 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() await db.flush()
def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]: 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: if not current_date:
return None return None
next_date = None
if pattern.type == 'daily': if pattern.type == 'daily':
return current_date + timedelta(days=pattern.interval) next_date = current_date + timedelta(days=pattern.interval)
elif pattern.type == 'weekly': elif pattern.type == 'weekly':
if not pattern.days_of_week: if not pattern.days_of_week:
return current_date + timedelta(weeks=pattern.interval) next_date = current_date + timedelta(weeks=pattern.interval)
else:
current_weekday = current_date.weekday() current_weekday = current_date.weekday()
next_weekday = min((d for d in pattern.days_of_week if d > current_weekday), # Find the next valid weekday
default=min(pattern.days_of_week)) next_days = sorted([d for d in pattern.days_of_week if d > current_weekday])
days_ahead = next_weekday - current_weekday if next_days:
if days_ahead <= 0: # Next occurrence is in the same week
days_ahead += 7 days_ahead = next_days[0] - current_weekday
return current_date + timedelta(days=days_ahead) 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': elif pattern.type == 'monthly':
year = current_date.year + (current_date.month + pattern.interval - 1) // 12 year = current_date.year + (current_date.month + pattern.interval - 1) // 12
month = (current_date.month + pattern.interval - 1) % 12 + 1 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': 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 return next_date

View File

@ -5,12 +5,14 @@ from datetime import datetime
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.schemas.settlement_activity import SettlementActivityPublic from app.schemas.settlement_activity import SettlementActivityPublic
from app.schemas.recurrence import RecurrencePatternCreate, RecurrencePatternPublic
class ExpenseSplitBase(BaseModel): class ExpenseSplitBase(BaseModel):
user_id: int user_id: int
owed_amount: Decimal owed_amount: Optional[Decimal] = None
share_percentage: Optional[Decimal] = None share_percentage: Optional[Decimal] = None
share_units: Optional[int] = None share_units: Optional[int] = None
# Note: Status is handled by the backend, not in create/update payloads
class ExpenseSplitCreate(ExpenseSplitBase): class ExpenseSplitCreate(ExpenseSplitBase):
pass pass
@ -18,10 +20,10 @@ class ExpenseSplitCreate(ExpenseSplitBase):
class ExpenseSplitPublic(ExpenseSplitBase): class ExpenseSplitPublic(ExpenseSplitBase):
id: int id: int
expense_id: int expense_id: int
status: ExpenseSplitStatusEnum
user: Optional[UserPublic] = None user: Optional[UserPublic] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
status: ExpenseSplitStatusEnum
paid_at: Optional[datetime] = None paid_at: Optional[datetime] = None
settlement_activities: List[SettlementActivityPublic] = [] settlement_activities: List[SettlementActivityPublic] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -32,11 +32,11 @@
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id"> @touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header"> <div class="neo-list-header">
<span>{{ list.name }}</span> <span>{{ list.name }}</span>
<div class="actions"> <!-- <div class="actions">
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm" <VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
icon="archive" /> icon="archive" />
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" /> <VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
</div> </div> -->
</div> </div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div> <div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list"> <ul class="neo-item-list">

View File

@ -3,7 +3,7 @@ import { API_BASE_URL, API_VERSION, API_ENDPOINTS } from '@/config/api-config' /
import router from '@/router' // Import the router instance import router from '@/router' // Import the router instance
import { useAuthStore } from '@/stores/auth' // Import the auth store import { useAuthStore } from '@/stores/auth' // Import the auth store
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
import { stringify } from 'qs'; import { stringify } from 'qs'
// Create axios instance // Create axios instance
const api = axios.create({ const api = axios.create({
@ -79,11 +79,15 @@ api.interceptors.response.use(
// Set refreshing state and create refresh promise // Set refreshing state and create refresh promise
authStore.isRefreshing = true authStore.isRefreshing = true
refreshPromise = api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, { refreshPromise = api.post(
headers: { API_ENDPOINTS.AUTH.REFRESH,
'Content-Type': 'application/json', { refresh_token: refreshTokenValue },
} {
}) headers: {
'Content-Type': 'application/json',
},
},
)
try { try {
const response = await refreshPromise const response = await refreshPromise