Merge pull request 'ph5' (#69) from ph5 into prod

Reviewed-on: #69
This commit is contained in:
mo 2025-06-22 21:28:18 +02:00
commit 82418132cd
36 changed files with 2129 additions and 1140 deletions

View File

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_transactional_session from app.database import get_transactional_session
from app.models import User 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 app.config import settings
from fastapi.security import OAuth2PasswordRequestForm 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) return RedirectResponse(url=redirect_url)
@router.post('/jwt/refresh') @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() data = await request.json()
refresh_token = data.get('refresh_token') refresh_token = data.get('refresh_token')
if not refresh_token: if not refresh_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
refresh_strategy = get_refresh_jwt_strategy() refresh_strategy = get_refresh_jwt_strategy()
try: 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: 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
if not user: if not user:
@ -106,6 +123,7 @@ async def refresh_jwt_token(request: Request):
access_strategy = get_jwt_strategy() access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user) access_token = await access_strategy.write_token(user)
new_refresh_token = await refresh_strategy.write_token(user) new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({ return JSONResponse({
"access_token": access_token, "access_token": access_token,
"refresh_token": new_refresh_token, "refresh_token": new_refresh_token,

View File

@ -18,6 +18,7 @@ from app.schemas.chore import (
from app.schemas.time_entry import TimeEntryPublic from app.schemas.time_entry import TimeEntryPublic
from app.crud import chore as crud_chore from app.crud import chore as crud_chore
from app.crud import history as crud_history 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 from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__) 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) 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) 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( @router.delete(
"/personal/{chore_id}", "/personal/{chore_id}",
status_code=status.HTTP_204_NO_CONTENT, 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}) chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group})
try: 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: except GroupNotFoundError as e:
logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.") 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) 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), db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), 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}") 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.") # Validate that the chore is in the specified group
if chore_in.group_id is not None and chore_in.group_id != group_id: chore_to_update = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.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}).") 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}")
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})
try: 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: if not updated_chore:
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id) raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return updated_chore 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") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments")
@router.get( @router.get(
"/chores/{chore_id}/assignments", "/{chore_id}/assignments",
response_model=PyList[ChoreAssignmentPublic], response_model=PyList[ChoreAssignmentPublic],
summary="List Chore Assignments", summary="List Chore Assignments",
tags=["Chore Assignments"] tags=["Chore Assignments"]

View File

@ -1,113 +1,24 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, selectinload
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN
from typing import List
from app.database import get_transactional_session from app.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
from app.models import ( from app.models import User as UserModel, Expense as ExpenseModel
User as UserModel, from app.schemas.cost import ListCostSummary, GroupBalanceSummary
Group as GroupModel, from app.schemas.expense import ExpensePublic
List as ListModel, from app.services import costs_service
Expense as ExpenseModel, from app.core.exceptions import (
Item as ItemModel, ListNotFoundError,
UserGroup as UserGroupModel, ListPermissionError,
SplitTypeEnum, GroupNotFoundError,
ExpenseSplit as ExpenseSplitModel, GroupPermissionError,
SettlementActivity as SettlementActivityModel, InvalidOperationError
Settlement as SettlementModel
) )
from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement
from app.schemas.expense import ExpenseCreate
from app.crud import list as crud_list
from app.crud import expense as crud_expense
from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> List[SuggestedSettlement]:
"""
Calculate suggested settlements to balance the finances within a group.
This function takes the current balances of all users and suggests optimal settlements
to minimize the number of transactions needed to settle all debts.
Args:
user_balances: List of UserBalanceDetail objects with their current balances
Returns:
List of SuggestedSettlement objects representing the suggested payments
"""
# Create list of users who owe money (negative balance) and who are owed money (positive balance)
debtors = [] # Users who owe money (negative balance)
creditors = [] # Users who are owed money (positive balance)
# Threshold to consider a balance as zero due to floating point precision
epsilon = Decimal('0.01')
# Sort users into debtors and creditors
for user in user_balances:
# Skip users with zero balance (or very close to zero)
if abs(user.net_balance) < epsilon:
continue
if user.net_balance < Decimal('0'):
# User owes money
debtors.append({
'user_id': user.user_id,
'user_identifier': user.user_identifier,
'amount': -user.net_balance # Convert to positive amount
})
else:
# User is owed money
creditors.append({
'user_id': user.user_id,
'user_identifier': user.user_identifier,
'amount': user.net_balance
})
# Sort by amount (descending) to handle largest debts first
debtors.sort(key=lambda x: x['amount'], reverse=True)
creditors.sort(key=lambda x: x['amount'], reverse=True)
settlements = []
# Iterate through debtors and match them with creditors
while debtors and creditors:
debtor = debtors[0]
creditor = creditors[0]
# Determine the settlement amount (the smaller of the two amounts)
amount = min(debtor['amount'], creditor['amount']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
# Create settlement record
if amount > Decimal('0'):
settlements.append(
SuggestedSettlement(
from_user_id=debtor['user_id'],
from_user_identifier=debtor['user_identifier'],
to_user_id=creditor['user_id'],
to_user_identifier=creditor['user_identifier'],
amount=amount
)
)
# Update balances
debtor['amount'] -= amount
creditor['amount'] -= amount
# Remove users who have settled their debts/credits
if debtor['amount'] < epsilon:
debtors.pop(0)
if creditor['amount'] < epsilon:
creditors.pop(0)
return settlements
@router.get( @router.get(
"/lists/{list_id}/cost-summary", "/lists/{list_id}/cost-summary",
@ -116,8 +27,8 @@ def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> L
tags=["Costs"], tags=["Costs"],
responses={ responses={
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"}, status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"},
status.HTTP_404_NOT_FOUND: {"description": "List or associated user not found"} status.HTTP_404_NOT_FOUND: {"description": "List not found"},
} },
) )
async def get_list_cost_summary( async def get_list_cost_summary(
list_id: int, list_id: int,
@ -125,151 +36,62 @@ async def get_list_cost_summary(
current_user: UserModel = Depends(current_active_user), current_user: UserModel = Depends(current_active_user),
): ):
""" """
Retrieves a calculated cost summary for a specific list, detailing total costs, Retrieves a calculated cost summary for a specific list.
equal shares per user, and individual user balances based on their contributions. If an expense has been generated for this list, the summary will be based on that.
Otherwise, it will be a basic summary of item prices.
The user must have access to the list to view its cost summary. This endpoint is idempotent and does not create any data.
Costs are split among group members if the list belongs to a group, or just for
the creator if it's a personal list. All users who added items with prices are
included in the calculation.
""" """
logger.info(f"User {current_user.email} requesting cost summary for list {list_id}") logger.info(f"User {current_user.email} requesting cost summary for list {list_id}")
# 1. Verify user has access to the target list
try: try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) return await costs_service.get_list_cost_summary_logic(
db=db, list_id=list_id, current_user_id=current_user.id
)
except ListPermissionError as e: except ListPermissionError as e:
logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}") logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}")
raise raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except ListNotFoundError as e: except ListNotFoundError as e:
logger.warning(f"List {list_id} not found when checking permissions for cost summary: {str(e)}") logger.warning(f"List {list_id} not found when getting cost summary: {str(e)}")
raise raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# 2. Get the list with its items and users
list_result = await db.execute( @router.post(
select(ListModel) "/lists/{list_id}/cost-summary",
.options( response_model=ExpensePublic,
selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)), status_code=status.HTTP_201_CREATED,
selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))), summary="Generate and Get Expense from List Summary",
selectinload(ListModel.creator) tags=["Costs"],
responses={
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this list"},
status.HTTP_404_NOT_FOUND: {"description": "List not found"},
status.HTTP_400_BAD_REQUEST: {"description": "Invalid operation (e.g., no items to expense, or expense already exists)"},
},
)
async def generate_expense_from_list_summary(
list_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Creates an ITEM_BASED expense from the items in a given list.
This should be called to finalize the costs for a shopping list and turn it into a formal expense.
It will fail if an expense for this list already exists.
"""
logger.info(f"User {current_user.email} requesting to generate expense from list {list_id}")
try:
expense = await costs_service.generate_expense_from_list_logic(
db=db, list_id=list_id, current_user_id=current_user.id
) )
.where(ListModel.id == list_id) return expense
) except (ListPermissionError, GroupPermissionError) as e:
db_list = list_result.scalars().first() logger.warning(f"Permission denied for user {current_user.email} on list {list_id}: {str(e)}")
if not db_list: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
raise ListNotFoundError(list_id) except (ListNotFoundError, GroupNotFoundError) as e:
logger.warning(f"Resource not found for list {list_id}: {str(e)}")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except InvalidOperationError as e:
logger.warning(f"Invalid operation for list {list_id}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# 3. Get or create an expense for this list
expense_result = await db.execute(
select(ExpenseModel)
.where(ExpenseModel.list_id == list_id)
.options(selectinload(ExpenseModel.splits))
)
db_expense = expense_result.scalars().first()
if not db_expense:
# Create a new expense for this list
total_amount = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0"))
if total_amount == Decimal("0"):
return ListCostSummary(
list_id=db_list.id,
list_name=db_list.name,
total_list_cost=Decimal("0.00"),
num_participating_users=0,
equal_share_per_user=Decimal("0.00"),
user_balances=[]
)
# Create expense with ITEM_BASED split type
expense_in = ExpenseCreate(
description=f"Cost summary for list {db_list.name}",
total_amount=total_amount,
list_id=list_id,
split_type=SplitTypeEnum.ITEM_BASED,
paid_by_user_id=db_list.creator.id
)
db_expense = await crud_expense.create_expense(db=db, expense_in=expense_in, current_user_id=current_user.id)
# 4. Calculate cost summary from expense splits
participating_users = set()
user_items_added_value = {}
total_list_cost = Decimal("0.00")
# Get all users who added items
for item in db_list.items:
if item.price is not None and item.price > Decimal("0") and item.added_by_user:
participating_users.add(item.added_by_user)
user_items_added_value[item.added_by_user.id] = user_items_added_value.get(item.added_by_user.id, Decimal("0.00")) + item.price
total_list_cost += item.price
# Get all users from expense splits
for split in db_expense.splits:
if split.user:
participating_users.add(split.user)
num_participating_users = len(participating_users)
if num_participating_users == 0:
return ListCostSummary(
list_id=db_list.id,
list_name=db_list.name,
total_list_cost=Decimal("0.00"),
num_participating_users=0,
equal_share_per_user=Decimal("0.00"),
user_balances=[]
)
# This is the ideal equal share, returned in the summary
equal_share_per_user_for_response = (total_list_cost / Decimal(num_participating_users)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Sort users for deterministic remainder distribution
sorted_participating_users = sorted(list(participating_users), key=lambda u: u.id)
user_final_shares = {}
if num_participating_users > 0:
base_share_unrounded = total_list_cost / Decimal(num_participating_users)
# Calculate initial share for each user, rounding down
for user in sorted_participating_users:
user_final_shares[user.id] = base_share_unrounded.quantize(Decimal("0.01"), rounding=ROUND_DOWN)
# Calculate sum of rounded down shares
sum_of_rounded_shares = sum(user_final_shares.values())
# Calculate remaining pennies to be distributed
remaining_pennies = int(((total_list_cost - sum_of_rounded_shares) * Decimal("100")).to_integral_value(rounding=ROUND_HALF_UP))
# Distribute remaining pennies one by one to sorted users
for i in range(remaining_pennies):
user_to_adjust = sorted_participating_users[i % num_participating_users]
user_final_shares[user_to_adjust.id] += Decimal("0.01")
user_balances = []
for user in sorted_participating_users: # Iterate over sorted users
items_added = user_items_added_value.get(user.id, Decimal("0.00"))
# current_user_share is now the precisely calculated share for this user
current_user_share = user_final_shares.get(user.id, Decimal("0.00"))
balance = items_added - current_user_share
user_identifier = user.name if user.name else user.email
user_balances.append(
UserCostShare(
user_id=user.id,
user_identifier=user_identifier,
items_added_value=items_added.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
amount_due=current_user_share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
balance=balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
)
user_balances.sort(key=lambda x: x.user_identifier)
return ListCostSummary(
list_id=db_list.id,
list_name=db_list.name,
total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
num_participating_users=num_participating_users,
equal_share_per_user=equal_share_per_user_for_response, # Use the ideal share for the response field
user_balances=user_balances
)
@router.get( @router.get(
"/groups/{group_id}/balance-summary", "/groups/{group_id}/balance-summary",
@ -278,8 +100,8 @@ async def get_list_cost_summary(
tags=["Costs", "Groups"], tags=["Costs", "Groups"],
responses={ responses={
status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this group"}, status.HTTP_403_FORBIDDEN: {"description": "User does not have permission to access this group"},
status.HTTP_404_NOT_FOUND: {"description": "Group not found"} status.HTTP_404_NOT_FOUND: {"description": "Group not found"},
} },
) )
async def get_group_balance_summary( async def get_group_balance_summary(
group_id: int, group_id: int,
@ -292,132 +114,13 @@ async def get_group_balance_summary(
The user must be a member of the group to view its balance summary. The user must be a member of the group to view its balance summary.
""" """
logger.info(f"User {current_user.email} requesting balance summary for group {group_id}") logger.info(f"User {current_user.email} requesting balance summary for group {group_id}")
try:
# 1. Verify user is a member of the target group return await costs_service.get_group_balance_summary_logic(
group_check = await db.execute( db=db, group_id=group_id, current_user_id=current_user.id
select(GroupModel)
.options(selectinload(GroupModel.member_associations))
.where(GroupModel.id == group_id)
)
db_group_for_check = group_check.scalars().first()
if not db_group_for_check:
raise GroupNotFoundError(group_id)
user_is_member = any(assoc.user_id == current_user.id for assoc in db_group_for_check.member_associations)
if not user_is_member:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"User not a member of group {group_id}")
# 2. Get all expenses and settlements for the group
expenses_result = await db.execute(
select(ExpenseModel)
.where(ExpenseModel.group_id == group_id)
.options(selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user))
)
expenses = expenses_result.scalars().all()
settlements_result = await db.execute(
select(SettlementModel)
.where(SettlementModel.group_id == group_id)
.options(
selectinload(SettlementModel.paid_by_user),
selectinload(SettlementModel.paid_to_user)
) )
) except GroupPermissionError as e:
settlements = settlements_result.scalars().all() logger.warning(f"Permission denied for user {current_user.email} on group {group_id}: {str(e)}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
# Fetch SettlementActivities related to the group's expenses except GroupNotFoundError as e:
# This requires joining SettlementActivity -> ExpenseSplit -> Expense logger.warning(f"Group {group_id} not found when getting balance summary: {str(e)}")
settlement_activities_result = await db.execute( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
select(SettlementActivityModel)
.join(ExpenseSplitModel, SettlementActivityModel.expense_split_id == ExpenseSplitModel.id)
.join(ExpenseModel, ExpenseSplitModel.expense_id == ExpenseModel.id)
.where(ExpenseModel.group_id == group_id)
.options(selectinload(SettlementActivityModel.payer)) # Optional: if you need payer details directly
)
settlement_activities = settlement_activities_result.scalars().all()
# 3. Calculate user balances
user_balances_data = {}
# Initialize UserBalanceDetail for each group member
for assoc in db_group_for_check.member_associations:
if assoc.user:
user_balances_data[assoc.user.id] = {
"user_id": assoc.user.id,
"user_identifier": assoc.user.name if assoc.user.name else assoc.user.email,
"total_paid_for_expenses": Decimal("0.00"),
"initial_total_share_of_expenses": Decimal("0.00"),
"total_amount_paid_via_settlement_activities": Decimal("0.00"),
"total_generic_settlements_paid": Decimal("0.00"),
"total_generic_settlements_received": Decimal("0.00"),
}
# Process Expenses
for expense in expenses:
if expense.paid_by_user_id in user_balances_data:
user_balances_data[expense.paid_by_user_id]["total_paid_for_expenses"] += expense.total_amount
for split in expense.splits:
if split.user_id in user_balances_data:
user_balances_data[split.user_id]["initial_total_share_of_expenses"] += split.owed_amount
# Process Settlement Activities (SettlementActivityModel)
for activity in settlement_activities:
if activity.paid_by_user_id in user_balances_data:
user_balances_data[activity.paid_by_user_id]["total_amount_paid_via_settlement_activities"] += activity.amount_paid
# Process Generic Settlements (SettlementModel)
for settlement in settlements:
if settlement.paid_by_user_id in user_balances_data:
user_balances_data[settlement.paid_by_user_id]["total_generic_settlements_paid"] += settlement.amount
if settlement.paid_to_user_id in user_balances_data:
user_balances_data[settlement.paid_to_user_id]["total_generic_settlements_received"] += settlement.amount
# Calculate Final Balances
final_user_balances = []
for user_id, data in user_balances_data.items():
initial_total_share_of_expenses = data["initial_total_share_of_expenses"]
total_amount_paid_via_settlement_activities = data["total_amount_paid_via_settlement_activities"]
adjusted_total_share_of_expenses = initial_total_share_of_expenses - total_amount_paid_via_settlement_activities
total_paid_for_expenses = data["total_paid_for_expenses"]
total_generic_settlements_received = data["total_generic_settlements_received"]
total_generic_settlements_paid = data["total_generic_settlements_paid"]
net_balance = (
total_paid_for_expenses + total_generic_settlements_received
) - (adjusted_total_share_of_expenses + total_generic_settlements_paid)
# Quantize all final values for UserBalanceDetail schema
user_detail = UserBalanceDetail(
user_id=data["user_id"],
user_identifier=data["user_identifier"],
total_paid_for_expenses=total_paid_for_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
# Store adjusted_total_share_of_expenses in total_share_of_expenses
total_share_of_expenses=adjusted_total_share_of_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
# Store total_generic_settlements_paid in total_settlements_paid
total_settlements_paid=total_generic_settlements_paid.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
total_settlements_received=total_generic_settlements_received.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
net_balance=net_balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
final_user_balances.append(user_detail)
# Sort by user identifier
final_user_balances.sort(key=lambda x: x.user_identifier)
# Calculate suggested settlements
suggested_settlements = calculate_suggested_settlements(final_user_balances)
# Calculate overall totals for the group
overall_total_expenses = sum(expense.total_amount for expense in expenses)
overall_total_settlements = sum(settlement.amount for settlement in settlements)
return GroupBalanceSummary(
group_id=db_group_for_check.id,
group_name=db_group_for_check.name,
overall_total_expenses=overall_total_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
overall_total_settlements=overall_total_settlements.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
user_balances=final_user_balances,
suggested_settlements=suggested_settlements
)

View File

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from typing import List as PyList, Optional, Sequence from typing import List as PyList, Optional, Sequence, Union
from app.database import get_transactional_session from app.database import get_transactional_session
from app.auth import current_active_user from app.auth import current_active_user
@ -14,13 +14,16 @@ from app.models import (
List as ListModel, List as ListModel,
UserGroup as UserGroupModel, UserGroup as UserGroupModel,
UserRoleEnum, UserRoleEnum,
ExpenseSplit as ExpenseSplitModel ExpenseSplit as ExpenseSplitModel,
Expense as ExpenseModel,
Settlement as SettlementModel
) )
from app.schemas.expense import ( from app.schemas.expense import (
ExpenseCreate, ExpensePublic, ExpenseCreate, ExpensePublic,
SettlementCreate, SettlementPublic, SettlementCreate, SettlementPublic,
ExpenseUpdate, SettlementUpdate ExpenseUpdate, SettlementUpdate
) )
from app.schemas.financials import FinancialActivityResponse
from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added from app.schemas.settlement_activity import SettlementActivityCreate, SettlementActivityPublic # Added
from app.crud import expense as crud_expense from app.crud import expense as crud_expense
from app.crud import settlement as crud_settlement from app.crud import settlement as crud_settlement
@ -30,8 +33,9 @@ from app.crud import list as crud_list
from app.core.exceptions import ( from app.core.exceptions import (
ListNotFoundError, GroupNotFoundError, UserNotFoundError, ListNotFoundError, GroupNotFoundError, UserNotFoundError,
InvalidOperationError, GroupPermissionError, ListPermissionError, InvalidOperationError, GroupPermissionError, ListPermissionError,
ItemNotFoundError, GroupMembershipError ItemNotFoundError, GroupMembershipError, OverpaymentError, FinancialConflictError
) )
from app.services import financials_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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) expenses = await crud_expense.get_user_accessible_expenses(db, user_id=current_user.id, skip=skip, limit=limit)
# Apply recurring filter if specified # 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: 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 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)}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User referenced in settlement activity not found: {str(e)}")
except InvalidOperationError as e: except InvalidOperationError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(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: except Exception as e:
logger.error(f"Unexpected error recording settlement activity for expense_split_id {expense_split_id}: {str(e)}", exc_info=True) 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.") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while recording settlement activity.")
@ -655,4 +667,21 @@ async def delete_settlement_record(
logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True) logger.error(f"Unexpected error deleting settlement {settlement_id}: {str(e)}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred.")
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/users/me/financial-activity", response_model=FinancialActivityResponse, summary="Get User's Financial Activity", tags=["Users", "Expenses", "Settlements"])
async def get_user_financial_activity(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""
Retrieves a consolidated and chronologically sorted list of all financial activities
for the current user, including expenses they are part of and settlements they have
made or received.
"""
logger.info(f"User {current_user.email} requesting their financial activity feed.")
activities = await financials_service.get_user_financial_activity(db=db, user_id=current_user.id)
# The service returns a mix of ExpenseModel and SettlementModel objects.
# We need to wrap it in our response schema. Pydantic will handle the Union type.
return FinancialActivityResponse(activities=activities)

View File

@ -258,14 +258,30 @@ class InviteOperationError(HTTPException):
class SettlementOperationError(HTTPException): class SettlementOperationError(HTTPException):
"""Raised when a settlement operation fails.""" """Raised when a settlement operation fails."""
def __init__(self, detail: str): def __init__(self, detail: str = "An error occurred during a settlement operation."):
super().__init__( super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail detail=detail
) )
class FinancialConflictError(HTTPException):
"""Raised when a financial 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): class ConflictError(HTTPException):
"""Raised when an optimistic lock version conflict occurs.""" """Raised when a conflict occurs."""
def __init__(self, detail: str): def __init__(self, detail: str):
super().__init__( super().__init__(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
@ -343,6 +359,10 @@ class ChoreOperationError(HTTPException):
class ChoreNotFoundError(HTTPException): class ChoreNotFoundError(HTTPException):
"""Raised when a chore or assignment is not found.""" """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): 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: if detail:
error_detail = detail error_detail = detail
elif group_id is not None: elif group_id is not None:

