ph5 #69
@ -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
|
||||
)
|
||||
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))
|
@ -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)
|
||||
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)
|
@ -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,
|
||||
|
@ -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]:
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
return next_date
|
@ -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)
|
||||
|
9
be/app/schemas/financials.py
Normal file
9
be/app/schemas/financials.py
Normal 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
|
35
be/app/schemas/recurrence.py
Normal file
35
be/app/schemas/recurrence.py
Normal 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
|
343
be/app/services/costs_service.py
Normal file
343
be/app/services/costs_service.py
Normal 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
|
||||
)
|
31
be/app/services/financials_service.py
Normal file
31
be/app/services/financials_service.py
Normal 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
|
@ -32,11 +32,11 @@
|
||||
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
|
||||
<div class="neo-list-header">
|
||||
<span>{{ list.name }}</span>
|
||||
<div class="actions">
|
||||
<!-- <div class="actions">
|
||||
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
|
||||
icon="archive" />
|
||||
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
|
||||
<ul class="neo-item-list">
|
||||
|
@ -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 { useAuthStore } from '@/stores/auth' // Import the auth store
|
||||
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
|
||||
const api = axios.create({
|
||||
@ -79,11 +79,15 @@ api.interceptors.response.use(
|
||||
|
||||
// Set refreshing state and create refresh promise
|
||||
authStore.isRefreshing = true
|
||||
refreshPromise = api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
refreshPromise = api.post(
|
||||
API_ENDPOINTS.AUTH.REFRESH,
|
||||
{ refresh_token: refreshTokenValue },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await refreshPromise
|
||||
|
Loading…
Reference in New Issue
Block a user