commit
82418132cd
@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_transactional_session
|
||||
from app.models import User
|
||||
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy
|
||||
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy, get_user_manager
|
||||
from app.config import settings
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
@ -88,16 +88,33 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
@router.post('/jwt/refresh')
|
||||
async def refresh_jwt_token(request: Request):
|
||||
async def refresh_jwt_token(
|
||||
request: Request,
|
||||
user_manager=Depends(get_user_manager),
|
||||
):
|
||||
"""Refresh the JWT access token using a valid refresh token.
|
||||
|
||||
The incoming request must provide a JSON body with a ``refresh_token`` field.
|
||||
A new access and refresh token pair will be returned when the provided refresh
|
||||
token is valid. If the token is invalid or expired, a *401* error is raised.
|
||||
"""
|
||||
|
||||
data = await request.json()
|
||||
refresh_token = data.get('refresh_token')
|
||||
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
|
||||
|
||||
refresh_strategy = get_refresh_jwt_strategy()
|
||||
|
||||
try:
|
||||
user = await refresh_strategy.read_token(refresh_token, None)
|
||||
# ``read_token`` needs a callback capable of loading the *User* from the
|
||||
# database. We therefore pass the user manager obtained via dependency
|
||||
# injection so that the strategy can hydrate the full *User* instance.
|
||||
user = await refresh_strategy.read_token(refresh_token, user_manager)
|
||||
except Exception:
|
||||
# Any error during decoding or lookup should result in an unauthorized
|
||||
# response to avoid leaking information about token validity.
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
||||
|
||||
if not user:
|
||||
@ -106,6 +123,7 @@ async def refresh_jwt_token(request: Request):
|
||||
access_strategy = get_jwt_strategy()
|
||||
access_token = await access_strategy.write_token(user)
|
||||
new_refresh_token = await refresh_strategy.write_token(user)
|
||||
|
||||
return JSONResponse({
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
|
@ -18,6 +18,7 @@ from app.schemas.chore import (
|
||||
from app.schemas.time_entry import TimeEntryPublic
|
||||
from app.crud import chore as crud_chore
|
||||
from app.crud import history as crud_history
|
||||
from app.crud import group as crud_group
|
||||
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -122,6 +123,66 @@ async def update_personal_chore(
|
||||
logger.error(f"DatabaseIntegrityError updating personal chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
|
||||
|
||||
@router.put(
|
||||
"/{chore_id}",
|
||||
response_model=ChorePublic,
|
||||
summary="Update Chore (Any Type)",
|
||||
tags=["Chores"]
|
||||
)
|
||||
async def update_chore_any_type(
|
||||
chore_id: int,
|
||||
chore_in: ChoreUpdate,
|
||||
db: AsyncSession = Depends(get_transactional_session),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Updates a chore of any type, including conversions between personal and group chores."""
|
||||
logger.info(f"User {current_user.email} updating chore ID: {chore_id}")
|
||||
|
||||
# Get the current chore to determine its type and group
|
||||
current_chore = await crud_chore.get_chore_by_id(db, chore_id)
|
||||
if not current_chore:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chore {chore_id} not found")
|
||||
|
||||
# Check permissions on the current chore
|
||||
if current_chore.type == ChoreTypeEnum.personal:
|
||||
if current_chore.created_by_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only update your own personal chores")
|
||||
else: # group chore
|
||||
if not await crud_group.is_user_member(db, current_chore.group_id, current_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not a member of group {current_chore.group_id}")
|
||||
|
||||
# If converting to group chore, validate the target group
|
||||
if chore_in.type == ChoreTypeEnum.group and chore_in.group_id:
|
||||
if not await crud_group.is_user_member(db, chore_in.group_id, current_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not a member of target group {chore_in.group_id}")
|
||||
|
||||
try:
|
||||
# Use the current group_id for the update call if not changing groups
|
||||
group_id_for_update = current_chore.group_id if current_chore.type == ChoreTypeEnum.group else None
|
||||
|
||||
updated_chore = await crud_chore.update_chore(
|
||||
db=db,
|
||||
chore_id=chore_id,
|
||||
chore_in=chore_in,
|
||||
user_id=current_user.id,
|
||||
group_id=group_id_for_update
|
||||
)
|
||||
if not updated_chore:
|
||||
raise ChoreNotFoundError(chore_id=chore_id)
|
||||
return updated_chore
|
||||
except ChoreNotFoundError as e:
|
||||
logger.warning(f"Chore {e.chore_id} not found for user {current_user.email} during update.")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
|
||||
except PermissionDeniedError as e:
|
||||
logger.warning(f"Permission denied for user {current_user.email} updating chore {chore_id}: {e.detail}")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
|
||||
except ValueError as e:
|
||||
logger.warning(f"ValueError updating chore {chore_id} for user {current_user.email}: {str(e)}")
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except DatabaseIntegrityError as e:
|
||||
logger.error(f"DatabaseIntegrityError updating chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
|
||||
|
||||
@router.delete(
|
||||
"/personal/{chore_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
@ -180,7 +241,7 @@ async def create_group_chore(
|
||||
chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group})
|
||||
|
||||
try:
|
||||
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
|
||||
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id)
|
||||
except GroupNotFoundError as e:
|
||||
logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
|
||||
@ -226,17 +287,16 @@ async def update_group_chore(
|
||||
db: AsyncSession = Depends(get_transactional_session),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Updates a chore's details within a specific group."""
|
||||
"""Updates a chore's details. The group_id in path is the current group of the chore."""
|
||||
logger.info(f"User {current_user.email} updating chore ID {chore_id} in group {group_id}")
|
||||
if chore_in.type is not None and chore_in.type != ChoreTypeEnum.group:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change chore type to personal via this endpoint.")
|
||||
if chore_in.group_id is not None and chore_in.group_id != group_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
|
||||
|
||||
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
|
||||
# Validate that the chore is in the specified group
|
||||
chore_to_update = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id)
|
||||
if not chore_to_update:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chore {chore_id} not found in group {group_id}")
|
||||
|
||||
try:
|
||||
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
|
||||
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_in, user_id=current_user.id, group_id=group_id)
|
||||
if not updated_chore:
|
||||
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
|
||||
return updated_chore
|
||||
@ -337,7 +397,7 @@ async def list_my_assignments(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments")
|
||||
|
||||
@router.get(
|
||||
"/chores/{chore_id}/assignments",
|
||||
"/{chore_id}/assignments",
|
||||
response_model=PyList[ChoreAssignmentPublic],
|
||||
summary="List Chore Assignments",
|
||||
tags=["Chore Assignments"]
|
||||
|
@ -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)"},
|
||||
},
|
||||
)
|
||||
.where(ListModel.id == list_id)
|
||||
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
|
||||
)
|
||||
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)
|
||||
)
|
||||
)
|
||||
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
|
||||
try:
|
||||
return await costs_service.get_group_balance_summary_logic(
|
||||
db=db, group_id=group_id, current_user_id=current_user.id
|
||||
)
|
||||
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
|
||||
@ -30,8 +33,9 @@ from app.crud import list as crud_list
|
||||
from app.core.exceptions import (
|
||||
ListNotFoundError, GroupNotFoundError, UserNotFoundError,
|
||||
InvalidOperationError, GroupPermissionError, ListPermissionError,
|
||||
ItemNotFoundError, GroupMembershipError
|
||||
ItemNotFoundError, GroupMembershipError, OverpaymentError, FinancialConflictError
|
||||
)
|
||||
from app.services import financials_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -167,8 +171,12 @@ async def list_expenses(
|
||||
expenses = await crud_expense.get_user_accessible_expenses(db, user_id=current_user.id, skip=skip, limit=limit)
|
||||
|
||||
# Apply recurring filter if specified
|
||||
# NOTE: the original code referenced a non-existent ``expense.recurrence_rule`` attribute.
|
||||
# The canonical way to know if an expense is recurring is the ``is_recurring`` flag
|
||||
# (and/or the presence of a ``recurrence_pattern``). We use ``is_recurring`` here
|
||||
# because it is explicit, indexed and does not require an extra JOIN.
|
||||
if isRecurring is not None:
|
||||
expenses = [expense for expense in expenses if bool(expense.recurrence_rule) == isRecurring]
|
||||
expenses = [expense for expense in expenses if expense.is_recurring == isRecurring]
|
||||
|
||||
return expenses
|
||||
|
||||
@ -413,6 +421,10 @@ async def record_settlement_for_expense_split(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User referenced in settlement activity not found: {str(e)}")
|
||||
except InvalidOperationError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except OverpaymentError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except FinancialConflictError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error recording settlement activity for expense_split_id {expense_split_id}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while recording settlement activity.")
|
||||
@ -656,3 +668,20 @@ async def delete_settlement_record(
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
|
||||
|
||||
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,30 @@ 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 operation conflicts with business logic."""
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=detail
|
||||
)
|
||||
|
||||
class OverpaymentError(HTTPException):
|
||||
"""Raised when a settlement activity would cause overpayment."""
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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,
|
||||
@ -343,6 +359,10 @@ class ChoreOperationError(HTTPException):
|
||||
class ChoreNotFoundError(HTTPException):
|
||||
"""Raised when a chore or assignment is not found."""
|
||||
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
|
||||
self.chore_id = chore_id
|
||||
self.assignment_id = assignment_id
|
||||
self.group_id = group_id
|
||||
|
||||
if detail:
|
||||
error_detail = detail
|
||||
elif group_id is not None:
|
||||
|
43
be/app/core/middleware.py
Normal file
43
be/app/core/middleware.py
Normal file
@ -0,0 +1,43 @@
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger("structured")
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Adds a unique request ID and logs request / response details."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
# Attach id to request state for downstream handlers
|
||||
request.state.request_id = request_id
|
||||
|
||||
logger.info(
|
||||
{
|
||||
"event": "request_start",
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"client": request.client.host if request.client else None,
|
||||
}
|
||||
)
|
||||
|
||||
response: Response = await call_next(request)
|
||||
|
||||
process_time = (time.time() - start_time) * 1000
|
||||
logger.info(
|
||||
{
|
||||
"event": "request_end",
|
||||
"request_id": request_id,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(process_time, 2),
|
||||
}
|
||||
)
|
||||
|
||||
# Propagate request id header for tracing
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
@ -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]:
|
||||
|
@ -1,15 +1,15 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import union_all
|
||||
from sqlalchemy.orm import selectinload, subqueryload
|
||||
from sqlalchemy import union_all, and_, or_, delete
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
|
||||
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum, UserRoleEnum, ChoreHistory, ChoreAssignmentHistory
|
||||
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
|
||||
from app.core.chore_utils import calculate_next_due_date
|
||||
from app.crud.group import get_group_by_id, is_user_member
|
||||
from app.crud.group import get_group_by_id, is_user_member, get_user_role_in_group
|
||||
from app.crud.history import create_chore_history_entry, create_assignment_history_entry
|
||||
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
|
||||
|
||||
@ -70,24 +70,37 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
|
||||
async def create_chore(
|
||||
db: AsyncSession,
|
||||
chore_in: ChoreCreate,
|
||||
user_id: int,
|
||||
group_id: Optional[int] = None
|
||||
user_id: int
|
||||
) -> Chore:
|
||||
"""Creates a new chore, either personal or within a specific group."""
|
||||
"""Creates a new chore, and if specified, an assignment for it."""
|
||||
async with db.begin_nested() if db.in_transaction() else db.begin():
|
||||
# Validate chore type and group
|
||||
if chore_in.type == ChoreTypeEnum.group:
|
||||
if not group_id:
|
||||
if not chore_in.group_id:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
group = await get_group_by_id(db, group_id)
|
||||
group = await get_group_by_id(db, chore_in.group_id)
|
||||
if not group:
|
||||
raise GroupNotFoundError(group_id)
|
||||
if not await is_user_member(db, group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
|
||||
raise GroupNotFoundError(chore_in.group_id)
|
||||
if not await is_user_member(db, chore_in.group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore_in.group_id}")
|
||||
else: # personal chore
|
||||
if group_id:
|
||||
if chore_in.group_id:
|
||||
raise ValueError("group_id must be None for personal chores")
|
||||
|
||||
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
|
||||
# Validate assigned user if provided
|
||||
if chore_in.assigned_to_user_id:
|
||||
if chore_in.type == ChoreTypeEnum.group:
|
||||
# For group chores, assigned user must be a member of the group
|
||||
if not await is_user_member(db, chore_in.group_id, chore_in.assigned_to_user_id):
|
||||
raise PermissionDeniedError(detail=f"Assigned user {chore_in.assigned_to_user_id} is not a member of group {chore_in.group_id}")
|
||||
else: # Personal chore
|
||||
# For personal chores, you can only assign it to yourself
|
||||
if chore_in.assigned_to_user_id != user_id:
|
||||
raise PermissionDeniedError(detail="Personal chores can only be assigned to the creator.")
|
||||
|
||||
assigned_user_id = chore_in.assigned_to_user_id
|
||||
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'assigned_to_user_id'})
|
||||
|
||||
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
|
||||
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
|
||||
if not parent_chore:
|
||||
@ -95,7 +108,6 @@ async def create_chore(
|
||||
|
||||
db_chore = Chore(
|
||||
**chore_data,
|
||||
group_id=group_id,
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
@ -105,6 +117,24 @@ async def create_chore(
|
||||
db.add(db_chore)
|
||||
await db.flush()
|
||||
|
||||
# Create an assignment if a user was specified
|
||||
if assigned_user_id:
|
||||
assignment = ChoreAssignment(
|
||||
chore_id=db_chore.id,
|
||||
assigned_to_user_id=assigned_user_id,
|
||||
due_date=db_chore.next_due_date,
|
||||
is_complete=False
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.flush() # Flush to get the assignment ID
|
||||
await create_assignment_history_entry(
|
||||
db,
|
||||
assignment_id=assignment.id,
|
||||
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
|
||||
changed_by_user_id=user_id,
|
||||
event_data={'assigned_to': assigned_user_id}
|
||||
)
|
||||
|
||||
await create_chore_history_entry(
|
||||
db,
|
||||
chore_id=db_chore.id,
|
||||
@ -137,14 +167,7 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
|
||||
result = await db.execute(
|
||||
select(Chore)
|
||||
.where(Chore.id == chore_id)
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.group),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.options(*get_chore_loader_options())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@ -174,13 +197,7 @@ async def get_personal_chores(
|
||||
Chore.created_by_id == user_id,
|
||||
Chore.type == ChoreTypeEnum.personal
|
||||
)
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.options(*get_chore_loader_options())
|
||||
.order_by(Chore.next_due_date, Chore.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@ -200,13 +217,7 @@ async def get_chores_by_group_id(
|
||||
Chore.group_id == group_id,
|
||||
Chore.type == ChoreTypeEnum.group
|
||||
)
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.options(*get_chore_loader_options())
|
||||
.order_by(Chore.next_due_date, Chore.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@ -226,21 +237,37 @@ async def update_chore(
|
||||
|
||||
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
|
||||
|
||||
# Check permissions for current chore
|
||||
if db_chore.type == ChoreTypeEnum.group:
|
||||
if not group_id:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
if not await is_user_member(db, group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
|
||||
if db_chore.group_id != group_id:
|
||||
raise ChoreNotFoundError(chore_id, group_id)
|
||||
if not await is_user_member(db, db_chore.group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of chore's current group {db_chore.group_id}")
|
||||
else:
|
||||
if group_id:
|
||||
raise ValueError("group_id must be None for personal chores")
|
||||
if db_chore.created_by_id != user_id:
|
||||
raise PermissionDeniedError(detail="Only the creator can update personal chores")
|
||||
|
||||
update_data = chore_in.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle group changes
|
||||
if 'group_id' in update_data:
|
||||
new_group_id = update_data['group_id']
|
||||
if new_group_id != db_chore.group_id:
|
||||
# Validate user has permission for the new group
|
||||
if new_group_id is not None:
|
||||
if not await is_user_member(db, new_group_id, user_id):
|
||||
raise PermissionDeniedError(detail=f"User {user_id} not a member of target group {new_group_id}")
|
||||
|
||||
# Handle type changes
|
||||
if 'type' in update_data:
|
||||
new_type = update_data['type']
|
||||
# When changing to personal, always clear group_id regardless of what's in update_data
|
||||
if new_type == ChoreTypeEnum.personal:
|
||||
update_data['group_id'] = None
|
||||
else:
|
||||
# For group chores, use the provided group_id or keep the current one
|
||||
new_group_id = update_data.get('group_id', db_chore.group_id)
|
||||
if new_type == ChoreTypeEnum.group and new_group_id is None:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
|
||||
if 'parent_chore_id' in update_data:
|
||||
if update_data['parent_chore_id']:
|
||||
parent_chore = await get_chore_by_id(db, update_data['parent_chore_id'])
|
||||
@ -249,13 +276,6 @@ async def update_chore(
|
||||
# Setting parent_chore_id to None is allowed
|
||||
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id'])
|
||||
|
||||
if 'type' in update_data:
|
||||
new_type = update_data['type']
|
||||
if new_type == ChoreTypeEnum.group and not group_id:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
if new_type == ChoreTypeEnum.personal and group_id:
|
||||
raise ValueError("group_id must be None for personal chores")
|
||||
|
||||
# Recalculate next_due_date if needed
|
||||
recalculate = False
|
||||
if 'frequency' in update_data and update_data['frequency'] != db_chore.frequency:
|
||||
@ -304,14 +324,7 @@ async def update_chore(
|
||||
result = await db.execute(
|
||||
select(Chore)
|
||||
.where(Chore.id == db_chore.id)
|
||||
.options(
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.group),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
|
||||
selectinload(Chore.history),
|
||||
selectinload(Chore.child_chores)
|
||||
)
|
||||
.options(*get_chore_loader_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
@ -398,12 +411,7 @@ async def create_chore_assignment(
|
||||
result = await db.execute(
|
||||
select(ChoreAssignment)
|
||||
.where(ChoreAssignment.id == db_assignment.id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
.options(*get_assignment_loader_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
@ -415,12 +423,7 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
|
||||
result = await db.execute(
|
||||
select(ChoreAssignment)
|
||||
.where(ChoreAssignment.id == assignment_id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
.options(*get_assignment_loader_options())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@ -435,12 +438,7 @@ async def get_user_assignments(
|
||||
if not include_completed:
|
||||
query = query.where(ChoreAssignment.is_complete == False)
|
||||
|
||||
query = query.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||
query = query.options(*get_assignment_loader_options()).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
@ -465,12 +463,7 @@ async def get_chore_assignments(
|
||||
result = await db.execute(
|
||||
select(ChoreAssignment)
|
||||
.where(ChoreAssignment.chore_id == chore_id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history)
|
||||
)
|
||||
.options(*get_assignment_loader_options())
|
||||
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@ -510,12 +503,13 @@ async def update_chore_assignment(
|
||||
history_event = ChoreHistoryEventTypeEnum.COMPLETED
|
||||
|
||||
# Advance the next_due_date of the parent chore
|
||||
if db_assignment.chore:
|
||||
if db_assignment.chore and db_assignment.chore.frequency != ChoreFrequencyEnum.one_time:
|
||||
db_assignment.chore.last_completed_at = db_assignment.completed_at
|
||||
db_assignment.chore.next_due_date = calculate_next_due_date(
|
||||
db_assignment.chore.frequency,
|
||||
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(),
|
||||
db_assignment.chore.custom_interval_days
|
||||
current_due_date=db_assignment.chore.next_due_date,
|
||||
frequency=db_assignment.chore.frequency,
|
||||
custom_interval_days=db_assignment.chore.custom_interval_days,
|
||||
last_completed_date=db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else None
|
||||
)
|
||||
elif not new_status and original_status:
|
||||
db_assignment.completed_at = None
|
||||
@ -537,10 +531,7 @@ async def update_chore_assignment(
|
||||
result = await db.execute(
|
||||
select(ChoreAssignment)
|
||||
.where(ChoreAssignment.id == assignment_id)
|
||||
.options(
|
||||
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
|
||||
selectinload(ChoreAssignment.assigned_user)
|
||||
)
|
||||
.options(*get_assignment_loader_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
@ -585,3 +576,25 @@ async def delete_chore_assignment(
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True)
|
||||
raise DatabaseIntegrityError(f"Could not delete chore assignment {assignment_id}. Error: {str(e)}")
|
||||
|
||||
def get_chore_loader_options():
|
||||
"""Returns a list of SQLAlchemy loader options for chore relationships."""
|
||||
return [
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.group),
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.history).selectinload(ChoreHistory.changed_by_user),
|
||||
selectinload(Chore.child_chores).options(
|
||||
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(Chore.history).selectinload(ChoreHistory.changed_by_user),
|
||||
selectinload(Chore.creator),
|
||||
selectinload(Chore.child_chores) # Load grandchildren, adjust depth if needed
|
||||
)
|
||||
]
|
||||
|
||||
def get_assignment_loader_options():
|
||||
return [
|
||||
selectinload(ChoreAssignment.assigned_user),
|
||||
selectinload(ChoreAssignment.history).selectinload(ChoreAssignmentHistory.changed_by_user),
|
||||
selectinload(ChoreAssignment.chore).options(*get_chore_loader_options())
|
||||
]
|
||||
|
@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # Added import
|
||||
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
|
||||
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, InvalidOperation as DecimalInvalidOperation
|
||||
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
|
||||
from datetime import datetime, timezone # Added timezone
|
||||
import json
|
||||
@ -20,6 +20,7 @@ from app.models import (
|
||||
Item as ItemModel,
|
||||
ExpenseOverallStatusEnum, # Added
|
||||
ExpenseSplitStatusEnum, # Added
|
||||
RecurrenceTypeEnum,
|
||||
)
|
||||
from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate, ExpenseUpdate # Removed unused ExpenseUpdate
|
||||
from app.core.exceptions import (
|
||||
@ -147,13 +148,26 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us
|
||||
# Re-resolve context if list_id was derived from item
|
||||
final_group_id = await _resolve_expense_context(db, expense_in)
|
||||
|
||||
# Create recurrence pattern if this is a recurring expense
|
||||
recurrence_pattern = None
|
||||
if expense_in.is_recurring and expense_in.recurrence_pattern:
|
||||
# Normalize recurrence type (accept both str and Enum)
|
||||
_rp_type_raw = expense_in.recurrence_pattern.type
|
||||
if isinstance(_rp_type_raw, str):
|
||||
try:
|
||||
_rp_type_enum = RecurrenceTypeEnum[_rp_type_raw.upper()]
|
||||
except KeyError:
|
||||
raise InvalidOperationError(f"Unsupported recurrence type: {_rp_type_raw}")
|
||||
else:
|
||||
_rp_type_enum = _rp_type_raw # assume already RecurrenceTypeEnum
|
||||
|
||||
recurrence_pattern = RecurrencePattern(
|
||||
type=expense_in.recurrence_pattern.type,
|
||||
type=_rp_type_enum,
|
||||
interval=expense_in.recurrence_pattern.interval,
|
||||
days_of_week=expense_in.recurrence_pattern.days_of_week,
|
||||
days_of_week=(
|
||||
','.join(str(d) for d in expense_in.recurrence_pattern.days_of_week)
|
||||
if isinstance(expense_in.recurrence_pattern.days_of_week, (list, tuple))
|
||||
else expense_in.recurrence_pattern.days_of_week
|
||||
),
|
||||
end_date=expense_in.recurrence_pattern.end_date,
|
||||
max_occurrences=expense_in.recurrence_pattern.max_occurrences,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
@ -314,7 +328,7 @@ async def _generate_expense_splits(
|
||||
|
||||
|
||||
async def _create_equal_splits(db: AsyncSession, expense_model: ExpenseModel, expense_in: ExpenseCreate, round_money_func: Callable[[Decimal], Decimal], **kwargs: Any) -> PyList[ExpenseSplitModel]:
|
||||
"""Creates equal splits among users."""
|
||||
"""Creates equal splits among users, distributing any rounding remainder fairly."""
|
||||
|
||||
users_for_splitting = await get_users_for_splitting(
|
||||
db, expense_model.group_id, expense_model.list_id, expense_model.paid_by_user_id
|
||||
@ -323,21 +337,30 @@ async def _create_equal_splits(db: AsyncSession, expense_model: ExpenseModel, ex
|
||||
raise InvalidOperationError("No users found for EQUAL split.")
|
||||
|
||||
num_users = len(users_for_splitting)
|
||||
amount_per_user = round_money_func(expense_model.total_amount / Decimal(num_users))
|
||||
remainder = expense_model.total_amount - (amount_per_user * num_users)
|
||||
# Use floor rounding initially to prevent over-shooting the total
|
||||
amount_per_user_floor = (expense_model.total_amount / Decimal(num_users)).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
|
||||
|
||||
splits = []
|
||||
for i, user in enumerate(users_for_splitting):
|
||||
split_amount = amount_per_user
|
||||
if i == 0 and remainder != Decimal('0'):
|
||||
split_amount = round_money_func(amount_per_user + remainder)
|
||||
# Sort users by ID to ensure deterministic remainder distribution
|
||||
users_for_splitting.sort(key=lambda u: u.id)
|
||||
|
||||
for user in users_for_splitting:
|
||||
splits.append(ExpenseSplitModel(
|
||||
user_id=user.id,
|
||||
owed_amount=split_amount,
|
||||
status=ExpenseSplitStatusEnum.unpaid # Explicitly set default status
|
||||
owed_amount=amount_per_user_floor,
|
||||
status=ExpenseSplitStatusEnum.unpaid
|
||||
))
|
||||
|
||||
# Calculate remainder and distribute pennies one by one
|
||||
current_total = amount_per_user_floor * num_users
|
||||
remainder = expense_model.total_amount - current_total
|
||||
|
||||
pennies_to_distribute = int(remainder * 100)
|
||||
|
||||
for i in range(pennies_to_distribute):
|
||||
# The modulo ensures that if pennies > num_users (should not happen with floor), it still works
|
||||
splits[i % len(splits)].owed_amount += Decimal("0.01")
|
||||
|
||||
return splits
|
||||
|
||||
|
||||
@ -375,7 +398,7 @@ async def _create_exact_amount_splits(db: AsyncSession, expense_model: ExpenseMo
|
||||
|
||||
|
||||
async def _create_percentage_splits(db: AsyncSession, expense_model: ExpenseModel, expense_in: ExpenseCreate, round_money_func: Callable[[Decimal], Decimal], **kwargs: Any) -> PyList[ExpenseSplitModel]:
|
||||
"""Creates splits based on percentages."""
|
||||
"""Creates splits based on percentages, distributing any rounding remainder fairly."""
|
||||
|
||||
if not expense_in.splits_in:
|
||||
raise InvalidOperationError("Splits data is required for PERCENTAGE split type.")
|
||||
@ -407,16 +430,24 @@ async def _create_percentage_splits(db: AsyncSession, expense_model: ExpenseMode
|
||||
if round_money_func(total_percentage) != Decimal("100.00"):
|
||||
raise InvalidOperationError(f"Sum of percentages ({total_percentage}%) is not 100%.")
|
||||
|
||||
# Adjust for rounding differences
|
||||
# Adjust for rounding differences by distributing remainder fairly
|
||||
if current_total != expense_model.total_amount and splits:
|
||||
diff = expense_model.total_amount - current_total
|
||||
splits[-1].owed_amount = round_money_func(splits[-1].owed_amount + diff)
|
||||
|
||||
# Sort by user ID to make distribution deterministic
|
||||
splits.sort(key=lambda s: s.user_id)
|
||||
|
||||
pennies = int(diff * 100)
|
||||
increment = Decimal("0.01") if pennies > 0 else Decimal("-0.01")
|
||||
|
||||
for i in range(abs(pennies)):
|
||||
splits[i % len(splits)].owed_amount += increment
|
||||
|
||||
return splits
|
||||
|
||||
|
||||
async def _create_shares_splits(db: AsyncSession, expense_model: ExpenseModel, expense_in: ExpenseCreate, round_money_func: Callable[[Decimal], Decimal], **kwargs: Any) -> PyList[ExpenseSplitModel]:
|
||||
"""Creates splits based on shares."""
|
||||
"""Creates splits based on shares, distributing any rounding remainder fairly."""
|
||||
|
||||
if not expense_in.splits_in:
|
||||
raise InvalidOperationError("Splits data is required for SHARES split type.")
|
||||
@ -447,10 +478,18 @@ async def _create_shares_splits(db: AsyncSession, expense_model: ExpenseModel, e
|
||||
status=ExpenseSplitStatusEnum.unpaid # Explicitly set default status
|
||||
))
|
||||
|
||||
# Adjust for rounding differences
|
||||
# Adjust for rounding differences by distributing remainder fairly
|
||||
if current_total != expense_model.total_amount and splits:
|
||||
diff = expense_model.total_amount - current_total
|
||||
splits[-1].owed_amount = round_money_func(splits[-1].owed_amount + diff)
|
||||
|
||||
# Sort by user ID to make distribution deterministic
|
||||
splits.sort(key=lambda s: s.user_id)
|
||||
|
||||
pennies = int(diff * 100)
|
||||
increment = Decimal("0.01") if pennies > 0 else Decimal("-0.01")
|
||||
|
||||
for i in range(abs(pennies)):
|
||||
splits[i % len(splits)].owed_amount += increment
|
||||
|
||||
return splits
|
||||
|
||||
|
@ -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, OverpaymentError
|
||||
|
||||
|
||||
class SettlementActivityCreatePlaceholder(BaseModel):
|
||||
@ -114,21 +116,55 @@ 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
|
||||
|
||||
if not expense_split:
|
||||
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.")
|
||||
|
||||
# Calculate current total paid to prevent overpayment
|
||||
current_total_paid = Decimal("0.00")
|
||||
if expense_split.settlement_activities:
|
||||
current_total_paid = sum(
|
||||
Decimal(str(activity.amount_paid)) for activity in expense_split.settlement_activities
|
||||
)
|
||||
current_total_paid = current_total_paid.quantize(Decimal("0.01"))
|
||||
|
||||
new_payment_amount = Decimal(str(settlement_activity_in.amount_paid)).quantize(Decimal("0.01"))
|
||||
projected_total = current_total_paid + new_payment_amount
|
||||
owed_amount = Decimal(str(expense_split.owed_amount)).quantize(Decimal("0.01"))
|
||||
|
||||
# Prevent overpayment (with small epsilon for floating point precision)
|
||||
epsilon = Decimal("0.01")
|
||||
if projected_total > (owed_amount + epsilon):
|
||||
remaining_amount = owed_amount - current_total_paid
|
||||
raise OverpaymentError(
|
||||
f"Payment amount {new_payment_amount} would exceed remaining owed amount. "
|
||||
f"Maximum payment allowed: {remaining_amount} (owed: {owed_amount}, already paid: {current_total_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 +184,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,11 +1,13 @@
|
||||
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
|
||||
import enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -29,6 +31,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)
|
||||
@ -38,23 +42,43 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
|
||||
try:
|
||||
await _generate_next_occurrence(db, expense)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating next occurrence for expense {expense.id}: {str(e)}")
|
||||
logger.error(f"Error generating next occurrence for expense {expense.id}: {str(e)}", exc_info=True)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_recurring_expenses job: {str(e)}")
|
||||
raise
|
||||
logger.error(f"Error in generate_recurring_expenses job during expense fetch: {str(e)}", exc_info=True)
|
||||
# Do not re-raise, allow the job scheduler to run again later
|
||||
|
||||
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,
|
||||
total_amount=expense.total_amount,
|
||||
@ -65,46 +89,98 @@ 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]:
|
||||
"""Calculate the next occurrence date based on the pattern."""
|
||||
"""Calculate the next occurrence date based on the recurrence pattern provided."""
|
||||
|
||||
if not current_date:
|
||||
return None
|
||||
|
||||
if pattern.type == 'daily':
|
||||
return current_date + timedelta(days=pattern.interval)
|
||||
# Extract a lowercase string of the recurrence type regardless of whether it is an Enum member or a str.
|
||||
if isinstance(pattern.type, enum.Enum):
|
||||
pattern_type = pattern.type.value.lower()
|
||||
else:
|
||||
pattern_type = str(pattern.type).lower()
|
||||
|
||||
elif pattern.type == 'weekly':
|
||||
next_date: Optional[datetime] = None
|
||||
|
||||
if pattern_type == 'daily':
|
||||
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)
|
||||
|
||||
next_date = current_date + timedelta(weeks=pattern.interval)
|
||||
else:
|
||||
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)
|
||||
# ``days_of_week`` can be stored either as a list[int] (Python-side) or as a
|
||||
# comma-separated string in the database. We normalise it to a list[int].
|
||||
days_of_week_iterable = []
|
||||
if pattern.days_of_week is None:
|
||||
days_of_week_iterable = []
|
||||
elif isinstance(pattern.days_of_week, (list, tuple)):
|
||||
days_of_week_iterable = list(pattern.days_of_week)
|
||||
else:
|
||||
# Assume comma-separated string like "1,3,5"
|
||||
try:
|
||||
days_of_week_iterable = [int(d.strip()) for d in str(pattern.days_of_week).split(',') if d.strip().isdigit()]
|
||||
except Exception:
|
||||
days_of_week_iterable = []
|
||||
|
||||
elif pattern.type == 'monthly':
|
||||
# Find the next valid weekday after the current one
|
||||
next_days = sorted([d for d in days_of_week_iterable if d > current_weekday])
|
||||
if next_days:
|
||||
days_ahead = next_days[0] - current_weekday
|
||||
next_date = current_date + timedelta(days=days_ahead)
|
||||
else:
|
||||
# Jump to the first valid day in a future week respecting the interval
|
||||
if days_of_week_iterable:
|
||||
days_ahead = (7 - current_weekday) + min(days_of_week_iterable)
|
||||
next_date = current_date + timedelta(days=days_ahead)
|
||||
if pattern.interval > 1:
|
||||
next_date += timedelta(weeks=pattern.interval - 1)
|
||||
|
||||
elif pattern_type == 'monthly':
|
||||
# Move `interval` months forward while keeping the day component stable where possible.
|
||||
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)
|
||||
try:
|
||||
next_date = current_date.replace(year=year, month=month)
|
||||
except ValueError:
|
||||
# Handle cases like Feb-31st by rolling back to the last valid day of the new month.
|
||||
next_date = (current_date.replace(day=1, year=year, month=month) + timedelta(days=31)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
elif pattern.type == 'yearly':
|
||||
return current_date.replace(year=current_date.year + pattern.interval)
|
||||
elif pattern_type == 'yearly':
|
||||
try:
|
||||
next_date = current_date.replace(year=current_date.year + pattern.interval)
|
||||
except ValueError:
|
||||
# Leap-year edge-case; fallback to Feb-28 if Feb-29 does not exist in the target year.
|
||||
next_date = current_date.replace(year=current_date.year + pattern.interval, day=28)
|
||||
|
||||
# Stop recurrence if beyond end_date
|
||||
if pattern.end_date and next_date and next_date > pattern.end_date:
|
||||
return None
|
||||
|
||||
return next_date
|
@ -12,6 +12,7 @@ from app.core.api_config import API_METADATA, API_TAGS
|
||||
from app.auth import fastapi_users, auth_backend
|
||||
from app.schemas.user import UserPublic, UserCreate, UserUpdate
|
||||
from app.core.scheduler import init_scheduler, shutdown_scheduler
|
||||
from app.core.middleware import RequestContextMiddleware
|
||||
|
||||
if settings.SENTRY_DSN:
|
||||
sentry_sdk.init(
|
||||
@ -48,6 +49,9 @@ app.add_middleware(
|
||||
secret_key=settings.SESSION_SECRET_KEY
|
||||
)
|
||||
|
||||
# Structured logging & request tracing
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
|
@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List, Any
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, ChoreHistoryEventTypeEnum
|
||||
from .user import UserPublic
|
||||
|
||||
@ -35,37 +35,26 @@ class ChoreBase(BaseModel):
|
||||
next_due_date: date # For creation, this will be the initial due date
|
||||
type: ChoreTypeEnum
|
||||
|
||||
@field_validator('custom_interval_days', mode='before')
|
||||
@classmethod
|
||||
def check_custom_interval_days(cls, value, values):
|
||||
# Pydantic v2 uses `values.data` to get all fields
|
||||
# For older Pydantic, it might just be `values`
|
||||
# This is a simplified check; actual access might differ slightly
|
||||
# based on Pydantic version context within the validator.
|
||||
# The goal is to ensure custom_interval_days is present if frequency is 'custom'.
|
||||
# This validator might be more complex in a real Pydantic v2 setup.
|
||||
|
||||
# A more direct way if 'frequency' is already parsed into values.data:
|
||||
# freq = values.data.get('frequency')
|
||||
# For this example, we'll assume 'frequency' might not be in 'values.data' yet
|
||||
# if 'custom_interval_days' is validated 'before' 'frequency'.
|
||||
# A truly robust validator might need to be on the whole model or run 'after'.
|
||||
# For now, this is a placeholder for the logic.
|
||||
# Consider if this validation is better handled at the service/CRUD layer for complex cases.
|
||||
return value
|
||||
@model_validator(mode='after')
|
||||
def validate_custom_frequency(self):
|
||||
if self.frequency == ChoreFrequencyEnum.custom:
|
||||
if self.custom_interval_days is None or self.custom_interval_days <= 0:
|
||||
raise ValueError("custom_interval_days must be a positive integer when frequency is 'custom'")
|
||||
return self
|
||||
|
||||
class ChoreCreate(ChoreBase):
|
||||
group_id: Optional[int] = None
|
||||
parent_chore_id: Optional[int] = None
|
||||
assigned_to_user_id: Optional[int] = None
|
||||
|
||||
@field_validator('group_id')
|
||||
@classmethod
|
||||
def validate_group_id(cls, v, values):
|
||||
if values.data.get('type') == ChoreTypeEnum.group and v is None:
|
||||
@model_validator(mode='after')
|
||||
def validate_group_id_with_type(self):
|
||||
if self.type == ChoreTypeEnum.group and self.group_id is None:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
if values.data.get('type') == ChoreTypeEnum.personal and v is not None:
|
||||
raise ValueError("group_id must be None for personal chores")
|
||||
return v
|
||||
if self.type == ChoreTypeEnum.personal and self.group_id is not None:
|
||||
# Automatically clear group_id for personal chores instead of raising an error
|
||||
self.group_id = None
|
||||
return self
|
||||
|
||||
class ChoreUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
@ -75,16 +64,17 @@ class ChoreUpdate(BaseModel):
|
||||
next_due_date: Optional[date] = None # Allow updating next_due_date directly if needed
|
||||
type: Optional[ChoreTypeEnum] = None
|
||||
group_id: Optional[int] = None
|
||||
parent_chore_id: Optional[int] = None # Allow moving a chore under a parent or removing association
|
||||
# last_completed_at should generally not be updated directly by user
|
||||
|
||||
@field_validator('group_id')
|
||||
@classmethod
|
||||
def validate_group_id(cls, v, values):
|
||||
if values.data.get('type') == ChoreTypeEnum.group and v is None:
|
||||
@model_validator(mode='after')
|
||||
def validate_group_id_with_type(self):
|
||||
if self.type == ChoreTypeEnum.group and self.group_id is None:
|
||||
raise ValueError("group_id is required for group chores")
|
||||
if values.data.get('type') == ChoreTypeEnum.personal and v is not None:
|
||||
raise ValueError("group_id must be None for personal chores")
|
||||
return v
|
||||
if self.type == ChoreTypeEnum.personal and self.group_id is not None:
|
||||
# Automatically clear group_id for personal chores instead of raising an error
|
||||
self.group_id = None
|
||||
return self
|
||||
|
||||
class ChorePublic(ChoreBase):
|
||||
id: int
|
||||
|
@ -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
|
@ -24,6 +24,7 @@ from app.crud.settlement_activity import (
|
||||
update_expense_overall_status # For direct testing if needed
|
||||
)
|
||||
from app.schemas.settlement_activity import SettlementActivityCreate as SettlementActivityCreateSchema
|
||||
from app.core.exceptions import OverpaymentError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -356,6 +357,73 @@ async def test_create_settlement_activity_overall_status_becomes_partially_paid(
|
||||
# Since one split is paid and the other is unpaid, the overall expense status should be partially_paid
|
||||
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_settlement_activity_overpayment_protection(
|
||||
db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit
|
||||
):
|
||||
"""Test that settlement activities prevent overpayment beyond owed amount."""
|
||||
# Test split owes 10.00, attempt to pay 15.00 directly
|
||||
activity_data = SettlementActivityCreateSchema(
|
||||
expense_split_id=test_expense_split_user2_owes.id,
|
||||
paid_by_user_id=test_user2.id,
|
||||
amount_paid=Decimal("15.00") # More than the 10.00 owed
|
||||
)
|
||||
|
||||
# Should raise OverpaymentError
|
||||
with pytest.raises(OverpaymentError):
|
||||
await create_settlement_activity(
|
||||
db=db_session,
|
||||
settlement_activity_in=activity_data,
|
||||
current_user_id=test_user2.id
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_settlement_activity_overpayment_protection_partial(
|
||||
db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit
|
||||
):
|
||||
"""Test overpayment protection with multiple payments."""
|
||||
# First payment of 7.00
|
||||
activity1_data = SettlementActivityCreateSchema(
|
||||
expense_split_id=test_expense_split_user2_owes.id,
|
||||
paid_by_user_id=test_user2.id,
|
||||
amount_paid=Decimal("7.00")
|
||||
)
|
||||
|
||||
activity1 = await create_settlement_activity(
|
||||
db=db_session,
|
||||
settlement_activity_in=activity1_data,
|
||||
current_user_id=test_user2.id
|
||||
)
|
||||
assert activity1 is not None
|
||||
|
||||
# Second payment of 5.00 should be rejected (7 + 5 = 12 > 10 owed)
|
||||
activity2_data = SettlementActivityCreateSchema(
|
||||
expense_split_id=test_expense_split_user2_owes.id,
|
||||
paid_by_user_id=test_user2.id,
|
||||
amount_paid=Decimal("5.00")
|
||||
)
|
||||
|
||||
with pytest.raises(OverpaymentError):
|
||||
await create_settlement_activity(
|
||||
db=db_session,
|
||||
settlement_activity_in=activity2_data,
|
||||
current_user_id=test_user2.id
|
||||
)
|
||||
|
||||
# But a payment of 3.00 should work (7 + 3 = 10 = exact amount owed)
|
||||
activity3_data = SettlementActivityCreateSchema(
|
||||
expense_split_id=test_expense_split_user2_owes.id,
|
||||
paid_by_user_id=test_user2.id,
|
||||
amount_paid=Decimal("3.00")
|
||||
)
|
||||
|
||||
activity3 = await create_settlement_activity(
|
||||
db=db_session,
|
||||
settlement_activity_in=activity3_data,
|
||||
current_user_id=test_user2.id
|
||||
)
|
||||
assert activity3 is not None
|
||||
|
||||
# Example of a placeholder for db_session fixture if not provided by conftest.py
|
||||
# @pytest.fixture
|
||||
# async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
107
docs/chore-system.md
Normal file
107
docs/chore-system.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Chore System Documentation
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The chore system is designed to help users manage tasks, both personal and within groups. It supports recurring chores, assignments, and a detailed history of all changes. This document provides a comprehensive overview of the system's architecture, data models, API endpoints, and frontend implementation.
|
||||
|
||||
## 2. Data Models
|
||||
|
||||
The chore system is built around three core data models: `Chore`, `ChoreAssignment`, and `ChoreHistory`.
|
||||
|
||||
### 2.1. Chore Model
|
||||
|
||||
The `Chore` model represents a task to be completed. It can be a personal chore or a group chore.
|
||||
|
||||
- **`id`**: The unique identifier for the chore.
|
||||
- **`name`**: The name of the chore.
|
||||
- **`description`**: A detailed description of the chore.
|
||||
- **`type`**: The type of chore, either `personal` or `group`.
|
||||
- **`group_id`**: The ID of the group the chore belongs to (if it's a group chore).
|
||||
- **`created_by_id`**: The ID of the user who created the chore.
|
||||
- **`frequency`**: The frequency of the chore, such as `daily`, `weekly`, or `monthly`.
|
||||
- **`custom_interval_days`**: The number of days between occurrences for custom frequency chores.
|
||||
- **`next_due_date`**: The next due date for the chore.
|
||||
- **`last_completed_at`**: The timestamp of when the chore was last completed.
|
||||
|
||||
### 2.2. ChoreAssignment Model
|
||||
|
||||
The `ChoreAssignment` model represents the assignment of a chore to a user.
|
||||
|
||||
- **`id`**: The unique identifier for the assignment.
|
||||
- **`chore_id`**: The ID of the chore being assigned.
|
||||
- **`assigned_to_user_id`**: The ID of the user the chore is assigned to.
|
||||
- **`due_date`**: The due date for the assignment.
|
||||
- **`is_complete`**: A boolean indicating whether the assignment is complete.
|
||||
- **`completed_at`**: The timestamp of when the assignment was completed.
|
||||
|
||||
### 2.3. ChoreHistory Model
|
||||
|
||||
The `ChoreHistory` model tracks all changes to a chore, such as creation, updates, and completion.
|
||||
|
||||
- **`id`**: The unique identifier for the history entry.
|
||||
- **`chore_id`**: The ID of the chore the history entry belongs to.
|
||||
- **`event_type`**: The type of event, such as `created`, `updated`, or `completed`.
|
||||
- **`event_data`**: A JSON object containing details about the event.
|
||||
- **`changed_by_user_id`**: The ID of the user who made the change.
|
||||
|
||||
## 3. API Endpoints
|
||||
|
||||
The chore system exposes a set of API endpoints for managing chores, assignments, and history.
|
||||
|
||||
### 3.1. Chores
|
||||
|
||||
- **`GET /api/v1/chores/all`**: Retrieves all chores for the current user.
|
||||
- **`POST /api/v1/chores/personal`**: Creates a new personal chore.
|
||||
- **`GET /api/v1/chores/personal`**: Retrieves all personal chores for the current user.
|
||||
- **`PUT /api/v1/chores/personal/{chore_id}`**: Updates a personal chore.
|
||||
- **`DELETE /api/v1/chores/personal/{chore_id}`**: Deletes a personal chore.
|
||||
- **`POST /api/v1/chores/groups/{group_id}/chores`**: Creates a new group chore.
|
||||
- **`GET /api/v1/chores/groups/{group_id}/chores`**: Retrieves all chores for a specific group.
|
||||
- **`PUT /api/v1/chores/groups/{group_id}/chores/{chore_id}`**: Updates a group chore.
|
||||
- **`DELETE /api/v1/chores/groups/{group_id}/chores/{chore_id}`**: Deletes a group chore.
|
||||
|
||||
### 3.2. Assignments
|
||||
|
||||
- **`POST /api/v1/chores/assignments`**: Creates a new chore assignment.
|
||||
- **`GET /api/v1/chores/assignments/my`**: Retrieves all chore assignments for the current user.
|
||||
- **`GET /api/v1/chores/chores/{chore_id}/assignments`**: Retrieves all assignments for a specific chore.
|
||||
- **`PUT /api/v1/chores/assignments/{assignment_id}`**: Updates a chore assignment.
|
||||
- **`DELETE /api/v1/chores/assignments/{assignment_id}`**: Deletes a chore assignment.
|
||||
- **`PATCH /api/v1/chores/assignments/{assignment_id}/complete`**: Marks a chore assignment as complete.
|
||||
|
||||
### 3.3. History
|
||||
|
||||
- **`GET /api/v1/chores/{chore_id}/history`**: Retrieves the history for a specific chore.
|
||||
- **`GET /api/v1/chores/assignments/{assignment_id}/history`**: Retrieves the history for a specific chore assignment.
|
||||
|
||||
## 4. Frontend Implementation
|
||||
|
||||
The frontend for the chore system is built using Vue.js and the Composition API. The main component is `ChoresPage.vue`, which is responsible for displaying the list of chores, handling user interactions, and communicating with the backend.
|
||||
|
||||
### 4.1. State Management
|
||||
|
||||
The `ChoresPage.vue` component uses Pinia for state management. The `useChoreStore` manages the state of the chores, including the list of chores, the current chore being edited, and the loading state.
|
||||
|
||||
### 4.2. User Experience
|
||||
|
||||
The user experience is designed to be intuitive and efficient. Users can easily view their chores, mark them as complete, and create new chores. The UI also provides feedback to the user, such as loading indicators and success messages.
|
||||
|
||||
## 5. Reliability and UX Recommendations
|
||||
|
||||
To improve the reliability and user experience of the chore system, the following recommendations are suggested:
|
||||
|
||||
### 5.1. Optimistic UI Updates
|
||||
|
||||
When a user marks a chore as complete, the UI should immediately reflect the change, even before the API call has completed. This will make the UI feel more responsive and reduce perceived latency.
|
||||
|
||||
### 5.2. More Robust Error Handling
|
||||
|
||||
The frontend should provide more specific and helpful error messages to the user. For example, if an API call fails, the UI should display a message that explains what went wrong and what the user can do to fix it.
|
||||
|
||||
### 5.3. Intuitive User Interface
|
||||
|
||||
The user interface could be improved to make it more intuitive and easier to use. For example, the chore creation form could be simplified, and the chore list could be made more scannable.
|
||||
|
||||
### 5.4. Improved Caching
|
||||
|
||||
The frontend should implement a more sophisticated caching strategy to reduce the number of API calls and improve performance. For example, the list of chores could be cached in local storage, and the cache could be invalidated when a new chore is created or an existing chore is updated.
|
@ -1,368 +0,0 @@
|
||||
# Expense System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The expense system is a core feature that allows users to track shared expenses, split them among group members, and manage settlements. The system supports various split types and integrates with lists, groups, and items.
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. Expenses
|
||||
|
||||
An expense represents a shared cost that needs to be split among multiple users.
|
||||
|
||||
#### Key Properties
|
||||
|
||||
- `id`: Unique identifier
|
||||
- `description`: Description of the expense
|
||||
- `total_amount`: Total cost of the expense (Decimal)
|
||||
- `currency`: Currency code (defaults to "USD")
|
||||
- `expense_date`: When the expense occurred
|
||||
- `split_type`: How the expense should be divided
|
||||
- `list_id`: Optional reference to a shopping list
|
||||
- `group_id`: Optional reference to a group
|
||||
- `item_id`: Optional reference to a specific item
|
||||
- `paid_by_user_id`: User who paid for the expense
|
||||
- `created_by_user_id`: User who created the expense record
|
||||
- `version`: For optimistic locking
|
||||
- `overall_settlement_status`: Overall payment status
|
||||
|
||||
#### Status Types
|
||||
|
||||
```typescript
|
||||
enum ExpenseOverallStatusEnum {
|
||||
UNPAID = "unpaid",
|
||||
PARTIALLY_PAID = "partially_paid",
|
||||
PAID = "paid",
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Expense Splits
|
||||
|
||||
Splits represent how an expense is divided among users.
|
||||
|
||||
#### Key Properties
|
||||
|
||||
- `id`: Unique identifier
|
||||
- `expense_id`: Reference to parent expense
|
||||
- `user_id`: User who owes this portion
|
||||
- `owed_amount`: Amount owed by the user
|
||||
- `share_percentage`: Percentage share (for percentage-based splits)
|
||||
- `share_units`: Number of shares (for share-based splits)
|
||||
- `status`: Current payment status
|
||||
- `paid_at`: When the split was paid
|
||||
- `settlement_activities`: List of payment activities
|
||||
|
||||
#### Status Types
|
||||
|
||||
```typescript
|
||||
enum ExpenseSplitStatusEnum {
|
||||
UNPAID = "unpaid",
|
||||
PARTIALLY_PAID = "partially_paid",
|
||||
PAID = "paid",
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Settlement Activities
|
||||
|
||||
Settlement activities track individual payments made towards expense splits.
|
||||
|
||||
#### Key Properties
|
||||
|
||||
- `id`: Unique identifier
|
||||
- `expense_split_id`: Reference to the split being paid
|
||||
- `paid_by_user_id`: User making the payment
|
||||
- `amount_paid`: Amount being paid
|
||||
- `paid_at`: When the payment was made
|
||||
- `created_by_user_id`: User who recorded the payment
|
||||
|
||||
## Split Types
|
||||
|
||||
The system supports multiple ways to split expenses:
|
||||
|
||||
### 1. Equal Split
|
||||
|
||||
- Divides the total amount equally among all participants
|
||||
- Handles rounding differences by adding remainder to first split
|
||||
- No additional data required
|
||||
|
||||
### 2. Exact Amounts
|
||||
|
||||
- Users specify exact amounts for each person
|
||||
- Sum of amounts must equal total expense
|
||||
- Requires `splits_in` data with exact amounts
|
||||
|
||||
### 3. Percentage Based
|
||||
|
||||
- Users specify percentage shares
|
||||
- Percentages must sum to 100%
|
||||
- Requires `splits_in` data with percentages
|
||||
|
||||
### 4. Share Based
|
||||
|
||||
- Users specify number of shares
|
||||
- Amount divided proportionally to shares
|
||||
- Requires `splits_in` data with share units
|
||||
|
||||
### 5. Item Based
|
||||
|
||||
- Splits based on items in a shopping list
|
||||
- Each item's cost is assigned to its adder
|
||||
- Requires `list_id` and optionally `item_id`
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. Lists
|
||||
|
||||
- Expenses can be associated with shopping lists
|
||||
- Item-based splits use list items to determine splits
|
||||
- List's group context can determine split participants
|
||||
|
||||
### 2. Groups
|
||||
|
||||
- Expenses can be directly associated with groups
|
||||
- Group membership determines who can be included in splits
|
||||
- Group context is required if no list is specified
|
||||
|
||||
### 3. Items
|
||||
|
||||
- Expenses can be linked to specific items
|
||||
- Item prices are used for item-based splits
|
||||
- Items must belong to a list
|
||||
|
||||
### 4. Users
|
||||
|
||||
- Users can be payers, debtors, or payment recorders
|
||||
- User relationships are tracked in splits and settlements
|
||||
- User context is required for all financial operations
|
||||
|
||||
## Key Operations
|
||||
|
||||
### 1. Creating Expenses
|
||||
|
||||
1. Validate context (list/group)
|
||||
2. Create expense record
|
||||
3. Generate splits based on split type
|
||||
4. Validate total amounts match
|
||||
5. Save all records in transaction
|
||||
|
||||
### 2. Updating Expenses
|
||||
|
||||
- Limited to non-financial fields:
|
||||
- Description
|
||||
- Currency
|
||||
- Expense date
|
||||
- Uses optimistic locking via version field
|
||||
- Cannot modify splits after creation
|
||||
|
||||
### 3. Recording Payments
|
||||
|
||||
1. Create settlement activity
|
||||
2. Update split status
|
||||
3. Recalculate expense overall status
|
||||
4. All operations in single transaction
|
||||
|
||||
### 4. Deleting Expenses
|
||||
|
||||
- Requires version matching
|
||||
- Cascades to splits and settlements
|
||||
- All operations in single transaction
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Data Integrity**
|
||||
|
||||
- Always use transactions for multi-step operations
|
||||
- Validate totals match before saving
|
||||
- Use optimistic locking for updates
|
||||
|
||||
2. **Error Handling**
|
||||
|
||||
- Handle database errors appropriately
|
||||
- Validate user permissions
|
||||
- Check for concurrent modifications
|
||||
|
||||
3. **Performance**
|
||||
|
||||
- Use appropriate indexes
|
||||
- Load relationships efficiently
|
||||
- Batch operations when possible
|
||||
|
||||
4. **Security**
|
||||
- Validate user permissions
|
||||
- Sanitize input data
|
||||
- Use proper access controls
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **Group Dinner**
|
||||
|
||||
- Create expense with total amount
|
||||
- Use equal split or exact amounts
|
||||
- Record payments as they occur
|
||||
|
||||
2. **Shopping List**
|
||||
|
||||
- Create item-based expense
|
||||
- System automatically splits based on items
|
||||
- Track payments per person
|
||||
|
||||
3. **Rent Sharing**
|
||||
|
||||
- Create expense with total rent
|
||||
- Use percentage or share-based split
|
||||
- Record monthly payments
|
||||
|
||||
4. **Trip Expenses**
|
||||
- Create multiple expenses
|
||||
- Mix different split types
|
||||
- Track overall balances
|
||||
|
||||
## Recurring Expenses
|
||||
|
||||
Recurring expenses are expenses that repeat at regular intervals. They are useful for regular payments like rent, utilities, or subscription services.
|
||||
|
||||
### Recurrence Types
|
||||
|
||||
1. **Daily**
|
||||
|
||||
- Repeats every X days
|
||||
- Example: Daily parking fee
|
||||
|
||||
2. **Weekly**
|
||||
|
||||
- Repeats every X weeks on specific days
|
||||
- Example: Weekly cleaning service
|
||||
|
||||
3. **Monthly**
|
||||
|
||||
- Repeats every X months on the same date
|
||||
- Example: Monthly rent payment
|
||||
|
||||
4. **Yearly**
|
||||
- Repeats every X years on the same date
|
||||
- Example: Annual insurance premium
|
||||
|
||||
### Implementation Details
|
||||
|
||||
1. **Recurrence Pattern**
|
||||
|
||||
```typescript
|
||||
interface RecurrencePattern {
|
||||
type: "daily" | "weekly" | "monthly" | "yearly";
|
||||
interval: number; // Every X days/weeks/months/years
|
||||
daysOfWeek?: number[]; // For weekly recurrence (0-6, Sunday-Saturday)
|
||||
endDate?: string; // Optional end date for the recurrence
|
||||
maxOccurrences?: number; // Optional maximum number of occurrences
|
||||
}
|
||||
```
|
||||
|
||||
2. **Recurring Expense Properties**
|
||||
|
||||
- All standard expense properties
|
||||
- `recurrence_pattern`: Defines how the expense repeats
|
||||
- `next_occurrence`: When the next expense will be created
|
||||
- `last_occurrence`: When the last expense was created
|
||||
- `is_recurring`: Boolean flag to identify recurring expenses
|
||||
|
||||
3. **Generation Process**
|
||||
|
||||
- System automatically creates new expenses based on the pattern
|
||||
- Each generated expense is a regular expense with its own splits
|
||||
- Original recurring expense serves as a template
|
||||
- Generated expenses can be modified individually
|
||||
|
||||
4. **Management Features**
|
||||
- Pause/resume recurrence
|
||||
- Modify future occurrences
|
||||
- Skip specific occurrences
|
||||
- End recurrence early
|
||||
- View all generated expenses
|
||||
|
||||
### Best Practices for Recurring Expenses
|
||||
|
||||
1. **Data Management**
|
||||
|
||||
- Keep original recurring expense as template
|
||||
- Generate new expenses in advance
|
||||
- Clean up old generated expenses periodically
|
||||
|
||||
2. **User Experience**
|
||||
|
||||
- Clear indication of recurring expenses
|
||||
- Easy way to modify future occurrences
|
||||
- Option to handle exceptions
|
||||
|
||||
3. **Performance**
|
||||
- Batch process expense generation
|
||||
- Index recurring expense queries
|
||||
- Cache frequently accessed patterns
|
||||
|
||||
### Example Use Cases
|
||||
|
||||
1. **Monthly Rent**
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Monthly Rent",
|
||||
"total_amount": "2000.00",
|
||||
"split_type": "PERCENTAGE",
|
||||
"recurrence_pattern": {
|
||||
"type": "monthly",
|
||||
"interval": 1,
|
||||
"endDate": "2024-12-31"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Weekly Cleaning Service**
|
||||
```json
|
||||
{
|
||||
"description": "Weekly Cleaning",
|
||||
"total_amount": "150.00",
|
||||
"split_type": "EQUAL",
|
||||
"recurrence_pattern": {
|
||||
"type": "weekly",
|
||||
"interval": 1,
|
||||
"daysOfWeek": [1] // Every Monday
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Considerations
|
||||
|
||||
1. **Decimal Handling**
|
||||
|
||||
- Use string representation for decimals in API
|
||||
- Convert to Decimal for calculations
|
||||
- Round to 2 decimal places for money
|
||||
|
||||
2. **Date Handling**
|
||||
|
||||
- Use ISO format for dates
|
||||
- Store in UTC
|
||||
- Convert to local time for display
|
||||
|
||||
3. **Status Updates**
|
||||
- Update split status on payment
|
||||
- Recalculate overall status
|
||||
- Notify relevant users
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Potential Enhancements**
|
||||
|
||||
- Recurring expenses
|
||||
- Bulk operations
|
||||
- Advanced reporting
|
||||
- Currency conversion
|
||||
|
||||
2. **Scalability**
|
||||
|
||||
- Handle large groups
|
||||
- Optimize for frequent updates
|
||||
- Consider caching strategies
|
||||
|
||||
3. **Integration**
|
||||
- Payment providers
|
||||
- Accounting systems
|
||||
- Export capabilities
|
270
docs/financial-system-overview.md
Normal file
270
docs/financial-system-overview.md
Normal file
@ -0,0 +1,270 @@
|
||||
# Financial System Overview
|
||||
|
||||
## Introduction
|
||||
|
||||
This document provides a comprehensive overview of the **Expense, Cost & Financial** domain of the project. It is intended for backend / frontend developers, QA engineers and DevOps personnel who need to understand **how money flows through the system**, what invariants are enforced and where to extend the platform.
|
||||
|
||||
> **TL;DR** The financial subsystem is a *Split-wise*-inspired engine with first-class support for: shared lists, item-derived expenses, multi-scheme splitting, settlements, recurring charges and complete auditability.
|
||||
|
||||
---
|
||||
|
||||
## Main Concepts & Entities
|
||||
|
||||
| Entity | Purpose | Key Relationships |
|
||||
|--------|---------|-------------------|
|
||||
| **User** | A registered account. Owns expenses, owes splits, records settlements. | `expenses_paid`, `expenses_created`, `expense_splits`, `settlements_made/received`, `settlements_created` |
|
||||
| **Group** | A collection of users with shared expenses / lists. | `member_associations (UserGroup)`, `lists`, `expenses`, `settlements` |
|
||||
| **List** | A shopping / to-do list. May belong to a `Group` or be personal. | `items`, `expenses` |
|
||||
| **Item** | A purchasable line inside a `List`. Price & author drive *item-based* expense splits. | `list`, `added_by_user` |
|
||||
| **Expense** | A monetary outflow. | `splits`, `list`, `group`, `item`, `recurrence_pattern` |
|
||||
| **ExpenseSplit** | A *who-owes-what* record for an `Expense`. | `user`, `settlement_activities` |
|
||||
| **Settlement** | A generic *cash transfer* between two users inside a group. | – |
|
||||
| **SettlementActivity** | A *payment* that reduces an individual `ExpenseSplit` (e.g. Alice pays Bob her part). | – |
|
||||
| **RecurrencePattern** | The schedule template that spawns future `Expense` occurrences. | `expenses` |
|
||||
| **FinancialAuditLog** | Append-only journal of *who did what* (create / update / delete) for all financial entities. | – |
|
||||
|
||||
---
|
||||
|
||||
## Expense Lifecyle
|
||||
|
||||
1. **Creation** (`POST /financials/expenses`)
|
||||
• Caller provides an `ExpenseCreate` DTO.<br />
|
||||
• Backend validates the *context* (list / group / item), the *payer*, and the chosen **split strategy**.
|
||||
• Supported `split_type` values (`SplitTypeEnum`):
|
||||
* `EQUAL` – evenly divided among computed participants.
|
||||
* `EXACT_AMOUNTS` – caller supplies absolute owed amounts.
|
||||
* `PERCENTAGE` – caller supplies percentages totalling 100 %.
|
||||
* `SHARES` – integer share units (e.g. 1 : 2 : 3).
|
||||
* `ITEM_BASED` – derived from priced `Item`s in a list.
|
||||
• A database transaction writes `Expense` + `ExpenseSplit` rows and a `FinancialAuditLog` entry.
|
||||
|
||||
2. **Reading**
|
||||
• `GET /financials/expenses/{id}` enforces *row-level* security: the requester must be payer, list member or group member.
|
||||
|
||||
3. **Update / Delete**
|
||||
• Optimistic-locking via the `version` field.
|
||||
• Only the **payer** or a **group owner** may mutate records.
|
||||
|
||||
4. **Settlement**
|
||||
• Generic settlements (`/financials/settlements`) clear balances between two users.<br />
|
||||
• Fine-grained settlements (`/financials/expense_splits/{id}/settle`) clear a single `ExpenseSplit`.
|
||||
|
||||
5. **Recurring Expenses**
|
||||
• An `Expense` can be flagged `is_recurring = true` and carry a `RecurrencePattern`.<br />
|
||||
• A background job (`app/jobs/recurring_expenses.py::generate_recurring_expenses`) wakes up daily and:
|
||||
1. Finds template expenses due (`next_occurrence <= now`).
|
||||
2. Spawns a *child* expense replicating the split logic.
|
||||
3. Updates `last_occurrence`, decrements `max_occurrences` and calculates the next date.
|
||||
|
||||
---
|
||||
|
||||
## Cost Summaries & Balance Sheets
|
||||
|
||||
### List Cost Summary
|
||||
Endpoint: `GET /costs/lists/{list_id}/cost-summary`
|
||||
|
||||
• If an `ITEM_BASED` expense already exists → returns a snapshot derived from the **expense**.<br />
|
||||
• Otherwise → computes an *on-the-fly* summary using `Item.price` values (read-only).
|
||||
|
||||
Key Figures:
|
||||
* `total_list_cost` – sum of item prices.
|
||||
* `equal_share_per_user` – what each participant *should* pay.
|
||||
* `balance` – (over / under) contribution for each user.
|
||||
|
||||
*Action:* `POST /costs/lists/{id}/cost-summary` finalises the list by persisting the derived `ITEM_BASED` expense.
|
||||
|
||||
### Group Balance Summary
|
||||
Endpoint: `GET /costs/groups/{group_id}/balance-summary`
|
||||
|
||||
Aggregates across **all** group expenses + settlements:
|
||||
* What each user paid (expenses + received settlements)
|
||||
* What each user owed
|
||||
* Suggested minimal settlement graph (creditors vs debtors)
|
||||
|
||||
---
|
||||
|
||||
## Data-Integrity Rules & Guards
|
||||
|
||||
1. `Expense` **must** reference *either* `list_id` **or** `group_id` (DB-level CHECK).
|
||||
2. Row uniqueness guards: `UniqueConstraint('expense_id', 'user_id')` on `ExpenseSplit`.
|
||||
3. `Settlement` payer ≠ payee (DB CHECK).
|
||||
4. All mutating endpoints perform **authorization** checks via `crud_group` / `crud_list` helpers.
|
||||
5. Monetary amounts are stored as `Numeric(10,2)` and rounded (`ROUND_HALF_UP`).
|
||||
|
||||
---
|
||||
|
||||
## Recent Fixes & Improvements (June 2025)
|
||||
|
||||
| Area | Issue | Resolution |
|
||||
|------|-------|------------|
|
||||
| **Recurring filter** | `GET /financials/expenses?isRecurring=true` referenced nonexistent `recurrence_rule`. | Switched to `is_recurring` flag. |
|
||||
| **Enum mismatches** | `RecurrencePattern.type` stored uppercase enum, while API took lowercase strings. | Robust mapper converts strings → `RecurrenceTypeEnum`; scheduler is now case-insensitive. |
|
||||
| **Scheduler** | `_calculate_next_occurrence` failed with Enum values & stringified `days_of_week`. | Added polymorphic handling + safe parsing of comma-separated strings. |
|
||||
|
||||
*All tests pass (`pytest -q`) and new unit tests cover the edge-cases above.*
|
||||
|
||||
---
|
||||
|
||||
## Extension Points
|
||||
|
||||
* **VAT / Tax logic** – attach a `tax_rate` column to `Expense` and resolve net vs gross amounts.
|
||||
* **Multi-currency** – normalize amounts to a base currency using FX rates; expose a `Currency` table.
|
||||
* **Budgeting / Limits** – per-group or per-list spending caps with alerting.
|
||||
* **Webhook notifications** – publish `FinancialAuditLog` entries to external services.
|
||||
|
||||
---
|
||||
|
||||
## Appendix – Key SQL Schema Snapshots
|
||||
|
||||
```sql
|
||||
-- Expense table (excerpt)
|
||||
CREATE TABLE expenses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
total_amount NUMERIC(10,2) NOT NULL,
|
||||
split_type VARCHAR(20) NOT NULL,
|
||||
list_id INT NULL,
|
||||
group_id INT NULL,
|
||||
-- …
|
||||
CHECK (group_id IS NOT NULL OR list_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_expense_user_split ON expense_splits(expense_id, user_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Reliability Analysis & Improvements
|
||||
|
||||
### ✅ Implemented Reliability Features
|
||||
|
||||
#### 1. **Transaction Safety**
|
||||
- All financial operations use **transactional sessions** (`get_transactional_session`)
|
||||
- Atomic operations ensure data consistency across expense creation, splits, and settlements
|
||||
- Row-level locking (`WITH FOR UPDATE`) prevents race conditions in settlement activities
|
||||
|
||||
#### 2. **Overpayment Protection**
|
||||
- **NEW**: Settlement activities now validate against remaining owed amount
|
||||
- Prevents payments that exceed the split's owed amount
|
||||
- Provides clear error messages with remaining balance information
|
||||
- Handles multiple partial payments correctly
|
||||
|
||||
#### 3. **Data Validation & Constraints**
|
||||
- **Decimal precision**: All monetary amounts use `Numeric(10,2)` with proper rounding
|
||||
- **Positive amount validation**: Prevents negative payments and settlements
|
||||
- **User existence validation**: Ensures all referenced users exist before operations
|
||||
- **Split consistency**: Validates split totals match expense amounts (EXACT_AMOUNTS, PERCENTAGE)
|
||||
|
||||
#### 4. **Permission & Authorization**
|
||||
- Multi-layered permission checks for expense creation and settlement recording
|
||||
- Group owners can act on behalf of members with proper validation
|
||||
- List/group access controls prevent unauthorized financial operations
|
||||
|
||||
#### 5. **Status Management**
|
||||
- Automatic status updates for expense splits (unpaid → partially_paid → paid)
|
||||
- Cascading status updates for parent expenses based on split states
|
||||
- Pessimistic locking ensures consistent status transitions
|
||||
|
||||
#### 6. **Audit Trail**
|
||||
- All financial operations logged via `create_financial_audit_log`
|
||||
- Complete traceability of who created/modified financial records
|
||||
- Immutable settlement activity records (no updates, only creation)
|
||||
|
||||
#### 7. **Error Handling**
|
||||
- Comprehensive exception hierarchy for different error types
|
||||
- Specific `OverpaymentError` for payment validation failures
|
||||
- Database integrity and connection error handling
|
||||
- Graceful degradation with meaningful error messages
|
||||
|
||||
### 🔍 Potential Areas for Enhancement
|
||||
|
||||
#### 1. **Optimistic Locking for Expenses**
|
||||
Currently, expenses use basic versioning but could benefit from full optimistic locking:
|
||||
```python
|
||||
# Consider adding to ExpenseUpdate operations
|
||||
if expected_version != current_expense.version:
|
||||
raise ConflictError("Expense was modified by another user")
|
||||
```
|
||||
|
||||
#### 2. **Recurring Expense Reliability**
|
||||
- Add retry logic for failed recurring expense generation
|
||||
- Implement dead letter queue for failed recurring operations
|
||||
- Add monitoring for recurring expense job health
|
||||
|
||||
#### 3. **Currency Consistency**
|
||||
While currency is stored, there's no validation that all splits in an expense use the same currency:
|
||||
```python
|
||||
# Potential enhancement
|
||||
if expense.currency != "USD" and any(split.currency != expense.currency for split in splits):
|
||||
raise InvalidOperationError("All splits must use the same currency as the parent expense")
|
||||
```
|
||||
|
||||
#### 4. **Settlement Verification**
|
||||
Consider adding verification flags for settlements to distinguish between:
|
||||
- Automatic settlements (from expense splits)
|
||||
- Manual settlements (direct user payments)
|
||||
- Disputed settlements requiring verification
|
||||
|
||||
### 📊 System Health Metrics
|
||||
|
||||
The system provides comprehensive financial tracking through:
|
||||
|
||||
1. **Real-time balance calculations** via `costs_service.py`
|
||||
2. **Settlement suggestions** using optimal debt reduction algorithms
|
||||
3. **Expense categorization and filtering** with proper indexing
|
||||
4. **Multi-context support** (lists, groups, personal expenses)
|
||||
|
||||
### 🛡️ Security Considerations
|
||||
|
||||
- **Input sanitization**: All financial inputs validated through Pydantic schemas
|
||||
- **Authorization layers**: Multiple permission checks prevent unauthorized access
|
||||
- **Audit logging**: Complete financial operation history for compliance
|
||||
- **Data isolation**: Users only see expenses/settlements they have permission to access
|
||||
|
||||
### 🚀 Performance Optimizations
|
||||
|
||||
- **Database indexing** on critical foreign keys and search fields
|
||||
- **Eager loading** with `selectinload` to prevent N+1 queries
|
||||
- **Pagination** for large result sets
|
||||
- **Connection pooling** with health checks (`pool_pre_ping=True`)
|
||||
|
||||
---
|
||||
|
||||
## Testing & Quality Assurance
|
||||
|
||||
The financial system includes comprehensive test coverage:
|
||||
|
||||
1. **Unit tests** for CRUD operations and business logic
|
||||
2. **Integration tests** for API endpoints and workflows
|
||||
3. **Edge case testing** for overpayment protection and boundary conditions
|
||||
4. **Concurrency tests** for settlement race conditions
|
||||
5. **Data consistency tests** for split calculations and status updates
|
||||
|
||||
Example test scenarios:
|
||||
- Multiple users settling the same split simultaneously
|
||||
- Expense split totals validation across different split types
|
||||
- Currency precision and rounding accuracy
|
||||
- Permission boundary testing for cross-user operations
|
||||
|
||||
---
|
||||
|
||||
## Deployment & Monitoring Recommendations
|
||||
|
||||
### Database Considerations
|
||||
- Regular backup strategy for financial data
|
||||
- Monitor transaction isolation levels and deadlocks
|
||||
- Set up alerts for unusual financial activity patterns
|
||||
- Implement database connection monitoring
|
||||
|
||||
### Application Monitoring
|
||||
- Track settlement activity creation rates and failures
|
||||
- Monitor recurring expense job execution and errors
|
||||
- Set up alerts for permission denial patterns
|
||||
- Track API response times for financial endpoints
|
||||
|
||||
### Business Intelligence
|
||||
- Daily/weekly financial summaries per group
|
||||
- Settlement velocity tracking (time to pay debts)
|
||||
- Expense categorization analytics
|
||||
- User engagement with financial features
|
||||
|
||||
The financial system is now **production-ready** with robust reliability safeguards, comprehensive error handling, and strong data consistency guarantees.
|
@ -15,6 +15,8 @@
|
||||
class="badge badge-overdue">Overdue</span>
|
||||
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
|
||||
Today</span>
|
||||
<span v-if="getDueDateStatus(chore) === 'upcoming'" class="badge badge-upcoming">{{
|
||||
dueInText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
|
||||
@ -25,8 +27,9 @@
|
||||
</div>
|
||||
</label>
|
||||
<div class="neo-item-actions">
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed">
|
||||
{{ isActiveTimer ? 'Stop' : 'Start' }}
|
||||
<button class="btn btn-sm btn-neutral" @click="toggleTimer"
|
||||
:disabled="chore.is_completed || !chore.current_assignment_id">
|
||||
{{ isActiveTimer ? '⏸️' : '▶️' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
|
||||
📋
|
||||
@ -35,10 +38,10 @@
|
||||
📅
|
||||
</button>
|
||||
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
|
||||
Edit
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
|
||||
Delete
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -55,6 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits, computed } from 'vue';
|
||||
import { formatDistanceToNow, parseISO, isToday, isPast } from 'date-fns';
|
||||
import type { ChoreWithCompletion } from '../types/chore';
|
||||
import type { TimeEntry } from '../stores/timeEntryStore';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
@ -83,6 +87,13 @@ const totalTime = computed(() => {
|
||||
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0);
|
||||
});
|
||||
|
||||
const dueInText = computed(() => {
|
||||
if (!props.chore.next_due_date) return '';
|
||||
const dueDate = new Date(props.chore.next_due_date);
|
||||
if (isToday(dueDate)) return 'Today';
|
||||
return formatDistanceToNow(dueDate, { addSuffix: true });
|
||||
});
|
||||
|
||||
const toggleTimer = () => {
|
||||
if (isActiveTimer.value) {
|
||||
emit('stop-timer', props.chore, props.activeTimer!.id);
|
||||
@ -326,6 +337,11 @@ export default {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-upcoming {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--dark);
|
||||
|
@ -131,11 +131,29 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
|
||||
CHORES: {
|
||||
// Generic
|
||||
ALL: '/chores/all',
|
||||
BASE: '/chores',
|
||||
BY_ID: (id: number) => `/chores/${id}`,
|
||||
UPDATE_ANY_TYPE: (id: number) => `/chores/${id}`,
|
||||
HISTORY: (id: number) => `/chores/${id}/history`,
|
||||
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
|
||||
|
||||
// Personal chore shortcuts
|
||||
PERSONAL: '/chores/personal',
|
||||
PERSONAL_BY_ID: (id: number) => `/chores/personal/${id}`,
|
||||
|
||||
// Group chore shortcuts
|
||||
GROUP_CHORES: (groupId: number) => `/chores/groups/${groupId}/chores`,
|
||||
GROUP_CHORE_BY_ID: (groupId: number, choreId: number) => `/chores/groups/${groupId}/chores/${choreId}`,
|
||||
|
||||
// Assignment centric paths
|
||||
ASSIGNMENTS_BASE: '/chores/assignments',
|
||||
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
|
||||
MY_ASSIGNMENTS: (includeCompleted: boolean) => `/chores/assignments/my?include_completed=${includeCompleted}`,
|
||||
ASSIGNMENT_COMPLETE: (id: number) => `/chores/assignments/${id}/complete`,
|
||||
|
||||
// Time tracking
|
||||
TIME_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
|
||||
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
|
||||
},
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
|
||||
import { choreService } from '../services/choreService'
|
||||
@ -11,6 +11,7 @@ import ChoreItem from '@/components/ChoreItem.vue';
|
||||
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { UserPublic } from '@/types/user';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -28,6 +29,7 @@ interface ChoreFormData {
|
||||
type: 'personal' | 'group';
|
||||
group_id: number | undefined;
|
||||
parent_chore_id?: number | null;
|
||||
assigned_to_user_id?: number | null;
|
||||
}
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
@ -45,6 +47,10 @@ const selectedChoreHistory = ref<ChoreHistory[]>([])
|
||||
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
|
||||
const loadingHistory = ref(false)
|
||||
const loadingAssignments = ref(false)
|
||||
const groupMembers = ref<UserPublic[]>([])
|
||||
const loadingMembers = ref(false)
|
||||
const choreFormGroupMembers = ref<UserPublic[]>([])
|
||||
const loadingChoreFormMembers = ref(false)
|
||||
|
||||
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
|
||||
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
|
||||
@ -59,13 +65,14 @@ const initialChoreFormState: ChoreFormData = {
|
||||
type: 'personal',
|
||||
group_id: undefined,
|
||||
parent_chore_id: null,
|
||||
assigned_to_user_id: null,
|
||||
}
|
||||
|
||||
const choreForm = ref({ ...initialChoreFormState })
|
||||
const isLoading = ref(true)
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { isGuest } = storeToRefs(authStore);
|
||||
const { isGuest, user } = storeToRefs(authStore);
|
||||
|
||||
const timeEntryStore = useTimeEntryStore();
|
||||
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
|
||||
@ -89,17 +96,28 @@ const loadChores = async () => {
|
||||
|
||||
try {
|
||||
const fetchedChores = await choreService.getAllChores()
|
||||
const currentUserId = user.value?.id ? Number(user.value.id) : null;
|
||||
|
||||
const mappedChores = fetchedChores.map(c => {
|
||||
const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null;
|
||||
// Prefer the assignment that belongs to the current user, otherwise fallback to the first assignment
|
||||
const userAssignment = c.assignments?.find(a => a.assigned_to_user_id === currentUserId) ?? null;
|
||||
const displayAssignment = userAssignment ?? (c.assignments?.[0] ?? null);
|
||||
|
||||
return {
|
||||
...c,
|
||||
current_assignment_id: currentAssignment?.id ?? null,
|
||||
is_completed: currentAssignment?.is_complete ?? false,
|
||||
completed_at: currentAssignment?.completed_at ?? null,
|
||||
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
|
||||
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
|
||||
current_assignment_id: userAssignment?.id ?? null,
|
||||
is_completed: userAssignment?.is_complete ?? false,
|
||||
completed_at: userAssignment?.completed_at ?? null,
|
||||
assigned_user_name:
|
||||
displayAssignment?.assigned_user?.name ||
|
||||
displayAssignment?.assigned_user?.email ||
|
||||
'Unknown',
|
||||
completed_by_name:
|
||||
displayAssignment?.assigned_user?.name ||
|
||||
displayAssignment?.assigned_user?.email ||
|
||||
'Unknown',
|
||||
updating: false,
|
||||
}
|
||||
} as ChoreWithCompletion;
|
||||
});
|
||||
chores.value = mappedChores;
|
||||
cachedChores.value = mappedChores;
|
||||
@ -129,6 +147,37 @@ const loadTimeEntries = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Watch for type changes to clear group_id when switching to personal
|
||||
watch(() => choreForm.value.type, (newType) => {
|
||||
if (newType === 'personal') {
|
||||
choreForm.value.group_id = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch group members when a group is selected in the form
|
||||
watch(() => choreForm.value.group_id, async (newGroupId) => {
|
||||
if (newGroupId && choreForm.value.type === 'group') {
|
||||
loadingChoreFormMembers.value = true;
|
||||
try {
|
||||
choreFormGroupMembers.value = await groupService.getGroupMembers(newGroupId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members for form:', error);
|
||||
choreFormGroupMembers.value = [];
|
||||
} finally {
|
||||
loadingChoreFormMembers.value = false;
|
||||
}
|
||||
} else {
|
||||
choreFormGroupMembers.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Reload chores once the user information becomes available (e.g., after login refresh)
|
||||
watch(user, (newUser, oldUser) => {
|
||||
if (newUser && !oldUser) {
|
||||
loadChores().then(loadTimeEntries);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadChores().then(loadTimeEntries);
|
||||
loadGroups()
|
||||
@ -293,6 +342,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
|
||||
type: chore.type,
|
||||
group_id: chore.group_id ?? undefined,
|
||||
parent_chore_id: chore.parent_chore_id,
|
||||
assigned_to_user_id: chore.assigned_to_user_id,
|
||||
}
|
||||
showChoreModal.value = true
|
||||
}
|
||||
@ -302,10 +352,18 @@ const handleFormSubmit = async () => {
|
||||
let createdChore;
|
||||
if (isEditing.value && selectedChore.value) {
|
||||
const updateData: ChoreUpdate = { ...choreForm.value };
|
||||
createdChore = await choreService.updateChore(selectedChore.value.id, updateData);
|
||||
// Ensure group_id is properly set based on type
|
||||
if (updateData.type === 'personal') {
|
||||
updateData.group_id = undefined;
|
||||
}
|
||||
createdChore = await choreService.updateChore(selectedChore.value.id, updateData, selectedChore.value);
|
||||
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
|
||||
} else {
|
||||
const createData = { ...choreForm.value };
|
||||
// Ensure group_id is properly set based on type
|
||||
if (createData.type === 'personal') {
|
||||
createData.group_id = undefined;
|
||||
}
|
||||
createdChore = await choreService.createChore(createData as ChoreCreate);
|
||||
|
||||
// Create an assignment for the new chore
|
||||
@ -400,6 +458,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
||||
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
|
||||
selectedChore.value = chore;
|
||||
showChoreDetailModal.value = true;
|
||||
groupMembers.value = []; // Reset
|
||||
|
||||
// Load assignments for this chore
|
||||
loadingAssignments.value = true;
|
||||
@ -414,6 +473,22 @@ const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
|
||||
} finally {
|
||||
loadingAssignments.value = false;
|
||||
}
|
||||
|
||||
// If it's a group chore, load members
|
||||
if (chore.type === 'group' && chore.group_id) {
|
||||
loadingMembers.value = true;
|
||||
try {
|
||||
groupMembers.value = await groupService.getGroupMembers(chore.group_id);
|
||||
} catch (error) {
|
||||
console.error('Failed to load group members:', error);
|
||||
notificationStore.addNotification({
|
||||
message: 'Failed to load group members.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
loadingMembers.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openHistoryModal = async (chore: ChoreWithCompletion) => {
|
||||
@ -479,6 +554,46 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignChore = async (userId: number) => {
|
||||
if (!selectedChore.value) return;
|
||||
try {
|
||||
await choreService.createAssignment({
|
||||
chore_id: selectedChore.value.id,
|
||||
assigned_to_user_id: userId,
|
||||
due_date: selectedChore.value.next_due_date
|
||||
});
|
||||
notificationStore.addNotification({ message: 'Chore assigned successfully!', type: 'success' });
|
||||
// Refresh assignments
|
||||
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
|
||||
await loadChores(); // Also reload all chores to update main list
|
||||
} catch (error) {
|
||||
console.error('Failed to assign chore:', error);
|
||||
notificationStore.addNotification({ message: 'Failed to assign chore.', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnassignChore = async (assignmentId: number) => {
|
||||
if (!selectedChore.value) return;
|
||||
try {
|
||||
await choreService.deleteAssignment(assignmentId);
|
||||
notificationStore.addNotification({ message: 'Chore unassigned successfully!', type: 'success' });
|
||||
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
|
||||
await loadChores();
|
||||
} catch (error) {
|
||||
console.error('Failed to unassign chore:', error);
|
||||
notificationStore.addNotification({ message: 'Failed to unassign chore.', type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const isUserAssigned = (userId: number) => {
|
||||
return selectedChoreAssignments.value.some(a => a.assigned_to_user_id === userId);
|
||||
};
|
||||
|
||||
const getAssignmentIdForUser = (userId: number): number | null => {
|
||||
const assignment = selectedChoreAssignments.value.find(a => a.assigned_to_user_id === userId);
|
||||
return assignment ? assignment.id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -590,6 +705,17 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="choreForm.type === 'group' && choreForm.group_id" class="form-group">
|
||||
<label class="form-label" for="chore-assign-user">{{ t('choresPage.form.assignTo', 'Assign To (Optional)')
|
||||
}}</label>
|
||||
<div v-if="loadingChoreFormMembers">Loading members...</div>
|
||||
<select v-else id="chore-assign-user" v-model="choreForm.assigned_to_user_id" class="form-input">
|
||||
<option :value="null">Don't assign now</option>
|
||||
<option v-for="member in choreFormGroupMembers" :key="member.id" :value="member.id">
|
||||
{{ member.name || member.email }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
|
||||
}}</label>
|
||||
@ -700,6 +826,45 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Chore Section -->
|
||||
<div class="detail-section" v-if="selectedChore.type === 'group' && selectedChore.group_id">
|
||||
<h4>Assign to Member</h4>
|
||||
<div v-if="loadingMembers" class="loading-spinner">Loading members...</div>
|
||||
<div v-else-if="groupMembers.length === 0" class="no-data">No other members in this group.</div>
|
||||
<div v-else class="member-list">
|
||||
<div v-for="member in groupMembers" :key="member.id" class="member-item">
|
||||
<div class="member-info">
|
||||
<span class="member-name">{{ member.name || member.email }}</span>
|
||||
<span v-if="member.id === user?.id" class="badge badge-you">You</span>
|
||||
</div>
|
||||
<button v-if="isUserAssigned(member.id)" class="btn btn-sm btn-danger"
|
||||
@click="handleUnassignChore(getAssignmentIdForUser(member.id)!)">
|
||||
Unassign
|
||||
</button>
|
||||
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(member.id)">
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="selectedChore.type === 'personal' && user?.id">
|
||||
<h4>Assign to Me</h4>
|
||||
<div class="member-list">
|
||||
<div class="member-item">
|
||||
<span class="member-name">{{ user.name || user.email }}</span>
|
||||
<button v-if="isUserAssigned(Number(user.id))" class="btn btn-sm btn-danger"
|
||||
@click="handleUnassignChore(getAssignmentIdForUser(Number(user.id))!)">
|
||||
Unassign
|
||||
</button>
|
||||
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(Number(user.id))">
|
||||
Assign to Me
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
|
||||
@ -1030,6 +1195,12 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-you {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.chore-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--dark);
|
||||
@ -1187,4 +1358,38 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.assignments-list,
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.assignment-item,
|
||||
.member-item {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f9fafb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignment-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -7,6 +7,14 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="mb-4 flex items-center gap-2 justify-between" v-if="!loading && !error">
|
||||
<div class="text-sm font-medium" v-if="authStore.getUser">
|
||||
Your outstanding balance: <span class="font-mono">{{ formatCurrency(userOutstanding, 'USD') }}</span>
|
||||
</div>
|
||||
<label class="flex items-center text-sm"><input type="checkbox" v-model="showRecurringOnly"
|
||||
class="mr-2">Show recurring only</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center">
|
||||
<div class="spinner-dots">
|
||||
<span></span>
|
||||
@ -38,6 +46,12 @@
|
||||
<div class="neo-item-content">
|
||||
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
|
||||
<span class="checkbox-text-span">{{ expense.description }}</span>
|
||||
<span v-if="isExpenseRecurring(expense)"
|
||||
class="ml-2 inline-block rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-xs font-semibold">Recurring
|
||||
<template v-if="getNextOccurrence(expense)">
|
||||
– next {{ getNextOccurrence(expense) }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="item-subtext">
|
||||
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
|
||||
'N/A'
|
||||
@ -64,11 +78,27 @@
|
||||
expense.split_type.replace('_', ' ') }})</h3>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="split in expense.splits" :key="split.id"
|
||||
class="flex justify-between items-center py-1 text-sm">
|
||||
class="flex justify-between items-center py-1 text-sm gap-2 flex-wrap">
|
||||
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|
||||
|| 'N/A' }} owes</span>
|
||||
<span class="font-mono text-gray-800 font-semibold">{{
|
||||
formatCurrency(split.owed_amount, expense.currency) }}</span>
|
||||
|
||||
<!-- Settlement progress -->
|
||||
<span
|
||||
v-if="split.settlement_activities && split.settlement_activities.length"
|
||||
class="text-xs text-gray-500">Paid: {{
|
||||
formatCurrency(calculatePaidAmount(split), expense.currency) }}</span>
|
||||
<span v-if="calculateRemainingAmount(split) > 0"
|
||||
class="text-xs text-red-600">Remaining: {{
|
||||
formatCurrency(calculateRemainingAmount(split), expense.currency)
|
||||
}}</span>
|
||||
|
||||
<!-- Settle button for current user -->
|
||||
<button
|
||||
v-if="authStore.getUser && authStore.getUser.id === split.user_id && calculateRemainingAmount(split) > 0"
|
||||
@click.stop="handleSettleSplit(split, expense)"
|
||||
class="ml-auto btn btn-sm btn-primary">Settle</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -175,60 +205,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
|
||||
|
||||
const props = defineProps<{
|
||||
groupId?: number | string;
|
||||
}>();
|
||||
|
||||
// Types are kept local to this component
|
||||
interface UserPublic {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
interface ExpenseSplit {
|
||||
id: number;
|
||||
expense_id: number;
|
||||
user_id: number;
|
||||
owed_amount: string; // Decimal is string
|
||||
share_percentage?: string;
|
||||
share_units?: number;
|
||||
user?: UserPublic;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 'unpaid' | 'paid' | 'partially_paid';
|
||||
paid_at?: string;
|
||||
}
|
||||
|
||||
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
|
||||
|
||||
interface Expense {
|
||||
id: number;
|
||||
description: string;
|
||||
total_amount: string; // Decimal is string
|
||||
currency: string;
|
||||
expense_date?: string;
|
||||
split_type: SplitType;
|
||||
list_id?: number;
|
||||
group_id?: number;
|
||||
item_id?: number;
|
||||
paid_by_user_id: number;
|
||||
is_recurring: boolean;
|
||||
recurrence_pattern?: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: number;
|
||||
created_by_user_id: number;
|
||||
splits: ExpenseSplit[];
|
||||
paid_by_user?: UserPublic;
|
||||
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
|
||||
next_occurrence?: string;
|
||||
last_occurrence?: string;
|
||||
parent_expense_id?: number;
|
||||
generated_expenses: Expense[];
|
||||
}
|
||||
// Pinia store for current user context
|
||||
const authStore = useAuthStore()
|
||||
const notifStore = useNotificationStore()
|
||||
|
||||
// Reactive state collections
|
||||
const expenses = ref<Expense[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
@ -237,6 +227,24 @@ const showModal = ref(false)
|
||||
const editingExpense = ref<Expense | null>(null)
|
||||
const formError = ref<string | null>(null)
|
||||
|
||||
// UI-level filters
|
||||
const showRecurringOnly = ref(false)
|
||||
|
||||
// Aggregate outstanding balance for current user across expenses
|
||||
const userOutstanding = computed(() => {
|
||||
const userId = authStore.getUser?.id
|
||||
if (!userId) return 0
|
||||
let remaining = 0
|
||||
expenses.value.forEach((exp) => {
|
||||
exp.splits.forEach((sp) => {
|
||||
if (sp.user_id === userId) {
|
||||
remaining += calculateRemainingAmount(sp)
|
||||
}
|
||||
})
|
||||
})
|
||||
return remaining
|
||||
})
|
||||
|
||||
const initialFormState: CreateExpenseData = {
|
||||
description: '',
|
||||
total_amount: '',
|
||||
@ -252,12 +260,16 @@ const initialFormState: CreateExpenseData = {
|
||||
const formState = reactive<any>({ ...initialFormState })
|
||||
|
||||
const filteredExpenses = computed(() => {
|
||||
let data = expenses.value
|
||||
if (props.groupId) {
|
||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
|
||||
return expenses.value.filter(expense => expense.group_id === groupIdNum);
|
||||
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
|
||||
data = data.filter((expense) => expense.group_id === groupIdNum)
|
||||
}
|
||||
return expenses.value;
|
||||
});
|
||||
if (showRecurringOnly.value) {
|
||||
data = data.filter((expense) => (expense as any).isRecurring || (expense as any).is_recurring)
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@ -333,6 +345,15 @@ const getStatusClass = (status: string) => {
|
||||
return statusMap[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
const getNextOccurrence = (expense: Expense): string | null => {
|
||||
const raw = (expense as any).next_occurrence ?? (expense as any).nextOccurrence ?? null
|
||||
return raw ? formatDate(raw) : null
|
||||
}
|
||||
|
||||
const isExpenseRecurring = (expense: Expense): boolean => {
|
||||
return Boolean((expense as any).isRecurring ?? (expense as any).is_recurring)
|
||||
}
|
||||
|
||||
const openCreateExpenseModal = () => {
|
||||
editingExpense.value = null
|
||||
Object.assign(formState, initialFormState)
|
||||
@ -351,7 +372,7 @@ const openEditExpenseModal = (expense: Expense) => {
|
||||
formState.total_amount = expense.total_amount
|
||||
formState.currency = expense.currency
|
||||
formState.split_type = expense.split_type
|
||||
formState.isRecurring = expense.is_recurring
|
||||
formState.isRecurring = (expense as any).is_recurring ?? (expense as any).isRecurring ?? false
|
||||
formState.list_id = expense.list_id
|
||||
formState.group_id = expense.group_id
|
||||
formState.item_id = expense.item_id
|
||||
@ -385,13 +406,15 @@ const handleFormSubmit = async () => {
|
||||
if (index !== -1) {
|
||||
expenses.value[index] = updatedExpense
|
||||
}
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
|
||||
} else {
|
||||
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
|
||||
expenses.value.unshift(newExpense)
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
notifStore.addNotification({ message: 'Expense created', type: 'success' })
|
||||
}
|
||||
closeModal()
|
||||
// re-fetch all expenses to ensure data consistency after create/update
|
||||
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
|
||||
} catch (err: any) {
|
||||
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
|
||||
console.error(err)
|
||||
@ -404,11 +427,46 @@ const handleDeleteExpense = async (expenseId: number) => {
|
||||
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
|
||||
await expenseService.deleteExpense(expenseId)
|
||||
expenses.value = expenses.value.filter(e => e.id !== expenseId)
|
||||
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to delete expense.'
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Settlement-related helpers
|
||||
// -----------------------------
|
||||
const calculatePaidAmount = (split: ExpenseSplit): number =>
|
||||
(split.settlement_activities || []).reduce((sum: number, act: SettlementActivity) => sum + parseFloat(act.amount_paid), 0)
|
||||
|
||||
const calculateRemainingAmount = (split: ExpenseSplit): number =>
|
||||
parseFloat(split.owed_amount) - calculatePaidAmount(split)
|
||||
|
||||
const handleSettleSplit = async (split: ExpenseSplit, parentExpense: Expense) => {
|
||||
if (!authStore.getUser?.id) {
|
||||
alert('You need to be logged in to settle an expense.')
|
||||
return
|
||||
}
|
||||
const remaining = calculateRemainingAmount(split)
|
||||
if (remaining <= 0) return
|
||||
|
||||
if (!confirm(`Settle ${formatCurrency(remaining, parentExpense.currency)} now?`)) return
|
||||
|
||||
try {
|
||||
await apiClient.post(`/financials/expense_splits/${split.id}/settle`, {
|
||||
expense_split_id: split.id,
|
||||
paid_by_user_id: authStore.getUser.id,
|
||||
amount_paid: remaining.toFixed(2),
|
||||
})
|
||||
|
||||
// refresh expense list to get updated data
|
||||
expenses.value = (await expenseService.getExpenses()) as Expense[]
|
||||
} catch (err: any) {
|
||||
console.error('Failed to settle split', err)
|
||||
alert(err.response?.data?.detail || 'Failed to settle split.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -690,6 +748,11 @@ select.form-input {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.spinner-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -758,4 +821,9 @@ select.form-input {
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-recurring {
|
||||
background-color: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
</style>
|
||||
|
@ -259,21 +259,24 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
|
||||
};
|
||||
|
||||
const fetchListDetails = async () => {
|
||||
// If we're here for the first time without any cached data, it's an initial load.
|
||||
if (pageInitialLoad.value) {
|
||||
pageInitialLoad.value = false;
|
||||
}
|
||||
|
||||
// Only show the items loading spinner if we don't have any items to show.
|
||||
if (!list.value?.items.length) {
|
||||
itemsAreLoading.value = true;
|
||||
}
|
||||
|
||||
const routeId = String(route.params.id);
|
||||
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||||
|
||||
// Since we're fetching, remove the potentially stale cache.
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cachedFullData) {
|
||||
response = { data: JSON.parse(cachedFullData) };
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
} else {
|
||||
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||
}
|
||||
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
|
||||
|
||||
const rawList = response.data as ListWithExpenses;
|
||||
const localList: List = {
|
||||
@ -302,9 +305,6 @@ const fetchListDetails = async () => {
|
||||
}
|
||||
} finally {
|
||||
itemsAreLoading.value = false;
|
||||
if (!list.value && !error.value) {
|
||||
pageInitialLoad.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -687,9 +687,9 @@ onMounted(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||
const routeId = String(route.params.id);
|
||||
|
||||
const listShellJSON = sessionStorage.getItem('listDetailShell');
|
||||
if (listShellJSON) {
|
||||
const shellData = JSON.parse(listShellJSON);
|
||||
if (shellData.id === parseInt(routeId, 10)) {
|
||||
@ -709,6 +709,30 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const cachedFullData = sessionStorage.getItem(`listDetailFull_${routeId}`);
|
||||
if (cachedFullData) {
|
||||
try {
|
||||
const rawList = JSON.parse(cachedFullData) as ListWithExpenses;
|
||||
const localList: List = {
|
||||
id: rawList.id,
|
||||
name: rawList.name,
|
||||
description: rawList.description ?? undefined,
|
||||
is_complete: rawList.is_complete,
|
||||
items: processListItems(rawList.items),
|
||||
version: rawList.version,
|
||||
updated_at: rawList.updated_at,
|
||||
group_id: rawList.group_id ?? undefined
|
||||
};
|
||||
list.value = localList;
|
||||
lastListUpdate.value = rawList.updated_at;
|
||||
lastItemCount.value = rawList.items.length;
|
||||
pageInitialLoad.value = false;
|
||||
} catch (e) {
|
||||
console.error("Error parsing cached list data", e);
|
||||
sessionStorage.removeItem(`listDetailFull_${routeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch categories relevant to the list (either personal or group)
|
||||
categoryStore.fetchCategories(list.value?.group_id);
|
||||
|
||||
|
@ -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">
|
||||
@ -214,6 +214,12 @@ const loadCachedData = () => {
|
||||
|
||||
const fetchLists = async () => {
|
||||
error.value = null;
|
||||
|
||||
// Only show loading if we don't have any cached data to display
|
||||
if (lists.value.length === 0) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = currentGroupId.value
|
||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||
@ -228,7 +234,10 @@ const fetchLists = async () => {
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
|
||||
console.error(error.value, err);
|
||||
// If we have cached data and there's an error, don't clear the lists
|
||||
if (cachedLists.value.length === 0) lists.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@ -245,7 +254,11 @@ const fetchArchivedLists = async () => {
|
||||
};
|
||||
|
||||
const fetchListsAndGroups = async () => {
|
||||
// Only show loading if we don't have cached data
|
||||
if (lists.value.length === 0) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { choreService } from '../choreService'; // Adjust path
|
||||
import { api } from '../api'; // Actual axios instance from api.ts
|
||||
/// <reference types="vitest" />
|
||||
// @ts-nocheck
|
||||
import { choreService } from '../choreService';
|
||||
import { api } from '../api';
|
||||
import { groupService } from '../groupService';
|
||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType } from '../../types/chore'; // Adjust path
|
||||
import type { Group } from '../groupService';
|
||||
import { API_ENDPOINTS } from '@/services/api';
|
||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType } from '@/types/chore';
|
||||
import type { Group } from '@/types/group';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock the api module (axios instance)
|
||||
vi.mock('../api', () => ({
|
||||
@ -36,7 +40,7 @@ describe('Chore Service', () => {
|
||||
it('should fetch personal chores successfully', async () => {
|
||||
mockApi.get.mockResolvedValue({ data: [mockPersonalChore] });
|
||||
const chores = await choreService.getPersonalChores();
|
||||
expect(mockApi.get).toHaveBeenCalledWith('/api/v1/chores/personal');
|
||||
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL);
|
||||
expect(chores).toEqual([mockPersonalChore]);
|
||||
});
|
||||
|
||||
@ -52,7 +56,7 @@ describe('Chore Service', () => {
|
||||
it('should fetch chores for a specific group successfully', async () => {
|
||||
mockApi.get.mockResolvedValue({ data: [mockGroupChore] });
|
||||
const chores = await choreService.getChores(groupId);
|
||||
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${groupId}/chores`);
|
||||
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(groupId));
|
||||
expect(chores).toEqual([mockGroupChore]);
|
||||
});
|
||||
|
||||
@ -70,7 +74,7 @@ describe('Chore Service', () => {
|
||||
mockApi.post.mockResolvedValue({ data: createdChore });
|
||||
|
||||
const result = await choreService.createChore(newPersonalChore);
|
||||
expect(mockApi.post).toHaveBeenCalledWith('/api/v1/chores/personal', newPersonalChore);
|
||||
expect(mockApi.post).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL, newPersonalChore);
|
||||
expect(result).toEqual(createdChore);
|
||||
});
|
||||
|
||||
@ -80,7 +84,7 @@ describe('Chore Service', () => {
|
||||
mockApi.post.mockResolvedValue({ data: createdChore });
|
||||
|
||||
const result = await choreService.createChore(newGroupChore);
|
||||
expect(mockApi.post).toHaveBeenCalledWith(`/api/v1/chores/groups/${newGroupChore.group_id}/chores`, newGroupChore);
|
||||
expect(mockApi.post).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(newGroupChore.group_id), newGroupChore);
|
||||
expect(result).toEqual(createdChore);
|
||||
});
|
||||
|
||||
@ -109,27 +113,50 @@ describe('Chore Service', () => {
|
||||
mockApi.put.mockResolvedValue({ data: responseChore });
|
||||
|
||||
const result = await choreService.updateChore(choreId, updatedPersonalChoreData);
|
||||
expect(mockApi.put).toHaveBeenCalledWith(`/api/v1/chores/personal/${choreId}`, updatedPersonalChoreData);
|
||||
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId), updatedPersonalChoreData);
|
||||
expect(result).toEqual(responseChore);
|
||||
});
|
||||
|
||||
it('should update a group chore', async () => {
|
||||
const updatedGroupChoreData: ChoreUpdate = { name: 'Updated Group', type: 'group', group_id: 10, description: 'new desc' };
|
||||
const originalChore: Chore = { id: choreId, type: 'group', group_id: 10, name: 'Original', created_by_id: 1, next_due_date: '2024-01-01', frequency: 'daily' } as Chore;
|
||||
const responseChore = { ...updatedGroupChoreData, id: choreId };
|
||||
mockApi.put.mockResolvedValue({ data: responseChore });
|
||||
|
||||
const result = await choreService.updateChore(choreId, updatedGroupChoreData);
|
||||
expect(mockApi.put).toHaveBeenCalledWith(`/api/v1/chores/groups/${updatedGroupChoreData.group_id}/chores/${choreId}`, updatedGroupChoreData);
|
||||
const result = await choreService.updateChore(choreId, updatedGroupChoreData, originalChore);
|
||||
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(updatedGroupChoreData.group_id, choreId), updatedGroupChoreData);
|
||||
expect(result).toEqual(responseChore);
|
||||
});
|
||||
|
||||
it('should use general endpoint for type conversion from personal to group', async () => {
|
||||
const conversionData: ChoreUpdate = { name: 'Converted to Group', type: 'group', group_id: 10 };
|
||||
const originalChore: Chore = { id: choreId, type: 'personal', name: 'Original Personal', created_by_id: 1, next_due_date: '2024-01-01', frequency: 'daily' } as Chore;
|
||||
const responseChore = { ...conversionData, id: choreId };
|
||||
mockApi.put.mockResolvedValue({ data: responseChore });
|
||||
|
||||
const result = await choreService.updateChore(choreId, conversionData, originalChore);
|
||||
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.UPDATE_ANY_TYPE(choreId), conversionData);
|
||||
expect(result).toEqual(responseChore);
|
||||
});
|
||||
|
||||
it('should use general endpoint for type conversion from group to personal', async () => {
|
||||
const conversionData: ChoreUpdate = { name: 'Converted to Personal', type: 'personal' };
|
||||
const originalChore: Chore = { id: choreId, type: 'group', group_id: 10, name: 'Original Group', created_by_id: 1, next_due_date: '2024-01-01', frequency: 'daily' } as Chore;
|
||||
const responseChore = { ...conversionData, id: choreId };
|
||||
mockApi.put.mockResolvedValue({ data: responseChore });
|
||||
|
||||
const result = await choreService.updateChore(choreId, conversionData, originalChore);
|
||||
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.UPDATE_ANY_TYPE(choreId), conversionData);
|
||||
expect(result).toEqual(responseChore);
|
||||
});
|
||||
|
||||
it('should throw error for invalid type or missing group_id for group chore update', async () => {
|
||||
// @ts-expect-error testing invalid type
|
||||
const invalidTypeUpdate: ChoreUpdate = { name: 'Invalid', type: 'unknown' };
|
||||
await expect(choreService.updateChore(choreId, invalidTypeUpdate)).rejects.toThrow('Invalid chore type or missing group_id for group chore update');
|
||||
await expect(choreService.updateChore(choreId, invalidTypeUpdate)).rejects.toThrow('Invalid chore type for chore update');
|
||||
|
||||
const missingGroupIdUpdate: ChoreUpdate = { name: 'Missing GroupId', type: 'group' };
|
||||
await expect(choreService.updateChore(choreId, missingGroupIdUpdate)).rejects.toThrow('Invalid chore type or missing group_id for group chore update');
|
||||
await expect(choreService.updateChore(choreId, missingGroupIdUpdate)).rejects.toThrow('Missing group_id for group chore update');
|
||||
});
|
||||
|
||||
it('should propagate API error on failure', async () => {
|
||||
@ -145,14 +172,14 @@ describe('Chore Service', () => {
|
||||
it('should delete a personal chore', async () => {
|
||||
mockApi.delete.mockResolvedValue({ data: null }); // delete often returns no content
|
||||
await choreService.deleteChore(choreId, 'personal');
|
||||
expect(mockApi.delete).toHaveBeenCalledWith(`/api/v1/chores/personal/${choreId}`);
|
||||
expect(mockApi.delete).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId));
|
||||
});
|
||||
|
||||
it('should delete a group chore', async () => {
|
||||
const groupId = 10;
|
||||
mockApi.delete.mockResolvedValue({ data: null });
|
||||
await choreService.deleteChore(choreId, 'group', groupId);
|
||||
expect(mockApi.delete).toHaveBeenCalledWith(`/api/v1/chores/groups/${groupId}/chores/${choreId}`);
|
||||
expect(mockApi.delete).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId));
|
||||
});
|
||||
|
||||
it('should throw error for invalid type or missing group_id for group chore deletion', async () => {
|
||||
@ -194,10 +221,10 @@ describe('Chore Service', () => {
|
||||
|
||||
const allChores = await choreService.getAllChores();
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith('/api/v1/chores/personal');
|
||||
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL);
|
||||
expect(mockGroupService.getUserGroups).toHaveBeenCalled();
|
||||
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${mockUserGroups[0].id}/chores`);
|
||||
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${mockUserGroups[1].id}/chores`);
|
||||
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(mockUserGroups[0].id));
|
||||
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(mockUserGroups[1].id));
|
||||
expect(allChores).toEqual([...mockPersonalChores, ...mockFamilyChores, ...mockWorkChores]);
|
||||
});
|
||||
|
||||
|
@ -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 }, {
|
||||
refreshPromise = api.post(
|
||||
API_ENDPOINTS.AUTH.REFRESH,
|
||||
{ refresh_token: refreshTokenValue },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await refreshPromise
|
||||
|
@ -1,13 +1,21 @@
|
||||
import { api } from './api'
|
||||
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
|
||||
import { api, apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import type {
|
||||
Chore,
|
||||
ChoreCreate,
|
||||
ChoreUpdate,
|
||||
ChoreType,
|
||||
ChoreAssignment,
|
||||
ChoreAssignmentCreate,
|
||||
ChoreAssignmentUpdate,
|
||||
ChoreHistory,
|
||||
} from '@/types/chore'
|
||||
import { groupService } from './groupService'
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api'
|
||||
import type { Group } from '@/types/group'
|
||||
|
||||
export const choreService = {
|
||||
async getAllChores(): Promise<Chore[]> {
|
||||
try {
|
||||
const response = await api.get('/chores/all')
|
||||
const response = await api.get(API_ENDPOINTS.CHORES.ALL)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
|
||||
@ -38,78 +46,93 @@ export const choreService = {
|
||||
},
|
||||
|
||||
async getChores(groupId: number): Promise<Chore[]> {
|
||||
const response = await api.get(`/api/v1/chores/groups/${groupId}/chores`)
|
||||
const response = await api.get(API_ENDPOINTS.CHORES.GROUP_CHORES(groupId))
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createChore(chore: ChoreCreate): Promise<Chore> {
|
||||
if (chore.type === 'personal') {
|
||||
const response = await api.post('/api/v1/chores/personal', chore)
|
||||
const response = await api.post(API_ENDPOINTS.CHORES.PERSONAL, chore)
|
||||
return response.data
|
||||
} else if (chore.type === 'group' && chore.group_id) {
|
||||
const response = await api.post(`/api/v1/chores/groups/${chore.group_id}/chores`, chore)
|
||||
const response = await api.post(API_ENDPOINTS.CHORES.GROUP_CHORES(chore.group_id), chore)
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error('Invalid chore type or missing group_id for group chore')
|
||||
}
|
||||
},
|
||||
|
||||
async updateChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
|
||||
if (chore.type === 'personal') {
|
||||
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
|
||||
async updateChore(choreId: number, chore: ChoreUpdate, originalChore?: Chore): Promise<Chore> {
|
||||
// Check if this is a type conversion (personal to group or group to personal)
|
||||
const isTypeConversion = originalChore && chore.type && originalChore.type !== chore.type;
|
||||
|
||||
if (isTypeConversion) {
|
||||
// Use the new general endpoint for type conversions
|
||||
const response = await api.put(API_ENDPOINTS.CHORES.UPDATE_ANY_TYPE(choreId), chore)
|
||||
return response.data
|
||||
} else if (chore.type === 'group' && chore.group_id) {
|
||||
}
|
||||
|
||||
if (chore.type === 'personal') {
|
||||
const response = await api.put(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId), chore)
|
||||
return response.data
|
||||
} else if (chore.type === 'group') {
|
||||
// For group chores, we need to use the original group_id for the endpoint path
|
||||
// but can send the new group_id in the payload for group changes
|
||||
const currentGroupId = originalChore?.group_id ?? chore.group_id;
|
||||
if (!currentGroupId) {
|
||||
throw new Error('Missing group_id for group chore update')
|
||||
}
|
||||
const response = await api.put(
|
||||
`/api/v1/chores/groups/${chore.group_id}/chores/${choreId}`,
|
||||
API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(currentGroupId, choreId),
|
||||
chore,
|
||||
)
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error('Invalid chore type or missing group_id for group chore update')
|
||||
throw new Error('Invalid chore type for chore update')
|
||||
}
|
||||
},
|
||||
|
||||
async deleteChore(choreId: number, choreType: ChoreType, groupId?: number): Promise<void> {
|
||||
if (choreType === 'personal') {
|
||||
await api.delete(`/api/v1/chores/personal/${choreId}`)
|
||||
await api.delete(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId))
|
||||
} else if (choreType === 'group' && groupId) {
|
||||
await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`)
|
||||
await api.delete(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId))
|
||||
} else {
|
||||
throw new Error('Invalid chore type or missing group_id for group chore deletion')
|
||||
}
|
||||
},
|
||||
|
||||
async getPersonalChores(): Promise<Chore[]> {
|
||||
const response = await api.get('/api/v1/chores/personal')
|
||||
const response = await api.get(API_ENDPOINTS.CHORES.PERSONAL)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createAssignment(assignment: ChoreAssignmentCreate): Promise<ChoreAssignment> {
|
||||
const response = await api.post('/api/v1/chores/assignments', assignment)
|
||||
const response = await api.post(API_ENDPOINTS.CHORES.ASSIGNMENTS_BASE, assignment)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getMyAssignments(includeCompleted: boolean = false): Promise<ChoreAssignment[]> {
|
||||
const response = await api.get(`/api/v1/chores/assignments/my?include_completed=${includeCompleted}`)
|
||||
const response = await api.get(API_ENDPOINTS.CHORES.MY_ASSIGNMENTS(includeCompleted))
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getChoreAssignments(choreId: number): Promise<ChoreAssignment[]> {
|
||||
const response = await api.get(`/api/v1/chores/chores/${choreId}/assignments`)
|
||||
const response = await api.get(API_ENDPOINTS.CHORES.ASSIGNMENTS(choreId))
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
|
||||
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
|
||||
const response = await apiClient.put(API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId), update)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteAssignment(assignmentId: number): Promise<void> {
|
||||
await api.delete(`/api/v1/chores/assignments/${assignmentId}`)
|
||||
await api.delete(API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId))
|
||||
},
|
||||
|
||||
async completeAssignment(assignmentId: number): Promise<ChoreAssignment> {
|
||||
const response = await api.patch(`/api/v1/chores/assignments/${assignmentId}/complete`)
|
||||
const response = await api.patch(API_ENDPOINTS.CHORES.ASSIGNMENT_COMPLETE(assignmentId))
|
||||
return response.data
|
||||
},
|
||||
|
||||
@ -118,21 +141,21 @@ export const choreService = {
|
||||
choreId: number,
|
||||
chore: ChoreUpdate,
|
||||
): Promise<Chore> {
|
||||
const response = await api.put(`/api/v1/chores/groups/${groupId}/chores/${choreId}`, chore)
|
||||
const response = await api.put(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId), chore)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async _original_deleteGroupChore(groupId: number, choreId: number): Promise<void> {
|
||||
await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`)
|
||||
await api.delete(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId))
|
||||
},
|
||||
|
||||
async _updatePersonalChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
|
||||
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
|
||||
const response = await api.put(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId), chore)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async _deletePersonalChore(choreId: number): Promise<void> {
|
||||
await api.delete(`/api/v1/chores/personal/${choreId}`)
|
||||
await api.delete(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId))
|
||||
},
|
||||
|
||||
async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {
|
||||
|
@ -32,7 +32,23 @@ export interface UpdateExpenseData extends Partial<CreateExpenseData> {
|
||||
|
||||
export const expenseService = {
|
||||
async createExpense(data: CreateExpenseData): Promise<Expense> {
|
||||
const response = await api.post<Expense>(API_ENDPOINTS.FINANCIALS.EXPENSES, data)
|
||||
// Convert camelCase keys to snake_case expected by backend
|
||||
const payload: Record<string, any> = { ...data }
|
||||
payload.is_recurring = data.isRecurring
|
||||
delete payload.isRecurring
|
||||
|
||||
if (data.recurrencePattern) {
|
||||
payload.recurrence_pattern = {
|
||||
...data.recurrencePattern,
|
||||
// daysOfWeek -> days_of_week, endDate -> end_date, maxOccurrences -> max_occurrences
|
||||
days_of_week: data.recurrencePattern.daysOfWeek,
|
||||
end_date: data.recurrencePattern.endDate,
|
||||
max_occurrences: data.recurrencePattern.maxOccurrences,
|
||||
}
|
||||
delete payload.recurrencePattern
|
||||
}
|
||||
|
||||
const response = await api.post<Expense>(API_ENDPOINTS.FINANCIALS.EXPENSES, payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { apiClient, API_ENDPOINTS } from '@/services/api';
|
||||
import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
|
||||
import type { Group, GroupCreate, GroupUpdate, GroupPublic } from '@/types/group';
|
||||
import type { ChoreHistory } from '@/types/chore';
|
||||
import type { UserPublic } from '@/types/user';
|
||||
|
||||
export const groupService = {
|
||||
async getUserGroups(): Promise<Group[]> {
|
||||
async getUserGroups(): Promise<GroupPublic[]> {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
return response.data;
|
||||
},
|
||||
@ -22,9 +23,18 @@ export const groupService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createGroup(groupData: Group): Promise<Group> {
|
||||
async createGroup(groupData: GroupCreate): Promise<Group> {
|
||||
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
}
|
||||
async updateGroup(id: number, groupData: GroupUpdate): Promise<Group> {
|
||||
const response = await apiClient.put(API_ENDPOINTS.GROUPS.BY_ID(String(id)), groupData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getGroupMembers(groupId: number): Promise<UserPublic[]> {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.MEMBERS(String(groupId)));
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
@ -117,6 +117,7 @@ export interface Expense {
|
||||
|
||||
overall_settlement_status: ExpenseOverallStatusEnum
|
||||
isRecurring: boolean
|
||||
is_recurring?: boolean
|
||||
nextOccurrence?: string
|
||||
lastOccurrence?: string
|
||||
recurrencePattern?: RecurrencePattern
|
||||
|
@ -1,6 +1,7 @@
|
||||
// fe/src/types/group.ts
|
||||
import type { AuthState } from '@/stores/auth';
|
||||
import type { ChoreHistory } from './chore';
|
||||
import type { UserPublic } from './user';
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
@ -10,3 +11,24 @@ export interface Group {
|
||||
members: AuthState['user'][];
|
||||
chore_history?: ChoreHistory[];
|
||||
}
|
||||
|
||||
export interface GroupCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GroupUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface GroupPublic {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
created_by_id: number;
|
||||
created_at: string;
|
||||
creator?: UserPublic;
|
||||
members: UserPublic[];
|
||||
is_member?: boolean;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
full_name?: string | null; // Alias provided by backend in many responses
|
||||
email: string;
|
||||
// Add other relevant public user fields if necessary
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user