43
be/app/core/middleware.py Normal file
View 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

View File

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

View File

@ -1,15 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload, subqueryload
from sqlalchemy import union_all from sqlalchemy import union_all, and_, or_, delete
from typing import List, Optional from typing import List, Optional
import logging import logging
from datetime import date, datetime 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.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
from app.core.chore_utils import calculate_next_due_date 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.crud.history import create_chore_history_entry, create_assignment_history_entry
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError 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( async def create_chore(
db: AsyncSession, db: AsyncSession,
chore_in: ChoreCreate, chore_in: ChoreCreate,
user_id: int, user_id: int
group_id: Optional[int] = None
) -> Chore: ) -> 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(): async with db.begin_nested() if db.in_transaction() else db.begin():
# Validate chore type and group
if chore_in.type == ChoreTypeEnum.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") 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: if not group:
raise GroupNotFoundError(group_id) raise GroupNotFoundError(chore_in.group_id)
if not await is_user_member(db, group_id, user_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 {group_id}") raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore_in.group_id}")
else: # personal chore else: # personal chore
if group_id: if chore_in.group_id:
raise ValueError("group_id must be None for personal chores") 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']: 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']) parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
if not parent_chore: if not parent_chore:
@ -95,7 +108,6 @@ async def create_chore(
db_chore = Chore( db_chore = Chore(
**chore_data, **chore_data,
group_id=group_id,
created_by_id=user_id, created_by_id=user_id,
) )
@ -105,6 +117,24 @@ async def create_chore(
db.add(db_chore) db.add(db_chore)
await db.flush() 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( await create_chore_history_entry(
db, db,
chore_id=db_chore.id, 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( result = await db.execute(
select(Chore) select(Chore)
.where(Chore.id == chore_id) .where(Chore.id == chore_id)
.options( .options(*get_chore_loader_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)
)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@ -174,13 +197,7 @@ async def get_personal_chores(
Chore.created_by_id == user_id, Chore.created_by_id == user_id,
Chore.type == ChoreTypeEnum.personal Chore.type == ChoreTypeEnum.personal
) )
.options( .options(*get_chore_loader_options())
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name) .order_by(Chore.next_due_date, Chore.name)
) )
return result.scalars().all() return result.scalars().all()
@ -200,13 +217,7 @@ async def get_chores_by_group_id(
Chore.group_id == group_id, Chore.group_id == group_id,
Chore.type == ChoreTypeEnum.group Chore.type == ChoreTypeEnum.group
) )
.options( .options(*get_chore_loader_options())
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.order_by(Chore.next_due_date, Chore.name) .order_by(Chore.next_due_date, Chore.name)
) )
return result.scalars().all() 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)} 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 db_chore.type == ChoreTypeEnum.group:
if not group_id: if not await is_user_member(db, db_chore.group_id, user_id):
raise ValueError("group_id is required for group chores") raise PermissionDeniedError(detail=f"User {user_id} not a member of chore's current group {db_chore.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}")
if db_chore.group_id != group_id:
raise ChoreNotFoundError(chore_id, group_id)
else: else:
if group_id:
raise ValueError("group_id must be None for personal chores")
if db_chore.created_by_id != user_id: if db_chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can update personal chores") raise PermissionDeniedError(detail="Only the creator can update personal chores")
update_data = chore_in.model_dump(exclude_unset=True) 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 'parent_chore_id' in update_data:
if update_data['parent_chore_id']: if update_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, 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 # Setting parent_chore_id to None is allowed
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id']) 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 next_due_date if needed
recalculate = False recalculate = False
if 'frequency' in update_data and update_data['frequency'] != db_chore.frequency: if 'frequency' in update_data and update_data['frequency'] != db_chore.frequency:
@ -304,14 +324,7 @@ async def update_chore(
result = await db.execute( result = await db.execute(
select(Chore) select(Chore)
.where(Chore.id == db_chore.id) .where(Chore.id == db_chore.id)
.options( .options(*get_chore_loader_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)
)
) )
return result.scalar_one() return result.scalar_one()
except Exception as e: except Exception as e:
@ -398,12 +411,7 @@ async def create_chore_assignment(
result = await db.execute( result = await db.execute(
select(ChoreAssignment) select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id) .where(ChoreAssignment.id == db_assignment.id)
.options( .options(*get_assignment_loader_options())
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
) )
return result.scalar_one() return result.scalar_one()
except Exception as e: 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( result = await db.execute(
select(ChoreAssignment) select(ChoreAssignment)
.where(ChoreAssignment.id == assignment_id) .where(ChoreAssignment.id == assignment_id)
.options( .options(*get_assignment_loader_options())
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@ -435,12 +438,7 @@ async def get_user_assignments(
if not include_completed: if not include_completed:
query = query.where(ChoreAssignment.is_complete == False) query = query.where(ChoreAssignment.is_complete == False)
query = query.options( query = query.options(*get_assignment_loader_options()).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
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)
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@ -465,12 +463,7 @@ async def get_chore_assignments(
result = await db.execute( result = await db.execute(
select(ChoreAssignment) select(ChoreAssignment)
.where(ChoreAssignment.chore_id == chore_id) .where(ChoreAssignment.chore_id == chore_id)
.options( .options(*get_assignment_loader_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) .order_by(ChoreAssignment.due_date, ChoreAssignment.id)
) )
return result.scalars().all() return result.scalars().all()
@ -510,12 +503,13 @@ async def update_chore_assignment(
history_event = ChoreHistoryEventTypeEnum.COMPLETED history_event = ChoreHistoryEventTypeEnum.COMPLETED
# Advance the next_due_date of the parent chore # 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.last_completed_at = db_assignment.completed_at
db_assignment.chore.next_due_date = calculate_next_due_date( db_assignment.chore.next_due_date = calculate_next_due_date(
db_assignment.chore.frequency, current_due_date=db_assignment.chore.next_due_date,
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(), frequency=db_assignment.chore.frequency,
db_assignment.chore.custom_interval_days 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: elif not new_status and original_status:
db_assignment.completed_at = None db_assignment.completed_at = None
@ -537,10 +531,7 @@ async def update_chore_assignment(
result = await db.execute( result = await db.execute(
select(ChoreAssignment) select(ChoreAssignment)
.where(ChoreAssignment.id == assignment_id) .where(ChoreAssignment.id == assignment_id)
.options( .options(*get_assignment_loader_options())
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user)
)
) )
return result.scalar_one() return result.scalar_one()
except Exception as e: except Exception as e:
@ -585,3 +576,25 @@ async def delete_chore_assignment(
except Exception as e: except Exception as e:
logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True) 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)}") 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())
]

View File

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # Added import 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 typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
from datetime import datetime, timezone # Added timezone from datetime import datetime, timezone # Added timezone
import json import json
@ -20,6 +20,7 @@ from app.models import (
Item as ItemModel, Item as ItemModel,
ExpenseOverallStatusEnum, # Added ExpenseOverallStatusEnum, # Added
ExpenseSplitStatusEnum, # Added ExpenseSplitStatusEnum, # Added
RecurrenceTypeEnum,
) )
from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate, ExpenseUpdate # Removed unused ExpenseUpdate from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate, ExpenseUpdate # Removed unused ExpenseUpdate
from app.core.exceptions import ( 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 # Re-resolve context if list_id was derived from item
final_group_id = await _resolve_expense_context(db, expense_in) final_group_id = await _resolve_expense_context(db, expense_in)
# Create recurrence pattern if this is a recurring expense
recurrence_pattern = None recurrence_pattern = None
if expense_in.is_recurring and expense_in.recurrence_pattern: 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( recurrence_pattern = RecurrencePattern(
type=expense_in.recurrence_pattern.type, type=_rp_type_enum,
interval=expense_in.recurrence_pattern.interval, 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, end_date=expense_in.recurrence_pattern.end_date,
max_occurrences=expense_in.recurrence_pattern.max_occurrences, max_occurrences=expense_in.recurrence_pattern.max_occurrences,
created_at=datetime.now(timezone.utc), 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]: 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( users_for_splitting = await get_users_for_splitting(
db, expense_model.group_id, expense_model.list_id, expense_model.paid_by_user_id db, expense_model.group_id, expense_model.list_id, expense_model.paid_by_user_id
@ -323,20 +337,29 @@ async def _create_equal_splits(db: AsyncSession, expense_model: ExpenseModel, ex
raise InvalidOperationError("No users found for EQUAL split.") raise InvalidOperationError("No users found for EQUAL split.")
num_users = len(users_for_splitting) num_users = len(users_for_splitting)
amount_per_user = round_money_func(expense_model.total_amount / Decimal(num_users)) # Use floor rounding initially to prevent over-shooting the total
remainder = expense_model.total_amount - (amount_per_user * num_users) amount_per_user_floor = (expense_model.total_amount / Decimal(num_users)).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
splits = [] splits = []
for i, user in enumerate(users_for_splitting): # Sort users by ID to ensure deterministic remainder distribution
split_amount = amount_per_user users_for_splitting.sort(key=lambda u: u.id)
if i == 0 and remainder != Decimal('0'):
split_amount = round_money_func(amount_per_user + remainder) for user in users_for_splitting:
splits.append(ExpenseSplitModel( splits.append(ExpenseSplitModel(
user_id=user.id, user_id=user.id,
owed_amount=split_amount, owed_amount=amount_per_user_floor,
status=ExpenseSplitStatusEnum.unpaid # Explicitly set default status 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 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]: 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: if not expense_in.splits_in:
raise InvalidOperationError("Splits data is required for PERCENTAGE split type.") 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"): if round_money_func(total_percentage) != Decimal("100.00"):
raise InvalidOperationError(f"Sum of percentages ({total_percentage}%) is not 100%.") 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: if current_total != expense_model.total_amount and splits:
diff = expense_model.total_amount - current_total 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 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]: 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: if not expense_in.splits_in:
raise InvalidOperationError("Splits data is required for SHARES split type.") raise InvalidOperationError("Splits data is required for SHARES split type.")
@ -447,11 +478,19 @@ async def _create_shares_splits(db: AsyncSession, expense_model: ExpenseModel, e
status=ExpenseSplitStatusEnum.unpaid # Explicitly set default status 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: if current_total != expense_model.total_amount and splits:
diff = expense_model.total_amount - current_total 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 return splits

View File

@ -16,6 +16,8 @@ from app.models import (
) )
from pydantic import BaseModel from pydantic import BaseModel
from app.crud.audit import create_financial_audit_log from app.crud.audit import create_financial_audit_log
from app.schemas.settlement_activity import SettlementActivityCreate
from app.core.exceptions import UserNotFoundError, InvalidOperationError, FinancialConflictError, OverpaymentError
class SettlementActivityCreatePlaceholder(BaseModel): class SettlementActivityCreatePlaceholder(BaseModel):
@ -114,21 +116,55 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
async def create_settlement_activity( async def create_settlement_activity(
db: AsyncSession, db: AsyncSession,
settlement_activity_in: SettlementActivityCreatePlaceholder, settlement_activity_in: SettlementActivityCreate,
current_user_id: int current_user_id: int
) -> Optional[SettlementActivity]: ) -> SettlementActivity:
""" """
Creates a new settlement activity, then updates the parent expense split and expense statuses. Creates a new settlement activity, then updates the parent expense split and expense statuses.
Uses pessimistic locking on the ExpenseSplit row to prevent race conditions.
Relies on the calling context (e.g., transactional session dependency) for the transaction.
""" """
split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id)) # Lock the expense split row for the duration of the transaction
split_stmt = (
select(ExpenseSplit)
.where(ExpenseSplit.id == settlement_activity_in.expense_split_id)
.with_for_update()
)
split_result = await db.execute(split_stmt)
expense_split = split_result.scalar_one_or_none() expense_split = split_result.scalar_one_or_none()
if not expense_split: if not expense_split:
return None raise InvalidOperationError(f"Expense split with ID {settlement_activity_in.expense_split_id} not found.")
# Check if the split is already fully paid
if expense_split.status == ExpenseSplitStatusEnum.paid:
raise FinancialConflictError(f"Expense split {expense_split.id} is already fully paid.")
# 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)) user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id))
paid_by_user = user_result.scalar_one_or_none() if not user_result.scalar_one_or_none():
if not paid_by_user: raise UserNotFoundError(user_id=settlement_activity_in.paid_by_user_id)
return None # User not found
db_settlement_activity = SettlementActivity( db_settlement_activity = SettlementActivity(
expense_split_id=settlement_activity_in.expense_split_id, expense_split_id=settlement_activity_in.expense_split_id,
@ -148,14 +184,28 @@ async def create_settlement_activity(
entity=db_settlement_activity, entity=db_settlement_activity,
) )
# Update statuses # Update statuses within the same transaction
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id) updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id: if updated_split and updated_split.expense_id:
await update_expense_overall_status(db, expense_id=updated_split.expense_id) await update_expense_overall_status(db, expense_id=updated_split.expense_id)
else:
pass
return db_settlement_activity # Re-fetch the object with all relationships loaded to prevent lazy-loading issues during serialization
stmt = (
select(SettlementActivity)
.where(SettlementActivity.id == db_settlement_activity.id)
.options(
selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator)
)
)
result = await db.execute(stmt)
loaded_activity = result.scalar_one_or_none()
if not loaded_activity:
# This should not happen in a normal flow
raise InvalidOperationError("Failed to load settlement activity after creation.")
return loaded_activity
async def get_settlement_activity_by_id( async def get_settlement_activity_by_id(

View File

@ -1,11 +1,13 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from app.models import Expense, RecurrencePattern from sqlalchemy.orm import selectinload
from app.models import Expense, RecurrencePattern, SplitTypeEnum
from app.crud.expense import create_expense from app.crud.expense import create_expense
from app.schemas.expense import ExpenseCreate from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate
import logging import logging
from typing import Optional from typing import Optional
import enum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,6 +31,8 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
(RecurrencePattern.end_date > now) (RecurrencePattern.end_date > now)
) )
) )
).options(
selectinload(Expense.splits) # Eager load splits to use as a template
) )
result = await db.execute(query) result = await db.execute(query)
@ -38,22 +42,42 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
try: try:
await _generate_next_occurrence(db, expense) await _generate_next_occurrence(db, expense)
except Exception as e: 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 continue
except Exception as e: except Exception as e:
logger.error(f"Error in generate_recurring_expenses job: {str(e)}") logger.error(f"Error in generate_recurring_expenses job during expense fetch: {str(e)}", exc_info=True)
raise # Do not re-raise, allow the job scheduler to run again later
async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None: async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
"""Generate the next occurrence of a recurring expense.""" """Generate the next occurrence of a recurring expense."""
pattern = expense.recurrence_pattern pattern = expense.recurrence_pattern
if not pattern: if not pattern:
logger.warning(f"Recurring expense {expense.id} is missing its recurrence pattern.")
return return
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern) next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
if not next_date: if not next_date:
logger.info(f"No next occurrence date for expense {expense.id}, stopping recurrence.")
expense.is_recurring = False # Stop future processing
await db.flush()
return return
# Recreate splits from the template expense if needed
splits_data = None
if expense.split_type not in [SplitTypeEnum.EQUAL, SplitTypeEnum.ITEM_BASED]:
if not expense.splits:
logger.error(f"Cannot generate next occurrence for expense {expense.id} with split type {expense.split_type.value} because it has no splits to use as a template.")
return
splits_data = [
ExpenseSplitCreate(
user_id=split.user_id,
owed_amount=split.owed_amount,
share_percentage=split.share_percentage,
share_units=split.share_units,
) for split in expense.splits
]
new_expense = ExpenseCreate( new_expense = ExpenseCreate(
description=expense.description, description=expense.description,
@ -65,46 +89,98 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
group_id=expense.group_id, group_id=expense.group_id,
item_id=expense.item_id, item_id=expense.item_id,
paid_by_user_id=expense.paid_by_user_id, paid_by_user_id=expense.paid_by_user_id,
is_recurring=False, is_recurring=False, # The new expense is a single occurrence, not a recurring template
splits_in=None splits_in=splits_data
) )
# We pass the original creator's ID
created_expense = await create_expense(db, new_expense, expense.created_by_user_id) created_expense = await create_expense(db, new_expense, expense.created_by_user_id)
logger.info(f"Generated new expense {created_expense.id} from recurring expense {expense.id}.")
# Update the template expense for the next run
expense.last_occurrence = next_date expense.last_occurrence = next_date
expense.next_occurrence = _calculate_next_occurrence(next_date, pattern) next_next_date = _calculate_next_occurrence(next_date, pattern)
if pattern.max_occurrences: # Decrement occurrence count if it exists
if pattern.max_occurrences is not None:
pattern.max_occurrences -= 1 pattern.max_occurrences -= 1
if pattern.max_occurrences <= 0:
next_next_date = None # Stop recurrence
expense.next_occurrence = next_next_date
if not expense.next_occurrence:
expense.is_recurring = False # End the recurrence
await db.flush() await db.flush()
def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]: def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]:
"""Calculate the next occurrence date based on the pattern.""" """Calculate the next occurrence date based on the recurrence pattern provided."""
if not current_date: if not current_date:
return None return None
if pattern.type == 'daily': # Extract a lowercase string of the recurrence type regardless of whether it is an Enum member or a str.
return current_date + timedelta(days=pattern.interval) if isinstance(pattern.type, enum.Enum):
pattern_type = pattern.type.value.lower()
elif pattern.type == 'weekly': else:
pattern_type = str(pattern.type).lower()
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: if not pattern.days_of_week:
return current_date + timedelta(weeks=pattern.interval) next_date = current_date + timedelta(weeks=pattern.interval)
else:
current_weekday = current_date.weekday() current_weekday = current_date.weekday()
next_weekday = min((d for d in pattern.days_of_week if d > current_weekday), # ``days_of_week`` can be stored either as a list[int] (Python-side) or as a
default=min(pattern.days_of_week)) # comma-separated string in the database. We normalise it to a list[int].
days_ahead = next_weekday - current_weekday days_of_week_iterable = []
if days_ahead <= 0: if pattern.days_of_week is None:
days_ahead += 7 days_of_week_iterable = []
return current_date + timedelta(days=days_ahead) elif isinstance(pattern.days_of_week, (list, tuple)):
days_of_week_iterable = list(pattern.days_of_week)
elif pattern.type == 'monthly': 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 = []
# 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 year = current_date.year + (current_date.month + pattern.interval - 1) // 12
month = (current_date.month + pattern.interval - 1) % 12 + 1 month = (current_date.month + pattern.interval - 1) % 12 + 1
return current_date.replace(year=year, month=month) try:
next_date = current_date.replace(year=year, month=month)
elif pattern.type == 'yearly': except ValueError:
return current_date.replace(year=current_date.year + pattern.interval) # 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)
return None
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

View File

@ -12,6 +12,7 @@ from app.core.api_config import API_METADATA, API_TAGS
from app.auth import fastapi_users, auth_backend from app.auth import fastapi_users, auth_backend
from app.schemas.user import UserPublic, UserCreate, UserUpdate from app.schemas.user import UserPublic, UserCreate, UserUpdate
from app.core.scheduler import init_scheduler, shutdown_scheduler from app.core.scheduler import init_scheduler, shutdown_scheduler
from app.core.middleware import RequestContextMiddleware
if settings.SENTRY_DSN: if settings.SENTRY_DSN:
sentry_sdk.init( sentry_sdk.init(
@ -48,6 +49,9 @@ app.add_middleware(
secret_key=settings.SESSION_SECRET_KEY secret_key=settings.SESSION_SECRET_KEY
) )
# Structured logging & request tracing
app.add_middleware(RequestContextMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from typing import Optional, List, Any 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 ..models import ChoreFrequencyEnum, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from .user import UserPublic from .user import UserPublic
@ -35,37 +35,26 @@ class ChoreBase(BaseModel):
next_due_date: date # For creation, this will be the initial due date next_due_date: date # For creation, this will be the initial due date
type: ChoreTypeEnum type: ChoreTypeEnum
@field_validator('custom_interval_days', mode='before') @model_validator(mode='after')
@classmethod def validate_custom_frequency(self):
def check_custom_interval_days(cls, value, values): if self.frequency == ChoreFrequencyEnum.custom:
# Pydantic v2 uses `values.data` to get all fields if self.custom_interval_days is None or self.custom_interval_days <= 0:
# For older Pydantic, it might just be `values` raise ValueError("custom_interval_days must be a positive integer when frequency is 'custom'")
# This is a simplified check; actual access might differ slightly return self
# 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
class ChoreCreate(ChoreBase): class ChoreCreate(ChoreBase):
group_id: Optional[int] = None group_id: Optional[int] = None
parent_chore_id: Optional[int] = None parent_chore_id: Optional[int] = None
assigned_to_user_id: Optional[int] = None
@field_validator('group_id') @model_validator(mode='after')
@classmethod def validate_group_id_with_type(self):
def validate_group_id(cls, v, values): if self.type == ChoreTypeEnum.group and self.group_id is None:
if values.data.get('type') == ChoreTypeEnum.group and v is None:
raise ValueError("group_id is required for group chores") raise ValueError("group_id is required for group chores")
if values.data.get('type') == ChoreTypeEnum.personal and v is not None: if self.type == ChoreTypeEnum.personal and self.group_id is not None:
raise ValueError("group_id must be None for personal chores") # Automatically clear group_id for personal chores instead of raising an error
return v self.group_id = None
return self
class ChoreUpdate(BaseModel): class ChoreUpdate(BaseModel):
name: Optional[str] = None 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 next_due_date: Optional[date] = None # Allow updating next_due_date directly if needed
type: Optional[ChoreTypeEnum] = None type: Optional[ChoreTypeEnum] = None
group_id: Optional[int] = 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 # last_completed_at should generally not be updated directly by user
@field_validator('group_id') @model_validator(mode='after')
@classmethod def validate_group_id_with_type(self):
def validate_group_id(cls, v, values): if self.type == ChoreTypeEnum.group and self.group_id is None:
if values.data.get('type') == ChoreTypeEnum.group and v is None:
raise ValueError("group_id is required for group chores") raise ValueError("group_id is required for group chores")
if values.data.get('type') == ChoreTypeEnum.personal and v is not None: if self.type == ChoreTypeEnum.personal and self.group_id is not None:
raise ValueError("group_id must be None for personal chores") # Automatically clear group_id for personal chores instead of raising an error
return v self.group_id = None
return self
class ChorePublic(ChoreBase): class ChorePublic(ChoreBase):
id: int id: int

View File

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

View File

@ -0,0 +1,9 @@
from pydantic import BaseModel
from typing import Union, List
from .expense import ExpensePublic, SettlementPublic
class FinancialActivityResponse(BaseModel):
activities: List[Union[ExpensePublic, SettlementPublic]]
class Config:
orm_mode = True

View File

@ -0,0 +1,35 @@
from pydantic import BaseModel, validator
from typing import Optional, List
from datetime import datetime
class RecurrencePatternBase(BaseModel):
type: str
interval: int = 1
days_of_week: Optional[List[int]] = None
end_date: Optional[datetime] = None
max_occurrences: Optional[int] = None
@validator('type')
def type_must_be_valid(cls, v):
if v not in ['daily', 'weekly', 'monthly', 'yearly']:
raise ValueError("type must be one of 'daily', 'weekly', 'monthly', 'yearly'")
return v
@validator('days_of_week')
def days_of_week_must_be_valid(cls, v):
if v:
for day in v:
if not 0 <= day <= 6:
raise ValueError("days_of_week must be between 0 and 6")
return v
class RecurrencePatternCreate(RecurrencePatternBase):
pass
class RecurrencePatternPublic(RecurrencePatternBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True

View File

@ -0,0 +1,343 @@
# be/app/services/costs_service.py
import logging
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN
from typing import List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
User as UserModel,
Group as GroupModel,
List as ListModel,
Expense as ExpenseModel,
Item as ItemModel,
UserGroup as UserGroupModel,
SplitTypeEnum,
ExpenseSplit as ExpenseSplitModel,
SettlementActivity as SettlementActivityModel,
Settlement as SettlementModel
)
from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement
from app.schemas.expense import ExpenseCreate, ExpensePublic
from app.crud import list as crud_list
from app.crud import expense as crud_expense
from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError, GroupPermissionError, InvalidOperationError
logger = logging.getLogger(__name__)
def calculate_suggested_settlements(user_balances: List[UserBalanceDetail]) -> List[SuggestedSettlement]:
"""
Calculate suggested settlements to balance the finances within a group.
This function takes the current balances of all users and suggests optimal settlements
to minimize the number of transactions needed to settle all debts.
Args:
user_balances: List of UserBalanceDetail objects with their current balances
Returns:
List of SuggestedSettlement objects representing the suggested payments
"""
debtors = []
creditors = []
epsilon = Decimal('0.01')
for user in user_balances:
if abs(user.net_balance) < epsilon:
continue
if user.net_balance < Decimal('0'):
debtors.append({
'user_id': user.user_id,
'user_identifier': user.user_identifier,
'amount': -user.net_balance
})
else:
creditors.append({
'user_id': user.user_id,
'user_identifier': user.user_identifier,
'amount': user.net_balance
})
debtors.sort(key=lambda x: x['amount'], reverse=True)
creditors.sort(key=lambda x: x['amount'], reverse=True)
settlements = []
while debtors and creditors:
debtor = debtors[0]
creditor = creditors[0]
amount = min(debtor['amount'], creditor['amount']).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
if amount > Decimal('0'):
settlements.append(
SuggestedSettlement(
from_user_id=debtor['user_id'],
from_user_identifier=debtor['user_identifier'],
to_user_id=creditor['user_id'],
to_user_identifier=creditor['user_identifier'],
amount=amount
)
)
debtor['amount'] -= amount
creditor['amount'] -= amount
if debtor['amount'] < epsilon:
debtors.pop(0)
if creditor['amount'] < epsilon:
creditors.pop(0)
return settlements
async def get_list_cost_summary_logic(
db: AsyncSession, list_id: int, current_user_id: int
) -> ListCostSummary:
"""
Core logic to retrieve a calculated cost summary for a specific list.
This version does NOT create an expense if one is not found.
"""
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user_id)
list_result = await db.execute(
select(ListModel)
.options(
selectinload(ListModel.items).options(selectinload(ItemModel.added_by_user)),
selectinload(ListModel.group).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user))),
selectinload(ListModel.creator)
)
.where(ListModel.id == list_id)
)
db_list = list_result.scalars().first()
if not db_list:
raise ListNotFoundError(list_id)
expense_result = await db.execute(
select(ExpenseModel)
.where(ExpenseModel.list_id == list_id)
.options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)))
)
db_expense = expense_result.scalars().first()
total_list_cost = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0"))
# If no expense exists or no items with cost, return a summary based on item prices alone.
if not db_expense:
return ListCostSummary(
list_id=db_list.id,
list_name=db_list.name,
total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
num_participating_users=0,
equal_share_per_user=Decimal("0.00"),
user_balances=[]
)
# --- Calculation logic based on existing expense ---
participating_users = set()
user_items_added_value = {}
for item in db_list.items:
if item.price is not None and item.price > Decimal("0") and item.added_by_user:
participating_users.add(item.added_by_user)
user_items_added_value[item.added_by_user.id] = user_items_added_value.get(item.added_by_user.id, Decimal("0.00")) + item.price
for split in db_expense.splits:
if split.user:
participating_users.add(split.user)
num_participating_users = len(participating_users)
if num_participating_users == 0:
return ListCostSummary(
list_id=db_list.id,
list_name=db_list.name,
total_list_cost=total_list_cost.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
num_participating_users=0,
equal_share_per_user=Decimal("0.00"),
user_balances=[]
)
equal_share_per_user_for_response = (db_expense.total_amount / Decimal(num_participating_users)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
sorted_participating_users = sorted(list(participating_users), key=lambda u: u.id)
user_final_shares = {}
if num_participating_users > 0:
base_share_unrounded = db_expense.total_amount / Decimal(num_participating_users)
for user in sorted_participating_users:
user_final_shares[user.id] = base_share_unrounded.quantize(Decimal("0.01"), rounding=ROUND_DOWN)
sum_of_rounded_shares = sum(user_final_shares.values())
remaining_pennies = int(((db_expense.total_amount - sum_of_rounded_shares) * Decimal("100")).to_integral_value(rounding=ROUND_HALF_UP))
for i in range(remaining_pennies):
user_to_adjust = sorted_participating_users[i % num_participating_users]
user_final_shares[user_to_adjust.id] += Decimal("0.01")
user_balances = []
for user in sorted_participating_users:
items_added = user_items_added_value.get(user.id, Decimal("0.00"))
current_user_share = user_final_shares.get(user.id, Decimal("0.00"))
balance = items_added - current_user_share
user_identifier = user.name if user.name else user.email
user_balances.append(
UserCostShare(
user_id=user.id,
user_identifier=user_identifier,
items_added_value=items_added.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
amount_due=current_user_share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
balance=balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
)
user_balances.sort(key=lambda x: x.user_identifier)
return ListCostSummary(
list_id=db_list.id,
list_name=db_list.name,
total_list_cost=db_expense.total_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
num_participating_users=num_participating_users,
equal_share_per_user=equal_share_per_user_for_response,
user_balances=user_balances
)
async def generate_expense_from_list_logic(db: AsyncSession, list_id: int, current_user_id: int) -> ExpenseModel:
"""
Generates and saves an ITEM_BASED expense from a list's items.
"""
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user_id)
# Check if an expense already exists for this list
existing_expense_result = await db.execute(
select(ExpenseModel).where(ExpenseModel.list_id == list_id)
)
if existing_expense_result.scalars().first():
raise InvalidOperationError(f"An expense already exists for list {list_id}.")
db_list = await db.get(ListModel, list_id, options=[selectinload(ListModel.items), selectinload(ListModel.creator)])
if not db_list:
raise ListNotFoundError(list_id)
total_amount = sum(item.price for item in db_list.items if item.price is not None and item.price > Decimal("0"))
if total_amount <= Decimal("0"):
raise InvalidOperationError("Cannot create an expense for a list with no priced items.")
expense_in = ExpenseCreate(
description=f"Cost summary for list {db_list.name}",
total_amount=total_amount,
list_id=list_id,
split_type=SplitTypeEnum.ITEM_BASED,
paid_by_user_id=db_list.creator.id
)
return await crud_expense.create_expense(db=db, expense_in=expense_in, current_user_id=current_user_id)
async def get_group_balance_summary_logic(
db: AsyncSession, group_id: int, current_user_id: int
) -> GroupBalanceSummary:
"""
Core logic to retrieve a detailed financial balance summary for a group.
"""
group_check_result = await db.execute(
select(GroupModel).options(selectinload(GroupModel.member_associations).options(selectinload(UserGroupModel.user)))
.where(GroupModel.id == group_id)
)
db_group = group_check_result.scalars().first()
if not db_group:
raise GroupNotFoundError(group_id)
if not any(assoc.user_id == current_user_id for assoc in db_group.member_associations):
raise GroupPermissionError(group_id, "view balance summary for")
expenses_result = await db.execute(
select(ExpenseModel).where(ExpenseModel.group_id == group_id)
.options(selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user))
)
expenses = expenses_result.scalars().all()
settlements_result = await db.execute(
select(SettlementModel).where(SettlementModel.group_id == group_id)
.options(selectinload(SettlementModel.paid_by_user), selectinload(SettlementModel.paid_to_user))
)
settlements = settlements_result.scalars().all()
settlement_activities_result = await db.execute(
select(SettlementActivityModel)
.join(ExpenseSplitModel, SettlementActivityModel.expense_split_id == ExpenseSplitModel.id)
.join(ExpenseModel, ExpenseSplitModel.expense_id == ExpenseModel.id)
.where(ExpenseModel.group_id == group_id)
.options(selectinload(SettlementActivityModel.payer))
)
settlement_activities = settlement_activities_result.scalars().all()
user_balances_data = {}
for assoc in db_group.member_associations:
if assoc.user:
user_balances_data[assoc.user.id] = {
"user_id": assoc.user.id,
"user_identifier": assoc.user.name if assoc.user.name else assoc.user.email,
"total_paid_for_expenses": Decimal("0.00"),
"initial_total_share_of_expenses": Decimal("0.00"),
"total_amount_paid_via_settlement_activities": Decimal("0.00"),
"total_generic_settlements_paid": Decimal("0.00"),
"total_generic_settlements_received": Decimal("0.00"),
}
for expense in expenses:
if expense.paid_by_user_id in user_balances_data:
user_balances_data[expense.paid_by_user_id]["total_paid_for_expenses"] += expense.total_amount
for split in expense.splits:
if split.user_id in user_balances_data:
user_balances_data[split.user_id]["initial_total_share_of_expenses"] += split.owed_amount
for activity in settlement_activities:
if activity.paid_by_user_id in user_balances_data:
user_balances_data[activity.paid_by_user_id]["total_amount_paid_via_settlement_activities"] += activity.amount_paid
for settlement in settlements:
if settlement.paid_by_user_id in user_balances_data:
user_balances_data[settlement.paid_by_user_id]["total_generic_settlements_paid"] += settlement.amount
if settlement.paid_to_user_id in user_balances_data:
user_balances_data[settlement.paid_to_user_id]["total_generic_settlements_received"] += settlement.amount
final_user_balances = []
for user_id, data in user_balances_data.items():
initial_total_share_of_expenses = data["initial_total_share_of_expenses"]
total_amount_paid_via_settlement_activities = data["total_amount_paid_via_settlement_activities"]
adjusted_total_share_of_expenses = initial_total_share_of_expenses - total_amount_paid_via_settlement_activities
total_paid_for_expenses = data["total_paid_for_expenses"]
total_generic_settlements_received = data["total_generic_settlements_received"]
total_generic_settlements_paid = data["total_generic_settlements_paid"]
net_balance = (
total_paid_for_expenses + total_generic_settlements_received
) - (adjusted_total_share_of_expenses + total_generic_settlements_paid)
user_detail = UserBalanceDetail(
user_id=data["user_id"],
user_identifier=data["user_identifier"],
total_paid_for_expenses=total_paid_for_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
total_share_of_expenses=adjusted_total_share_of_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
total_settlements_paid=total_generic_settlements_paid.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
total_settlements_received=total_generic_settlements_received.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
net_balance=net_balance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
final_user_balances.append(user_detail)
final_user_balances.sort(key=lambda x: x.user_identifier)
suggested_settlements = calculate_suggested_settlements(final_user_balances)
overall_total_expenses = sum(expense.total_amount for expense in expenses)
overall_total_settlements = sum(settlement.amount for settlement in settlements)
return GroupBalanceSummary(
group_id=db_group.id,
group_name=db_group.name,
overall_total_expenses=overall_total_expenses.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
overall_total_settlements=overall_total_settlements.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
user_balances=final_user_balances,
suggested_settlements=suggested_settlements
)

View File

@ -0,0 +1,31 @@
import logging
from typing import List, Union
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Expense as ExpenseModel, Settlement as SettlementModel
from app.crud import expense as crud_expense, settlement as crud_settlement
logger = logging.getLogger(__name__)
async def get_user_financial_activity(
db: AsyncSession, user_id: int
) -> List[Union[ExpenseModel, SettlementModel]]:
"""
Retrieves and merges all financial activities (expenses and settlements) for a user.
The combined list is sorted by date.
"""
# Fetch all accessible expenses
expenses = await crud_expense.get_user_accessible_expenses(db, user_id=user_id, limit=200) # Using a generous limit
# Fetch all settlements involving the user
settlements = await crud_settlement.get_settlements_involving_user(db, user_id=user_id, limit=200) # Using a generous limit
# Combine and sort the activities
# We use a lambda to get the primary date for sorting from either type of object
combined_activity = sorted(
expenses + settlements,
key=lambda x: x.expense_date if isinstance(x, ExpenseModel) else x.settlement_date,
reverse=True
)
return combined_activity

View File

@ -24,6 +24,7 @@ from app.crud.settlement_activity import (
update_expense_overall_status # For direct testing if needed update_expense_overall_status # For direct testing if needed
) )
from app.schemas.settlement_activity import SettlementActivityCreate as SettlementActivityCreateSchema from app.schemas.settlement_activity import SettlementActivityCreate as SettlementActivityCreateSchema
from app.core.exceptions import OverpaymentError
@pytest.fixture @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 # 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 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 # Example of a placeholder for db_session fixture if not provided by conftest.py
# @pytest.fixture # @pytest.fixture
# async def db_session() -> AsyncGenerator[AsyncSession, None]: # async def db_session() -> AsyncGenerator[AsyncSession, None]:

107
docs/chore-system.md Normal file
View 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.

View File

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

View 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.

View File

@ -15,6 +15,8 @@
class="badge badge-overdue">Overdue</span> class="badge badge-overdue">Overdue</span>
<span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due <span v-if="getDueDateStatus(chore) === 'due-today'" class="badge badge-due-today">Due
Today</span> Today</span>
<span v-if="getDueDateStatus(chore) === 'upcoming'" class="badge badge-upcoming">{{
dueInText }}</span>
</div> </div>
</div> </div>
<div v-if="chore.description" class="chore-description">{{ chore.description }}</div> <div v-if="chore.description" class="chore-description">{{ chore.description }}</div>
@ -25,8 +27,9 @@
</div> </div>
</label> </label>
<div class="neo-item-actions"> <div class="neo-item-actions">
<button class="btn btn-sm btn-neutral" @click="toggleTimer" :disabled="chore.is_completed"> <button class="btn btn-sm btn-neutral" @click="toggleTimer"
{{ isActiveTimer ? 'Stop' : 'Start' }} :disabled="chore.is_completed || !chore.current_assignment_id">
{{ isActiveTimer ? '⏸️' : '▶️' }}
</button> </button>
<button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details"> <button class="btn btn-sm btn-neutral" @click="emit('open-details', chore)" title="View Details">
📋 📋
@ -35,10 +38,10 @@
📅 📅
</button> </button>
<button class="btn btn-sm btn-neutral" @click="emit('edit', chore)"> <button class="btn btn-sm btn-neutral" @click="emit('edit', chore)">
Edit
</button> </button>
<button class="btn btn-sm btn-danger" @click="emit('delete', chore)"> <button class="btn btn-sm btn-danger" @click="emit('delete', chore)">
Delete 🗑
</button> </button>
</div> </div>
</div> </div>
@ -55,6 +58,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue'; import { defineProps, defineEmits, computed } from 'vue';
import { formatDistanceToNow, parseISO, isToday, isPast } from 'date-fns';
import type { ChoreWithCompletion } from '../types/chore'; import type { ChoreWithCompletion } from '../types/chore';
import type { TimeEntry } from '../stores/timeEntryStore'; import type { TimeEntry } from '../stores/timeEntryStore';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
@ -83,6 +87,13 @@ const totalTime = computed(() => {
return props.timeEntries.reduce((acc, entry) => acc + (entry.duration_seconds || 0), 0); 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 = () => { const toggleTimer = () => {
if (isActiveTimer.value) { if (isActiveTimer.value) {
emit('stop-timer', props.chore, props.activeTimer!.id); emit('stop-timer', props.chore, props.activeTimer!.id);
@ -326,6 +337,11 @@ export default {
color: white; color: white;
} }
.badge-upcoming {
background-color: #3b82f6;
color: white;
}
.chore-description { .chore-description {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--dark); color: var(--dark);

View File

@ -131,11 +131,29 @@ export const API_ENDPOINTS = {
}, },
CHORES: { CHORES: {
// Generic
ALL: '/chores/all',
BASE: '/chores', BASE: '/chores',
BY_ID: (id: number) => `/chores/${id}`, BY_ID: (id: number) => `/chores/${id}`,
UPDATE_ANY_TYPE: (id: number) => `/chores/${id}`,
HISTORY: (id: number) => `/chores/${id}/history`, HISTORY: (id: number) => `/chores/${id}/history`,
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`, 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}`, 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_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`, TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
}, },

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns' import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService' import { choreService } from '../services/choreService'
@ -11,6 +11,7 @@ import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore'; import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { UserPublic } from '@/types/user';
const { t } = useI18n() const { t } = useI18n()
@ -28,6 +29,7 @@ interface ChoreFormData {
type: 'personal' | 'group'; type: 'personal' | 'group';
group_id: number | undefined; group_id: number | undefined;
parent_chore_id?: number | null; parent_chore_id?: number | null;
assigned_to_user_id?: number | null;
} }
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
@ -45,6 +47,10 @@ const selectedChoreHistory = ref<ChoreHistory[]>([])
const selectedChoreAssignments = ref<ChoreAssignment[]>([]) const selectedChoreAssignments = ref<ChoreAssignment[]>([])
const loadingHistory = ref(false) const loadingHistory = ref(false)
const loadingAssignments = 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 cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0) const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
@ -59,13 +65,14 @@ const initialChoreFormState: ChoreFormData = {
type: 'personal', type: 'personal',
group_id: undefined, group_id: undefined,
parent_chore_id: null, parent_chore_id: null,
assigned_to_user_id: null,
} }
const choreForm = ref({ ...initialChoreFormState }) const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true) const isLoading = ref(true)
const authStore = useAuthStore(); const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore); const { isGuest, user } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore(); const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore); const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
@ -89,17 +96,28 @@ const loadChores = async () => {
try { try {
const fetchedChores = await choreService.getAllChores() const fetchedChores = await choreService.getAllChores()
const currentUserId = user.value?.id ? Number(user.value.id) : null;
const mappedChores = fetchedChores.map(c => { 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 { return {
...c, ...c,
current_assignment_id: currentAssignment?.id ?? null, current_assignment_id: userAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? false, is_completed: userAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? null, completed_at: userAssignment?.completed_at ?? null,
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown', assigned_user_name:
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown', displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
completed_by_name:
displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
updating: false, updating: false,
} } as ChoreWithCompletion;
}); });
chores.value = mappedChores; chores.value = mappedChores;
cachedChores.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(() => { onMounted(() => {
loadChores().then(loadTimeEntries); loadChores().then(loadTimeEntries);
loadGroups() loadGroups()
@ -293,6 +342,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
type: chore.type, type: chore.type,
group_id: chore.group_id ?? undefined, group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id, parent_chore_id: chore.parent_chore_id,
assigned_to_user_id: chore.assigned_to_user_id,
} }
showChoreModal.value = true showChoreModal.value = true
} }
@ -302,10 +352,18 @@ const handleFormSubmit = async () => {
let createdChore; let createdChore;
if (isEditing.value && selectedChore.value) { if (isEditing.value && selectedChore.value) {
const updateData: ChoreUpdate = { ...choreForm.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' }); notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
} else { } else {
const createData = { ...choreForm.value }; 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); createdChore = await choreService.createChore(createData as ChoreCreate);
// Create an assignment for the new chore // Create an assignment for the new chore
@ -400,6 +458,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
const openChoreDetailModal = async (chore: ChoreWithCompletion) => { const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore; selectedChore.value = chore;
showChoreDetailModal.value = true; showChoreDetailModal.value = true;
groupMembers.value = []; // Reset
// Load assignments for this chore // Load assignments for this chore
loadingAssignments.value = true; loadingAssignments.value = true;
@ -414,6 +473,22 @@ const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
} finally { } finally {
loadingAssignments.value = false; 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) => { const openHistoryModal = async (chore: ChoreWithCompletion) => {
@ -479,6 +554,46 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId); 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> </script>
<template> <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> <option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select> </select>
</div> </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"> <div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore') <label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label> }}</label>
@ -700,6 +826,45 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
</div> </div>
</div> </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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button> <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; color: white;
} }
.badge-you {
background-color: var(--secondary);
color: white;
margin-left: 0.5rem;
}
.chore-description { .chore-description {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--dark); color: var(--dark);
@ -1187,4 +1358,38 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
opacity: 0.7; opacity: 0.7;
font-style: italic; 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> </style>

View File

@ -7,6 +7,14 @@
</button> </button>
</header> </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 v-if="loading" class="flex justify-center">
<div class="spinner-dots"> <div class="spinner-dots">
<span></span> <span></span>
@ -38,6 +46,12 @@
<div class="neo-item-content"> <div class="neo-item-content">
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)"> <div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
<span class="checkbox-text-span">{{ expense.description }}</span> <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"> <div class="item-subtext">
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email || Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
'N/A' 'N/A'
@ -64,11 +78,27 @@
expense.split_type.replace('_', ' ') }})</h3> expense.split_type.replace('_', ' ') }})</h3>
<ul class="space-y-1"> <ul class="space-y-1">
<li v-for="split in expense.splits" :key="split.id" <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 <span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|| 'N/A' }} owes</span> || 'N/A' }} owes</span>
<span class="font-mono text-gray-800 font-semibold">{{ <span class="font-mono text-gray-800 font-semibold">{{
formatCurrency(split.owed_amount, expense.currency) }}</span> 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> </li>
</ul> </ul>
</div> </div>
@ -175,60 +205,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService' 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<{ const props = defineProps<{
groupId?: number | string; groupId?: number | string;
}>(); }>();
// Types are kept local to this component // Pinia store for current user context
interface UserPublic { const authStore = useAuthStore()
id: number; const notifStore = useNotificationStore()
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[];
}
// Reactive state collections
const expenses = ref<Expense[]>([]) const expenses = ref<Expense[]>([])
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@ -237,6 +227,24 @@ const showModal = ref(false)
const editingExpense = ref<Expense | null>(null) const editingExpense = ref<Expense | null>(null)
const formError = ref<string | 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 = { const initialFormState: CreateExpenseData = {
description: '', description: '',
total_amount: '', total_amount: '',
@ -252,12 +260,16 @@ const initialFormState: CreateExpenseData = {
const formState = reactive<any>({ ...initialFormState }) const formState = reactive<any>({ ...initialFormState })
const filteredExpenses = computed(() => { const filteredExpenses = computed(() => {
let data = expenses.value
if (props.groupId) { if (props.groupId) {
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId; const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
return expenses.value.filter(expense => expense.group_id === groupIdNum); 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 () => { onMounted(async () => {
try { try {
@ -333,6 +345,15 @@ const getStatusClass = (status: string) => {
return statusMap[status] || 'bg-gray-100 text-gray-800' 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 = () => { const openCreateExpenseModal = () => {
editingExpense.value = null editingExpense.value = null
Object.assign(formState, initialFormState) Object.assign(formState, initialFormState)
@ -351,7 +372,7 @@ const openEditExpenseModal = (expense: Expense) => {
formState.total_amount = expense.total_amount formState.total_amount = expense.total_amount
formState.currency = expense.currency formState.currency = expense.currency
formState.split_type = expense.split_type 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.list_id = expense.list_id
formState.group_id = expense.group_id formState.group_id = expense.group_id
formState.item_id = expense.item_id formState.item_id = expense.item_id
@ -385,13 +406,15 @@ const handleFormSubmit = async () => {
if (index !== -1) { if (index !== -1) {
expenses.value[index] = updatedExpense expenses.value[index] = updatedExpense
} }
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
} else { } else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense; const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense) expenses.value.unshift(newExpense)
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense created', type: 'success' })
} }
closeModal() closeModal()
// re-fetch all expenses to ensure data consistency after create/update
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) { } catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.' formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err) 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. // Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
await expenseService.deleteExpense(expenseId) await expenseService.deleteExpense(expenseId)
expenses.value = expenses.value.filter(e => e.id !== expenseId) expenses.value = expenses.value.filter(e => e.id !== expenseId)
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.' error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err) 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -690,6 +748,11 @@ select.form-input {
background-color: #b91c1c; background-color: #b91c1c;
} }
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.spinner-dots { .spinner-dots {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -758,4 +821,9 @@ select.form-input {
margin-bottom: 1rem; margin-bottom: 1rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.badge-recurring {
background-color: #e0e7ff;
color: #4338ca;
}
</style> </style>

View File

@ -259,21 +259,24 @@ const processListItems = (items: Item[]): ItemWithUI[] => {
}; };
const fetchListDetails = async () => { const fetchListDetails = async () => {
// If we're here for the first time without any cached data, it's an initial load.
if (pageInitialLoad.value) { if (pageInitialLoad.value) {
pageInitialLoad.value = false; pageInitialLoad.value = false;
} }
itemsAreLoading.value = true;
// 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 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 { try {
let response; const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
if (cachedFullData) {
response = { data: JSON.parse(cachedFullData) };
sessionStorage.removeItem(`listDetailFull_${routeId}`);
} else {
response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(routeId));
}
const rawList = response.data as ListWithExpenses; const rawList = response.data as ListWithExpenses;
const localList: List = { const localList: List = {
@ -302,9 +305,6 @@ const fetchListDetails = async () => {
} }
} finally { } finally {
itemsAreLoading.value = false; itemsAreLoading.value = false;
if (!list.value && !error.value) {
pageInitialLoad.value = false;
}
} }
}; };
@ -687,9 +687,9 @@ onMounted(() => {
return; return;
} }
const listShellJSON = sessionStorage.getItem('listDetailShell');
const routeId = String(route.params.id); const routeId = String(route.params.id);
const listShellJSON = sessionStorage.getItem('listDetailShell');
if (listShellJSON) { if (listShellJSON) {
const shellData = JSON.parse(listShellJSON); const shellData = JSON.parse(listShellJSON);
if (shellData.id === parseInt(routeId, 10)) { 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) // Fetch categories relevant to the list (either personal or group)
categoryStore.fetchCategories(list.value?.group_id); categoryStore.fetchCategories(list.value?.group_id);

View File

@ -32,11 +32,11 @@
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id"> @touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header"> <div class="neo-list-header">
<span>{{ list.name }}</span> <span>{{ list.name }}</span>
<div class="actions"> <!-- <div class="actions">
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm" <VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
icon="archive" /> icon="archive" />
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" /> <VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
</div> </div> -->
</div> </div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div> <div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list"> <ul class="neo-item-list">
@ -214,6 +214,12 @@ const loadCachedData = () => {
const fetchLists = async () => { const fetchLists = async () => {
error.value = null; 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 { try {
const endpoint = currentGroupId.value const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value)) ? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
@ -228,7 +234,10 @@ const fetchLists = async () => {
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed'); error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err); 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 = []; if (cachedLists.value.length === 0) lists.value = [];
} finally {
loading.value = false;
} }
}; };
@ -245,7 +254,11 @@ const fetchArchivedLists = async () => {
}; };
const fetchListsAndGroups = async () => { const fetchListsAndGroups = async () => {
loading.value = true; // Only show loading if we don't have cached data
if (lists.value.length === 0) {
loading.value = true;
}
try { try {
await Promise.all([ await Promise.all([
fetchLists(), fetchLists(),

View File

@ -1,8 +1,12 @@
import { choreService } from '../choreService'; // Adjust path /// <reference types="vitest" />
import { api } from '../api'; // Actual axios instance from api.ts // @ts-nocheck
import { choreService } from '../choreService';
import { api } from '../api';
import { groupService } from '../groupService'; import { groupService } from '../groupService';
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType } from '../../types/chore'; // Adjust path import { API_ENDPOINTS } from '@/services/api';
import type { Group } from '../groupService'; 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) // Mock the api module (axios instance)
vi.mock('../api', () => ({ vi.mock('../api', () => ({
@ -36,7 +40,7 @@ describe('Chore Service', () => {
it('should fetch personal chores successfully', async () => { it('should fetch personal chores successfully', async () => {
mockApi.get.mockResolvedValue({ data: [mockPersonalChore] }); mockApi.get.mockResolvedValue({ data: [mockPersonalChore] });
const chores = await choreService.getPersonalChores(); 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]); expect(chores).toEqual([mockPersonalChore]);
}); });
@ -52,7 +56,7 @@ describe('Chore Service', () => {
it('should fetch chores for a specific group successfully', async () => { it('should fetch chores for a specific group successfully', async () => {
mockApi.get.mockResolvedValue({ data: [mockGroupChore] }); mockApi.get.mockResolvedValue({ data: [mockGroupChore] });
const chores = await choreService.getChores(groupId); 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]); expect(chores).toEqual([mockGroupChore]);
}); });
@ -70,7 +74,7 @@ describe('Chore Service', () => {
mockApi.post.mockResolvedValue({ data: createdChore }); mockApi.post.mockResolvedValue({ data: createdChore });
const result = await choreService.createChore(newPersonalChore); 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); expect(result).toEqual(createdChore);
}); });
@ -80,7 +84,7 @@ describe('Chore Service', () => {
mockApi.post.mockResolvedValue({ data: createdChore }); mockApi.post.mockResolvedValue({ data: createdChore });
const result = await choreService.createChore(newGroupChore); 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); expect(result).toEqual(createdChore);
}); });
@ -109,28 +113,51 @@ describe('Chore Service', () => {
mockApi.put.mockResolvedValue({ data: responseChore }); mockApi.put.mockResolvedValue({ data: responseChore });
const result = await choreService.updateChore(choreId, updatedPersonalChoreData); 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); expect(result).toEqual(responseChore);
}); });
it('should update a group chore', async () => { it('should update a group chore', async () => {
const updatedGroupChoreData: ChoreUpdate = { name: 'Updated Group', type: 'group', group_id: 10, description: 'new desc' }; 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 }; const responseChore = { ...updatedGroupChoreData, id: choreId };
mockApi.put.mockResolvedValue({ data: responseChore }); mockApi.put.mockResolvedValue({ data: responseChore });
const result = await choreService.updateChore(choreId, updatedGroupChoreData); const result = await choreService.updateChore(choreId, updatedGroupChoreData, originalChore);
expect(mockApi.put).toHaveBeenCalledWith(`/api/v1/chores/groups/${updatedGroupChoreData.group_id}/chores/${choreId}`, updatedGroupChoreData); expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(updatedGroupChoreData.group_id, choreId), updatedGroupChoreData);
expect(result).toEqual(responseChore); 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 () => { it('should throw error for invalid type or missing group_id for group chore update', async () => {
// @ts-expect-error testing invalid type // @ts-expect-error testing invalid type
const invalidTypeUpdate: ChoreUpdate = { name: 'Invalid', type: 'unknown' }; 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' }; 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 () => { it('should propagate API error on failure', async () => {
const updatedChoreData: ChoreUpdate = { name: 'Test Update', type: 'personal' }; const updatedChoreData: ChoreUpdate = { name: 'Test Update', type: 'personal' };
@ -145,22 +172,22 @@ describe('Chore Service', () => {
it('should delete a personal chore', async () => { it('should delete a personal chore', async () => {
mockApi.delete.mockResolvedValue({ data: null }); // delete often returns no content mockApi.delete.mockResolvedValue({ data: null }); // delete often returns no content
await choreService.deleteChore(choreId, 'personal'); 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 () => { it('should delete a group chore', async () => {
const groupId = 10; const groupId = 10;
mockApi.delete.mockResolvedValue({ data: null }); mockApi.delete.mockResolvedValue({ data: null });
await choreService.deleteChore(choreId, 'group', groupId); 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 () => { it('should throw error for invalid type or missing group_id for group chore deletion', async () => {
// @ts-expect-error testing invalid type // @ts-expect-error testing invalid type
await expect(choreService.deleteChore(choreId, 'unknown')).rejects.toThrow('Invalid chore type or missing group_id for group chore deletion'); await expect(choreService.deleteChore(choreId, 'unknown')).rejects.toThrow('Invalid chore type or missing group_id for group chore deletion');
await expect(choreService.deleteChore(choreId, 'group')).rejects.toThrow('Invalid chore type or missing group_id for group chore deletion'); // Missing groupId await expect(choreService.deleteChore(choreId, 'group')).rejects.toThrow('Invalid chore type or missing group_id for group chore deletion'); // Missing groupId
}); });
it('should propagate API error on failure', async () => { it('should propagate API error on failure', async () => {
const error = new Error('API Delete Failed'); const error = new Error('API Delete Failed');
@ -174,16 +201,16 @@ describe('Chore Service', () => {
{ id: 1, name: 'Family', members: [], owner_id: 1 }, { id: 1, name: 'Family', members: [], owner_id: 1 },
{ id: 2, name: 'Work', members: [], owner_id: 1 }, { id: 2, name: 'Work', members: [], owner_id: 1 },
]; ];
const mockPersonalChores: Chore[] = [{ id: 100, name: 'My Laundry', type: 'personal', description:'' }]; const mockPersonalChores: Chore[] = [{ id: 100, name: 'My Laundry', type: 'personal', description: '' }];
const mockFamilyChores: Chore[] = [{ id: 101, name: 'Clean Kitchen', type: 'group', group_id: 1, description:'' }]; const mockFamilyChores: Chore[] = [{ id: 101, name: 'Clean Kitchen', type: 'group', group_id: 1, description: '' }];
const mockWorkChores: Chore[] = [{ id: 102, name: 'Team Meeting Prep', type: 'group', group_id: 2, description:'' }]; const mockWorkChores: Chore[] = [{ id: 102, name: 'Team Meeting Prep', type: 'group', group_id: 2, description: '' }];
beforeEach(() => { beforeEach(() => {
// Mock the direct API calls made by getPersonalChores and getChores // Mock the direct API calls made by getPersonalChores and getChores
// if we are not spying/mocking those specific choreService methods. // if we are not spying/mocking those specific choreService methods.
// Here, we assume choreService.getPersonalChores and choreService.getChores are called, // Here, we assume choreService.getPersonalChores and choreService.getChores are called,
// so we can mock them or let them call the mocked api. For simplicity, let them call mocked api. // so we can mock them or let them call the mocked api. For simplicity, let them call mocked api.
}); });
it('should fetch all personal and group chores successfully', async () => { it('should fetch all personal and group chores successfully', async () => {
mockApi.get mockApi.get
@ -194,45 +221,45 @@ describe('Chore Service', () => {
const allChores = await choreService.getAllChores(); 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(mockGroupService.getUserGroups).toHaveBeenCalled();
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${mockUserGroups[0].id}/chores`); expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(mockUserGroups[0].id));
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${mockUserGroups[1].id}/chores`); expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(mockUserGroups[1].id));
expect(allChores).toEqual([...mockPersonalChores, ...mockFamilyChores, ...mockWorkChores]); expect(allChores).toEqual([...mockPersonalChores, ...mockFamilyChores, ...mockWorkChores]);
}); });
it('should return partial results if fetching chores for one group fails', async () => { it('should return partial results if fetching chores for one group fails', async () => {
const groupFetchError = new Error('Failed to fetch group 2 chores'); const groupFetchError = new Error('Failed to fetch group 2 chores');
mockApi.get mockApi.get
.mockResolvedValueOnce({ data: mockPersonalChores }) // Personal chores .mockResolvedValueOnce({ data: mockPersonalChores }) // Personal chores
.mockResolvedValueOnce({ data: mockFamilyChores }) // Group 1 chores .mockResolvedValueOnce({ data: mockFamilyChores }) // Group 1 chores
.mockRejectedValueOnce(groupFetchError); // Group 2 chores fail .mockRejectedValueOnce(groupFetchError); // Group 2 chores fail
mockGroupService.getUserGroups.mockResolvedValue(mockUserGroups); mockGroupService.getUserGroups.mockResolvedValue(mockUserGroups);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
const allChores = await choreService.getAllChores();
expect(allChores).toEqual([...mockPersonalChores, ...mockFamilyChores]);
expect(consoleErrorSpy).toHaveBeenCalledWith(`Failed to get chores for group ${mockUserGroups[1].id} (${mockUserGroups[1].name}):`, groupFetchError);
consoleErrorSpy.mockRestore();
});
it('should propagate error if getPersonalChores fails', async () => {
const personalFetchError = new Error('Failed to fetch personal chores');
mockApi.get.mockRejectedValueOnce(personalFetchError); // getPersonalChores fails
mockGroupService.getUserGroups.mockResolvedValue(mockUserGroups); // This might not even be called
await expect(choreService.getAllChores()).rejects.toThrow(personalFetchError);
expect(mockGroupService.getUserGroups).not.toHaveBeenCalled(); // Or it might, depending on Promise.all behavior if not used
});
it('should propagate error if getUserGroups fails', async () => { const allChores = await choreService.getAllChores();
const groupsFetchError = new Error('Failed to fetch groups');
// getPersonalChores might succeed or fail, let's say it succeeds for this test expect(allChores).toEqual([...mockPersonalChores, ...mockFamilyChores]);
mockApi.get.mockResolvedValueOnce({ data: mockPersonalChores }); expect(consoleErrorSpy).toHaveBeenCalledWith(`Failed to get chores for group ${mockUserGroups[1].id} (${mockUserGroups[1].name}):`, groupFetchError);
mockGroupService.getUserGroups.mockRejectedValue(groupsFetchError); consoleErrorSpy.mockRestore();
});
await expect(choreService.getAllChores()).rejects.toThrow(groupsFetchError);
}); it('should propagate error if getPersonalChores fails', async () => {
const personalFetchError = new Error('Failed to fetch personal chores');
mockApi.get.mockRejectedValueOnce(personalFetchError); // getPersonalChores fails
mockGroupService.getUserGroups.mockResolvedValue(mockUserGroups); // This might not even be called
await expect(choreService.getAllChores()).rejects.toThrow(personalFetchError);
expect(mockGroupService.getUserGroups).not.toHaveBeenCalled(); // Or it might, depending on Promise.all behavior if not used
});
it('should propagate error if getUserGroups fails', async () => {
const groupsFetchError = new Error('Failed to fetch groups');
// getPersonalChores might succeed or fail, let's say it succeeds for this test
mockApi.get.mockResolvedValueOnce({ data: mockPersonalChores });
mockGroupService.getUserGroups.mockRejectedValue(groupsFetchError);
await expect(choreService.getAllChores()).rejects.toThrow(groupsFetchError);
});
}); });
}); });

View File

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

View File

@ -1,13 +1,21 @@
import { api } from './api' import { api, apiClient, API_ENDPOINTS } from '@/services/api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore' import type {
Chore,
ChoreCreate,
ChoreUpdate,
ChoreType,
ChoreAssignment,
ChoreAssignmentCreate,
ChoreAssignmentUpdate,
ChoreHistory,
} from '@/types/chore'
import { groupService } from './groupService' import { groupService } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { Group } from '@/types/group' import type { Group } from '@/types/group'
export const choreService = { export const choreService = {
async getAllChores(): Promise<Chore[]> { async getAllChores(): Promise<Chore[]> {
try { try {
const response = await api.get('/chores/all') const response = await api.get(API_ENDPOINTS.CHORES.ALL)
return response.data return response.data
} catch (error) { } catch (error) {
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', 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[]> { 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 return response.data
}, },
async createChore(chore: ChoreCreate): Promise<Chore> { async createChore(chore: ChoreCreate): Promise<Chore> {
if (chore.type === 'personal') { 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 return response.data
} else if (chore.type === 'group' && chore.group_id) { } 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 return response.data
} else { } else {
throw new Error('Invalid chore type or missing group_id for group chore') throw new Error('Invalid chore type or missing group_id for group chore')
} }
}, },
async updateChore(choreId: number, chore: ChoreUpdate): Promise<Chore> { async updateChore(choreId: number, chore: ChoreUpdate, originalChore?: Chore): Promise<Chore> {
if (chore.type === 'personal') { // Check if this is a type conversion (personal to group or group to personal)
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore) 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 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( const response = await api.put(
`/api/v1/chores/groups/${chore.group_id}/chores/${choreId}`, API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(currentGroupId, choreId),
chore, chore,
) )
return response.data return response.data
} else { } 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> { async deleteChore(choreId: number, choreType: ChoreType, groupId?: number): Promise<void> {
if (choreType === 'personal') { 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) { } 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 { } else {
throw new Error('Invalid chore type or missing group_id for group chore deletion') throw new Error('Invalid chore type or missing group_id for group chore deletion')
} }
}, },
async getPersonalChores(): Promise<Chore[]> { 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 return response.data
}, },
async createAssignment(assignment: ChoreAssignmentCreate): Promise<ChoreAssignment> { 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 return response.data
}, },
async getMyAssignments(includeCompleted: boolean = false): Promise<ChoreAssignment[]> { 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 return response.data
}, },
async getChoreAssignments(choreId: number): Promise<ChoreAssignment[]> { 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 return response.data
}, },
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> { 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 return response.data
}, },
async deleteAssignment(assignmentId: number): Promise<void> { 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> { 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 return response.data
}, },
@ -118,21 +141,21 @@ export const choreService = {
choreId: number, choreId: number,
chore: ChoreUpdate, chore: ChoreUpdate,
): Promise<Chore> { ): 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 return response.data
}, },
async _original_deleteGroupChore(groupId: number, choreId: number): Promise<void> { 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> { 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 return response.data
}, },
async _deletePersonalChore(choreId: number): Promise<void> { 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[]> { async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {

View File

@ -32,7 +32,23 @@ export interface UpdateExpenseData extends Partial<CreateExpenseData> {
export const expenseService = { export const expenseService = {
async createExpense(data: CreateExpenseData): Promise<Expense> { 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 return response.data
}, },

View File

@ -1,9 +1,10 @@
import { apiClient, API_ENDPOINTS } from '@/services/api'; 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 { ChoreHistory } from '@/types/chore';
import type { UserPublic } from '@/types/user';
export const groupService = { export const groupService = {
async getUserGroups(): Promise<Group[]> { async getUserGroups(): Promise<GroupPublic[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE); const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
return response.data; return response.data;
}, },
@ -22,9 +23,18 @@ export const groupService = {
return response.data; 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); const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
return response.data; 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;
},
};

View File

@ -117,6 +117,7 @@ export interface Expense {
overall_settlement_status: ExpenseOverallStatusEnum overall_settlement_status: ExpenseOverallStatusEnum
isRecurring: boolean isRecurring: boolean
is_recurring?: boolean
nextOccurrence?: string nextOccurrence?: string
lastOccurrence?: string lastOccurrence?: string
recurrencePattern?: RecurrencePattern recurrencePattern?: RecurrencePattern

View File

@ -1,6 +1,7 @@
// fe/src/types/group.ts // fe/src/types/group.ts
import type { AuthState } from '@/stores/auth'; import type { AuthState } from '@/stores/auth';
import type { ChoreHistory } from './chore'; import type { ChoreHistory } from './chore';
import type { UserPublic } from './user';
export interface Group { export interface Group {
id: number; id: number;
@ -10,3 +11,24 @@ export interface Group {
members: AuthState['user'][]; members: AuthState['user'][];
chore_history?: ChoreHistory[]; 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;
}

View File

@ -3,6 +3,7 @@
export interface UserPublic { export interface UserPublic {
id: number; id: number;
name?: string | null; name?: string | null;
full_name?: string | null; // Alias provided by backend in many responses
email: string; email: string;
// Add other relevant public user fields if necessary // Add other relevant public user fields if necessary
} }