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 app.database import get_transactional_session
from app.models import User
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy
from app.auth import oauth, fastapi_users, auth_backend, get_jwt_strategy, get_refresh_jwt_strategy, get_user_manager
from app.config import settings
from fastapi.security import OAuth2PasswordRequestForm
@ -88,16 +88,33 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
return RedirectResponse(url=redirect_url)
@router.post('/jwt/refresh')
async def refresh_jwt_token(request: Request):
async def refresh_jwt_token(
request: Request,
user_manager=Depends(get_user_manager),
):
"""Refresh the JWT access token using a valid refresh token.
The incoming request must provide a JSON body with a ``refresh_token`` field.
A new access and refresh token pair will be returned when the provided refresh
token is valid. If the token is invalid or expired, a *401* error is raised.
"""
data = await request.json()
refresh_token = data.get('refresh_token')
if not refresh_token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
refresh_strategy = get_refresh_jwt_strategy()
try:
user = await refresh_strategy.read_token(refresh_token, None)
# ``read_token`` needs a callback capable of loading the *User* from the
# database. We therefore pass the user manager obtained via dependency
# injection so that the strategy can hydrate the full *User* instance.
user = await refresh_strategy.read_token(refresh_token, user_manager)
except Exception:
# Any error during decoding or lookup should result in an unauthorized
# response to avoid leaking information about token validity.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
if not user:
@ -106,6 +123,7 @@ async def refresh_jwt_token(request: Request):
access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user)
new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({
"access_token": access_token,
"refresh_token": new_refresh_token,

View File

@ -18,6 +18,7 @@ from app.schemas.chore import (
from app.schemas.time_entry import TimeEntryPublic
from app.crud import chore as crud_chore
from app.crud import history as crud_history
from app.crud import group as crud_group
from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, GroupNotFoundError, DatabaseIntegrityError
logger = logging.getLogger(__name__)
@ -122,6 +123,66 @@ async def update_personal_chore(
logger.error(f"DatabaseIntegrityError updating personal chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.put(
"/{chore_id}",
response_model=ChorePublic,
summary="Update Chore (Any Type)",
tags=["Chores"]
)
async def update_chore_any_type(
chore_id: int,
chore_in: ChoreUpdate,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Updates a chore of any type, including conversions between personal and group chores."""
logger.info(f"User {current_user.email} updating chore ID: {chore_id}")
# Get the current chore to determine its type and group
current_chore = await crud_chore.get_chore_by_id(db, chore_id)
if not current_chore:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chore {chore_id} not found")
# Check permissions on the current chore
if current_chore.type == ChoreTypeEnum.personal:
if current_chore.created_by_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only update your own personal chores")
else: # group chore
if not await crud_group.is_user_member(db, current_chore.group_id, current_user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not a member of group {current_chore.group_id}")
# If converting to group chore, validate the target group
if chore_in.type == ChoreTypeEnum.group and chore_in.group_id:
if not await crud_group.is_user_member(db, chore_in.group_id, current_user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"You are not a member of target group {chore_in.group_id}")
try:
# Use the current group_id for the update call if not changing groups
group_id_for_update = current_chore.group_id if current_chore.type == ChoreTypeEnum.group else None
updated_chore = await crud_chore.update_chore(
db=db,
chore_id=chore_id,
chore_in=chore_in,
user_id=current_user.id,
group_id=group_id_for_update
)
if not updated_chore:
raise ChoreNotFoundError(chore_id=chore_id)
return updated_chore
except ChoreNotFoundError as e:
logger.warning(f"Chore {e.chore_id} not found for user {current_user.email} during update.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
except PermissionDeniedError as e:
logger.warning(f"Permission denied for user {current_user.email} updating chore {chore_id}: {e.detail}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.detail)
except ValueError as e:
logger.warning(f"ValueError updating chore {chore_id} for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except DatabaseIntegrityError as e:
logger.error(f"DatabaseIntegrityError updating chore {chore_id} for {current_user.email}: {e.detail}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
@router.delete(
"/personal/{chore_id}",
status_code=status.HTTP_204_NO_CONTENT,
@ -180,7 +241,7 @@ async def create_group_chore(
chore_payload = chore_in.model_copy(update={"group_id": group_id, "type": ChoreTypeEnum.group})
try:
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
return await crud_chore.create_chore(db=db, chore_in=chore_payload, user_id=current_user.id)
except GroupNotFoundError as e:
logger.warning(f"Group {e.group_id} not found for chore creation by user {current_user.email}.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.detail)
@ -226,17 +287,16 @@ async def update_group_chore(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
):
"""Updates a chore's details within a specific group."""
"""Updates a chore's details. The group_id in path is the current group of the chore."""
logger.info(f"User {current_user.email} updating chore ID {chore_id} in group {group_id}")
if chore_in.type is not None and chore_in.type != ChoreTypeEnum.group:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change chore type to personal via this endpoint.")
if chore_in.group_id is not None and chore_in.group_id != group_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
# Validate that the chore is in the specified group
chore_to_update = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id)
if not chore_to_update:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Chore {chore_id} not found in group {group_id}")
try:
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_payload, user_id=current_user.id, group_id=group_id)
updated_chore = await crud_chore.update_chore(db=db, chore_id=chore_id, chore_in=chore_in, user_id=current_user.id, group_id=group_id)
if not updated_chore:
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return updated_chore
@ -337,7 +397,7 @@ async def list_my_assignments(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve assignments")
@router.get(
"/chores/{chore_id}/assignments",
"/{chore_id}/assignments",
response_model=PyList[ChoreAssignmentPublic],
summary="List Chore Assignments",
tags=["Chore Assignments"]

View File

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

View File

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

View File

@ -258,14 +258,30 @@ class InviteOperationError(HTTPException):
class SettlementOperationError(HTTPException):
"""Raised when a settlement operation fails."""
def __init__(self, detail: str):
def __init__(self, detail: str = "An error occurred during a settlement operation."):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail
)
class FinancialConflictError(HTTPException):
"""Raised when a financial operation conflicts with business logic."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
detail=detail
)
class OverpaymentError(HTTPException):
"""Raised when a settlement activity would cause overpayment."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail
)
class ConflictError(HTTPException):
"""Raised when an optimistic lock version conflict occurs."""
"""Raised when a conflict occurs."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
@ -343,6 +359,10 @@ class ChoreOperationError(HTTPException):
class ChoreNotFoundError(HTTPException):
"""Raised when a chore or assignment is not found."""
def __init__(self, chore_id: int = None, assignment_id: int = None, group_id: Optional[int] = None, detail: Optional[str] = None):
self.chore_id = chore_id
self.assignment_id = assignment_id
self.group_id = group_id
if detail:
error_detail = detail
elif group_id is not None:

43
be/app/core/middleware.py Normal file
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())
db.add(log_entry)
await db.commit()
await db.refresh(log_entry)
await db.flush()
return log_entry
async def get_financial_audit_logs_for_group(db: AsyncSession, *, group_id: int, skip: int = 0, limit: int = 100) -> List[FinancialAuditLog]:

View File

@ -1,15 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import union_all
from sqlalchemy.orm import selectinload, subqueryload
from sqlalchemy import union_all, and_, or_, delete
from typing import List, Optional
import logging
from datetime import date, datetime
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum
from app.models import Chore, Group, User, ChoreAssignment, ChoreFrequencyEnum, ChoreTypeEnum, UserGroup, ChoreHistoryEventTypeEnum, UserRoleEnum, ChoreHistory, ChoreAssignmentHistory
from app.schemas.chore import ChoreCreate, ChoreUpdate, ChoreAssignmentCreate, ChoreAssignmentUpdate
from app.core.chore_utils import calculate_next_due_date
from app.crud.group import get_group_by_id, is_user_member
from app.crud.group import get_group_by_id, is_user_member, get_user_role_in_group
from app.crud.history import create_chore_history_entry, create_assignment_history_entry
from app.core.exceptions import ChoreNotFoundError, GroupNotFoundError, PermissionDeniedError, DatabaseIntegrityError
@ -70,24 +70,37 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
async def create_chore(
db: AsyncSession,
chore_in: ChoreCreate,
user_id: int,
group_id: Optional[int] = None
user_id: int
) -> Chore:
"""Creates a new chore, either personal or within a specific group."""
"""Creates a new chore, and if specified, an assignment for it."""
async with db.begin_nested() if db.in_transaction() else db.begin():
# Validate chore type and group
if chore_in.type == ChoreTypeEnum.group:
if not group_id:
if not chore_in.group_id:
raise ValueError("group_id is required for group chores")
group = await get_group_by_id(db, group_id)
group = await get_group_by_id(db, chore_in.group_id)
if not group:
raise GroupNotFoundError(group_id)
if not await is_user_member(db, group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
raise GroupNotFoundError(chore_in.group_id)
if not await is_user_member(db, chore_in.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore_in.group_id}")
else: # personal chore
if group_id:
if chore_in.group_id:
raise ValueError("group_id must be None for personal chores")
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'group_id'})
# Validate assigned user if provided
if chore_in.assigned_to_user_id:
if chore_in.type == ChoreTypeEnum.group:
# For group chores, assigned user must be a member of the group
if not await is_user_member(db, chore_in.group_id, chore_in.assigned_to_user_id):
raise PermissionDeniedError(detail=f"Assigned user {chore_in.assigned_to_user_id} is not a member of group {chore_in.group_id}")
else: # Personal chore
# For personal chores, you can only assign it to yourself
if chore_in.assigned_to_user_id != user_id:
raise PermissionDeniedError(detail="Personal chores can only be assigned to the creator.")
assigned_user_id = chore_in.assigned_to_user_id
chore_data = chore_in.model_dump(exclude_unset=True, exclude={'assigned_to_user_id'})
if 'parent_chore_id' in chore_data and chore_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, chore_data['parent_chore_id'])
if not parent_chore:
@ -95,7 +108,6 @@ async def create_chore(
db_chore = Chore(
**chore_data,
group_id=group_id,
created_by_id=user_id,
)
@ -105,6 +117,24 @@ async def create_chore(
db.add(db_chore)
await db.flush()
# Create an assignment if a user was specified
if assigned_user_id:
assignment = ChoreAssignment(
chore_id=db_chore.id,
assigned_to_user_id=assigned_user_id,
due_date=db_chore.next_due_date,
is_complete=False
)
db.add(assignment)
await db.flush() # Flush to get the assignment ID
await create_assignment_history_entry(
db,
assignment_id=assignment.id,
event_type=ChoreHistoryEventTypeEnum.ASSIGNED,
changed_by_user_id=user_id,
event_data={'assigned_to': assigned_user_id}
)
await create_chore_history_entry(
db,
chore_id=db_chore.id,
@ -137,14 +167,7 @@ async def get_chore_by_id(db: AsyncSession, chore_id: int) -> Optional[Chore]:
result = await db.execute(
select(Chore)
.where(Chore.id == chore_id)
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.options(*get_chore_loader_options())
)
return result.scalar_one_or_none()
@ -174,13 +197,7 @@ async def get_personal_chores(
Chore.created_by_id == user_id,
Chore.type == ChoreTypeEnum.personal
)
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.options(*get_chore_loader_options())
.order_by(Chore.next_due_date, Chore.name)
)
return result.scalars().all()
@ -200,13 +217,7 @@ async def get_chores_by_group_id(
Chore.group_id == group_id,
Chore.type == ChoreTypeEnum.group
)
.options(
selectinload(Chore.creator),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.options(*get_chore_loader_options())
.order_by(Chore.next_due_date, Chore.name)
)
return result.scalars().all()
@ -226,21 +237,37 @@ async def update_chore(
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
# Check permissions for current chore
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
if not await is_user_member(db, group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
if db_chore.group_id != group_id:
raise ChoreNotFoundError(chore_id, group_id)
if not await is_user_member(db, db_chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of chore's current group {db_chore.group_id}")
else:
if group_id:
raise ValueError("group_id must be None for personal chores")
if db_chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can update personal chores")
update_data = chore_in.model_dump(exclude_unset=True)
# Handle group changes
if 'group_id' in update_data:
new_group_id = update_data['group_id']
if new_group_id != db_chore.group_id:
# Validate user has permission for the new group
if new_group_id is not None:
if not await is_user_member(db, new_group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of target group {new_group_id}")
# Handle type changes
if 'type' in update_data:
new_type = update_data['type']
# When changing to personal, always clear group_id regardless of what's in update_data
if new_type == ChoreTypeEnum.personal:
update_data['group_id'] = None
else:
# For group chores, use the provided group_id or keep the current one
new_group_id = update_data.get('group_id', db_chore.group_id)
if new_type == ChoreTypeEnum.group and new_group_id is None:
raise ValueError("group_id is required for group chores")
if 'parent_chore_id' in update_data:
if update_data['parent_chore_id']:
parent_chore = await get_chore_by_id(db, update_data['parent_chore_id'])
@ -249,13 +276,6 @@ async def update_chore(
# Setting parent_chore_id to None is allowed
setattr(db_chore, 'parent_chore_id', update_data['parent_chore_id'])
if 'type' in update_data:
new_type = update_data['type']
if new_type == ChoreTypeEnum.group and not group_id:
raise ValueError("group_id is required for group chores")
if new_type == ChoreTypeEnum.personal and group_id:
raise ValueError("group_id must be None for personal chores")
# Recalculate next_due_date if needed
recalculate = False
if 'frequency' in update_data and update_data['frequency'] != db_chore.frequency:
@ -304,14 +324,7 @@ async def update_chore(
result = await db.execute(
select(Chore)
.where(Chore.id == db_chore.id)
.options(
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.assignments).selectinload(ChoreAssignment.history),
selectinload(Chore.history),
selectinload(Chore.child_chores)
)
.options(*get_chore_loader_options())
)
return result.scalar_one()
except Exception as e:
@ -398,12 +411,7 @@ async def create_chore_assignment(
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
.options(*get_assignment_loader_options())
)
return result.scalar_one()
except Exception as e:
@ -415,12 +423,7 @@ async def get_chore_assignment_by_id(db: AsyncSession, assignment_id: int) -> Op
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
.options(*get_assignment_loader_options())
)
return result.scalar_one_or_none()
@ -435,12 +438,7 @@ async def get_user_assignments(
if not include_completed:
query = query.where(ChoreAssignment.is_complete == False)
query = query.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
query = query.options(*get_assignment_loader_options()).order_by(ChoreAssignment.due_date, ChoreAssignment.id)
result = await db.execute(query)
return result.scalars().all()
@ -465,12 +463,7 @@ async def get_chore_assignments(
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.chore_id == chore_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.creator),
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history)
)
.options(*get_assignment_loader_options())
.order_by(ChoreAssignment.due_date, ChoreAssignment.id)
)
return result.scalars().all()
@ -510,12 +503,13 @@ async def update_chore_assignment(
history_event = ChoreHistoryEventTypeEnum.COMPLETED
# Advance the next_due_date of the parent chore
if db_assignment.chore:
if db_assignment.chore and db_assignment.chore.frequency != ChoreFrequencyEnum.one_time:
db_assignment.chore.last_completed_at = db_assignment.completed_at
db_assignment.chore.next_due_date = calculate_next_due_date(
db_assignment.chore.frequency,
db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else date.today(),
db_assignment.chore.custom_interval_days
current_due_date=db_assignment.chore.next_due_date,
frequency=db_assignment.chore.frequency,
custom_interval_days=db_assignment.chore.custom_interval_days,
last_completed_date=db_assignment.chore.last_completed_at.date() if db_assignment.chore.last_completed_at else None
)
elif not new_status and original_status:
db_assignment.completed_at = None
@ -537,10 +531,7 @@ async def update_chore_assignment(
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == assignment_id)
.options(
selectinload(ChoreAssignment.chore).selectinload(Chore.child_chores),
selectinload(ChoreAssignment.assigned_user)
)
.options(*get_assignment_loader_options())
)
return result.scalar_one()
except Exception as e:
@ -585,3 +576,25 @@ async def delete_chore_assignment(
except Exception as e:
logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True)
raise DatabaseIntegrityError(f"Could not delete chore assignment {assignment_id}. Error: {str(e)}")
def get_chore_loader_options():
"""Returns a list of SQLAlchemy loader options for chore relationships."""
return [
selectinload(Chore.creator),
selectinload(Chore.group),
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.history).selectinload(ChoreHistory.changed_by_user),
selectinload(Chore.child_chores).options(
selectinload(Chore.assignments).selectinload(ChoreAssignment.assigned_user),
selectinload(Chore.history).selectinload(ChoreHistory.changed_by_user),
selectinload(Chore.creator),
selectinload(Chore.child_chores) # Load grandchildren, adjust depth if needed
)
]
def get_assignment_loader_options():
return [
selectinload(ChoreAssignment.assigned_user),
selectinload(ChoreAssignment.history).selectinload(ChoreAssignmentHistory.changed_by_user),
selectinload(ChoreAssignment.chore).options(*get_chore_loader_options())
]

View File

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError # Added import
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation as DecimalInvalidOperation
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, InvalidOperation as DecimalInvalidOperation
from typing import Callable, List as PyList, Optional, Sequence, Dict, defaultdict, Any
from datetime import datetime, timezone # Added timezone
import json
@ -20,6 +20,7 @@ from app.models import (
Item as ItemModel,
ExpenseOverallStatusEnum, # Added
ExpenseSplitStatusEnum, # Added
RecurrenceTypeEnum,
)
from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate, ExpenseUpdate # Removed unused ExpenseUpdate
from app.core.exceptions import (
@ -147,13 +148,26 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us
# Re-resolve context if list_id was derived from item
final_group_id = await _resolve_expense_context(db, expense_in)
# Create recurrence pattern if this is a recurring expense
recurrence_pattern = None
if expense_in.is_recurring and expense_in.recurrence_pattern:
# Normalize recurrence type (accept both str and Enum)
_rp_type_raw = expense_in.recurrence_pattern.type
if isinstance(_rp_type_raw, str):
try:
_rp_type_enum = RecurrenceTypeEnum[_rp_type_raw.upper()]
except KeyError:
raise InvalidOperationError(f"Unsupported recurrence type: {_rp_type_raw}")
else:
_rp_type_enum = _rp_type_raw # assume already RecurrenceTypeEnum
recurrence_pattern = RecurrencePattern(
type=expense_in.recurrence_pattern.type,
type=_rp_type_enum,
interval=expense_in.recurrence_pattern.interval,
days_of_week=expense_in.recurrence_pattern.days_of_week,
days_of_week=(
','.join(str(d) for d in expense_in.recurrence_pattern.days_of_week)
if isinstance(expense_in.recurrence_pattern.days_of_week, (list, tuple))
else expense_in.recurrence_pattern.days_of_week
),
end_date=expense_in.recurrence_pattern.end_date,
max_occurrences=expense_in.recurrence_pattern.max_occurrences,
created_at=datetime.now(timezone.utc),
@ -314,7 +328,7 @@ async def _generate_expense_splits(
async def _create_equal_splits(db: AsyncSession, expense_model: ExpenseModel, expense_in: ExpenseCreate, round_money_func: Callable[[Decimal], Decimal], **kwargs: Any) -> PyList[ExpenseSplitModel]:
"""Creates equal splits among users."""
"""Creates equal splits among users, distributing any rounding remainder fairly."""
users_for_splitting = await get_users_for_splitting(
db, expense_model.group_id, expense_model.list_id, expense_model.paid_by_user_id
@ -323,20 +337,29 @@ async def _create_equal_splits(db: AsyncSession, expense_model: ExpenseModel, ex
raise InvalidOperationError("No users found for EQUAL split.")
num_users = len(users_for_splitting)
amount_per_user = round_money_func(expense_model.total_amount / Decimal(num_users))
remainder = expense_model.total_amount - (amount_per_user * num_users)
# Use floor rounding initially to prevent over-shooting the total
amount_per_user_floor = (expense_model.total_amount / Decimal(num_users)).quantize(Decimal("0.01"), rounding=ROUND_DOWN)
splits = []
for i, user in enumerate(users_for_splitting):
split_amount = amount_per_user
if i == 0 and remainder != Decimal('0'):
split_amount = round_money_func(amount_per_user + remainder)
# Sort users by ID to ensure deterministic remainder distribution
users_for_splitting.sort(key=lambda u: u.id)
for user in users_for_splitting:
splits.append(ExpenseSplitModel(
user_id=user.id,
owed_amount=split_amount,
status=ExpenseSplitStatusEnum.unpaid # Explicitly set default status
owed_amount=amount_per_user_floor,
status=ExpenseSplitStatusEnum.unpaid
))
# Calculate remainder and distribute pennies one by one
current_total = amount_per_user_floor * num_users
remainder = expense_model.total_amount - current_total
pennies_to_distribute = int(remainder * 100)
for i in range(pennies_to_distribute):
# The modulo ensures that if pennies > num_users (should not happen with floor), it still works
splits[i % len(splits)].owed_amount += Decimal("0.01")
return splits
@ -375,7 +398,7 @@ async def _create_exact_amount_splits(db: AsyncSession, expense_model: ExpenseMo
async def _create_percentage_splits(db: AsyncSession, expense_model: ExpenseModel, expense_in: ExpenseCreate, round_money_func: Callable[[Decimal], Decimal], **kwargs: Any) -> PyList[ExpenseSplitModel]:
"""Creates splits based on percentages."""
"""Creates splits based on percentages, distributing any rounding remainder fairly."""
if not expense_in.splits_in:
raise InvalidOperationError("Splits data is required for PERCENTAGE split type.")
@ -407,16 +430,24 @@ async def _create_percentage_splits(db: AsyncSession, expense_model: ExpenseMode
if round_money_func(total_percentage) != Decimal("100.00"):
raise InvalidOperationError(f"Sum of percentages ({total_percentage}%) is not 100%.")
# Adjust for rounding differences
# Adjust for rounding differences by distributing remainder fairly
if current_total != expense_model.total_amount and splits:
diff = expense_model.total_amount - current_total
splits[-1].owed_amount = round_money_func(splits[-1].owed_amount + diff)
# Sort by user ID to make distribution deterministic
splits.sort(key=lambda s: s.user_id)
pennies = int(diff * 100)
increment = Decimal("0.01") if pennies > 0 else Decimal("-0.01")
for i in range(abs(pennies)):
splits[i % len(splits)].owed_amount += increment
return splits
async def _create_shares_splits(db: AsyncSession, expense_model: ExpenseModel, expense_in: ExpenseCreate, round_money_func: Callable[[Decimal], Decimal], **kwargs: Any) -> PyList[ExpenseSplitModel]:
"""Creates splits based on shares."""
"""Creates splits based on shares, distributing any rounding remainder fairly."""
if not expense_in.splits_in:
raise InvalidOperationError("Splits data is required for SHARES split type.")
@ -447,11 +478,19 @@ async def _create_shares_splits(db: AsyncSession, expense_model: ExpenseModel, e
status=ExpenseSplitStatusEnum.unpaid # Explicitly set default status
))
# Adjust for rounding differences
# Adjust for rounding differences by distributing remainder fairly
if current_total != expense_model.total_amount and splits:
diff = expense_model.total_amount - current_total
splits[-1].owed_amount = round_money_func(splits[-1].owed_amount + diff)
# Sort by user ID to make distribution deterministic
splits.sort(key=lambda s: s.user_id)
pennies = int(diff * 100)
increment = Decimal("0.01") if pennies > 0 else Decimal("-0.01")
for i in range(abs(pennies)):
splits[i % len(splits)].owed_amount += increment
return splits

View File

@ -16,6 +16,8 @@ from app.models import (
)
from pydantic import BaseModel
from app.crud.audit import create_financial_audit_log
from app.schemas.settlement_activity import SettlementActivityCreate
from app.core.exceptions import UserNotFoundError, InvalidOperationError, FinancialConflictError, OverpaymentError
class SettlementActivityCreatePlaceholder(BaseModel):
@ -114,21 +116,55 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
async def create_settlement_activity(
db: AsyncSession,
settlement_activity_in: SettlementActivityCreatePlaceholder,
settlement_activity_in: SettlementActivityCreate,
current_user_id: int
) -> Optional[SettlementActivity]:
) -> SettlementActivity:
"""
Creates a new settlement activity, then updates the parent expense split and expense statuses.
Uses pessimistic locking on the ExpenseSplit row to prevent race conditions.
Relies on the calling context (e.g., transactional session dependency) for the transaction.
"""
split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id))
# Lock the expense split row for the duration of the transaction
split_stmt = (
select(ExpenseSplit)
.where(ExpenseSplit.id == settlement_activity_in.expense_split_id)
.with_for_update()
)
split_result = await db.execute(split_stmt)
expense_split = split_result.scalar_one_or_none()
if not expense_split:
return None
raise InvalidOperationError(f"Expense split with ID {settlement_activity_in.expense_split_id} not found.")
# Check if the split is already fully paid
if expense_split.status == ExpenseSplitStatusEnum.paid:
raise FinancialConflictError(f"Expense split {expense_split.id} is already fully paid.")
# Calculate current total paid to prevent overpayment
current_total_paid = Decimal("0.00")
if expense_split.settlement_activities:
current_total_paid = sum(
Decimal(str(activity.amount_paid)) for activity in expense_split.settlement_activities
)
current_total_paid = current_total_paid.quantize(Decimal("0.01"))
new_payment_amount = Decimal(str(settlement_activity_in.amount_paid)).quantize(Decimal("0.01"))
projected_total = current_total_paid + new_payment_amount
owed_amount = Decimal(str(expense_split.owed_amount)).quantize(Decimal("0.01"))
# Prevent overpayment (with small epsilon for floating point precision)
epsilon = Decimal("0.01")
if projected_total > (owed_amount + epsilon):
remaining_amount = owed_amount - current_total_paid
raise OverpaymentError(
f"Payment amount {new_payment_amount} would exceed remaining owed amount. "
f"Maximum payment allowed: {remaining_amount} (owed: {owed_amount}, already paid: {current_total_paid})"
)
# Validate that the user paying exists
user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id))
paid_by_user = user_result.scalar_one_or_none()
if not paid_by_user:
return None # User not found
if not user_result.scalar_one_or_none():
raise UserNotFoundError(user_id=settlement_activity_in.paid_by_user_id)
db_settlement_activity = SettlementActivity(
expense_split_id=settlement_activity_in.expense_split_id,
@ -148,14 +184,28 @@ async def create_settlement_activity(
entity=db_settlement_activity,
)
# Update statuses
# Update statuses within the same transaction
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id:
await update_expense_overall_status(db, expense_id=updated_split.expense_id)
else:
pass
return db_settlement_activity
# Re-fetch the object with all relationships loaded to prevent lazy-loading issues during serialization
stmt = (
select(SettlementActivity)
.where(SettlementActivity.id == db_settlement_activity.id)
.options(
selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator)
)
)
result = await db.execute(stmt)
loaded_activity = result.scalar_one_or_none()
if not loaded_activity:
# This should not happen in a normal flow
raise InvalidOperationError("Failed to load settlement activity after creation.")
return loaded_activity
async def get_settlement_activity_by_id(

View File

@ -1,11 +1,13 @@
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models import Expense, RecurrencePattern
from sqlalchemy.orm import selectinload
from app.models import Expense, RecurrencePattern, SplitTypeEnum
from app.crud.expense import create_expense
from app.schemas.expense import ExpenseCreate
from app.schemas.expense import ExpenseCreate, ExpenseSplitCreate
import logging
from typing import Optional
import enum
logger = logging.getLogger(__name__)
@ -29,6 +31,8 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
(RecurrencePattern.end_date > now)
)
)
).options(
selectinload(Expense.splits) # Eager load splits to use as a template
)
result = await db.execute(query)
@ -38,22 +42,42 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
try:
await _generate_next_occurrence(db, expense)
except Exception as e:
logger.error(f"Error generating next occurrence for expense {expense.id}: {str(e)}")
logger.error(f"Error generating next occurrence for expense {expense.id}: {str(e)}", exc_info=True)
continue
except Exception as e:
logger.error(f"Error in generate_recurring_expenses job: {str(e)}")
raise
logger.error(f"Error in generate_recurring_expenses job during expense fetch: {str(e)}", exc_info=True)
# Do not re-raise, allow the job scheduler to run again later
async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
"""Generate the next occurrence of a recurring expense."""
pattern = expense.recurrence_pattern
if not pattern:
logger.warning(f"Recurring expense {expense.id} is missing its recurrence pattern.")
return
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
if not next_date:
logger.info(f"No next occurrence date for expense {expense.id}, stopping recurrence.")
expense.is_recurring = False # Stop future processing
await db.flush()
return
# Recreate splits from the template expense if needed
splits_data = None
if expense.split_type not in [SplitTypeEnum.EQUAL, SplitTypeEnum.ITEM_BASED]:
if not expense.splits:
logger.error(f"Cannot generate next occurrence for expense {expense.id} with split type {expense.split_type.value} because it has no splits to use as a template.")
return
splits_data = [
ExpenseSplitCreate(
user_id=split.user_id,
owed_amount=split.owed_amount,
share_percentage=split.share_percentage,
share_units=split.share_units,
) for split in expense.splits
]
new_expense = ExpenseCreate(
description=expense.description,
@ -65,46 +89,98 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
group_id=expense.group_id,
item_id=expense.item_id,
paid_by_user_id=expense.paid_by_user_id,
is_recurring=False,
splits_in=None
is_recurring=False, # The new expense is a single occurrence, not a recurring template
splits_in=splits_data
)
# We pass the original creator's ID
created_expense = await create_expense(db, new_expense, expense.created_by_user_id)
logger.info(f"Generated new expense {created_expense.id} from recurring expense {expense.id}.")
# Update the template expense for the next run
expense.last_occurrence = next_date
expense.next_occurrence = _calculate_next_occurrence(next_date, pattern)
next_next_date = _calculate_next_occurrence(next_date, pattern)
if pattern.max_occurrences:
# Decrement occurrence count if it exists
if pattern.max_occurrences is not None:
pattern.max_occurrences -= 1
if pattern.max_occurrences <= 0:
next_next_date = None # Stop recurrence
expense.next_occurrence = next_next_date
if not expense.next_occurrence:
expense.is_recurring = False # End the recurrence
await db.flush()
def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePattern) -> Optional[datetime]:
"""Calculate the next occurrence date based on the pattern."""
"""Calculate the next occurrence date based on the recurrence pattern provided."""
if not current_date:
return None
if pattern.type == 'daily':
return current_date + timedelta(days=pattern.interval)
elif pattern.type == 'weekly':
# Extract a lowercase string of the recurrence type regardless of whether it is an Enum member or a str.
if isinstance(pattern.type, enum.Enum):
pattern_type = pattern.type.value.lower()
else:
pattern_type = str(pattern.type).lower()
next_date: Optional[datetime] = None
if pattern_type == 'daily':
next_date = current_date + timedelta(days=pattern.interval)
elif pattern_type == 'weekly':
if not pattern.days_of_week:
return current_date + timedelta(weeks=pattern.interval)
current_weekday = current_date.weekday()
next_weekday = min((d for d in pattern.days_of_week if d > current_weekday),
default=min(pattern.days_of_week))
days_ahead = next_weekday - current_weekday
if days_ahead <= 0:
days_ahead += 7
return current_date + timedelta(days=days_ahead)
elif pattern.type == 'monthly':
next_date = current_date + timedelta(weeks=pattern.interval)
else:
current_weekday = current_date.weekday()
# ``days_of_week`` can be stored either as a list[int] (Python-side) or as a
# comma-separated string in the database. We normalise it to a list[int].
days_of_week_iterable = []
if pattern.days_of_week is None:
days_of_week_iterable = []
elif isinstance(pattern.days_of_week, (list, tuple)):
days_of_week_iterable = list(pattern.days_of_week)
else:
# Assume comma-separated string like "1,3,5"
try:
days_of_week_iterable = [int(d.strip()) for d in str(pattern.days_of_week).split(',') if d.strip().isdigit()]
except Exception:
days_of_week_iterable = []
# Find the next valid weekday after the current one
next_days = sorted([d for d in days_of_week_iterable if d > current_weekday])
if next_days:
days_ahead = next_days[0] - current_weekday
next_date = current_date + timedelta(days=days_ahead)
else:
# Jump to the first valid day in a future week respecting the interval
if days_of_week_iterable:
days_ahead = (7 - current_weekday) + min(days_of_week_iterable)
next_date = current_date + timedelta(days=days_ahead)
if pattern.interval > 1:
next_date += timedelta(weeks=pattern.interval - 1)
elif pattern_type == 'monthly':
# Move `interval` months forward while keeping the day component stable where possible.
year = current_date.year + (current_date.month + pattern.interval - 1) // 12
month = (current_date.month + pattern.interval - 1) % 12 + 1
return current_date.replace(year=year, month=month)
elif pattern.type == 'yearly':
return current_date.replace(year=current_date.year + pattern.interval)
return None
try:
next_date = current_date.replace(year=year, month=month)
except ValueError:
# Handle cases like Feb-31st by rolling back to the last valid day of the new month.
next_date = (current_date.replace(day=1, year=year, month=month) + timedelta(days=31)).replace(day=1) - timedelta(days=1)
elif pattern_type == 'yearly':
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.schemas.user import UserPublic, UserCreate, UserUpdate
from app.core.scheduler import init_scheduler, shutdown_scheduler
from app.core.middleware import RequestContextMiddleware
if settings.SENTRY_DSN:
sentry_sdk.init(
@ -48,6 +49,9 @@ app.add_middleware(
secret_key=settings.SESSION_SECRET_KEY
)
# Structured logging & request tracing
app.add_middleware(RequestContextMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from .user import UserPublic
@ -35,37 +35,26 @@ class ChoreBase(BaseModel):
next_due_date: date # For creation, this will be the initial due date
type: ChoreTypeEnum
@field_validator('custom_interval_days', mode='before')
@classmethod
def check_custom_interval_days(cls, value, values):
# Pydantic v2 uses `values.data` to get all fields
# For older Pydantic, it might just be `values`
# This is a simplified check; actual access might differ slightly
# based on Pydantic version context within the validator.
# The goal is to ensure custom_interval_days is present if frequency is 'custom'.
# This validator might be more complex in a real Pydantic v2 setup.
# A more direct way if 'frequency' is already parsed into values.data:
# freq = values.data.get('frequency')
# For this example, we'll assume 'frequency' might not be in 'values.data' yet
# if 'custom_interval_days' is validated 'before' 'frequency'.
# A truly robust validator might need to be on the whole model or run 'after'.
# For now, this is a placeholder for the logic.
# Consider if this validation is better handled at the service/CRUD layer for complex cases.
return value
@model_validator(mode='after')
def validate_custom_frequency(self):
if self.frequency == ChoreFrequencyEnum.custom:
if self.custom_interval_days is None or self.custom_interval_days <= 0:
raise ValueError("custom_interval_days must be a positive integer when frequency is 'custom'")
return self
class ChoreCreate(ChoreBase):
group_id: Optional[int] = None
parent_chore_id: Optional[int] = None
assigned_to_user_id: Optional[int] = None
@field_validator('group_id')
@classmethod
def validate_group_id(cls, v, values):
if values.data.get('type') == ChoreTypeEnum.group and v is None:
@model_validator(mode='after')
def validate_group_id_with_type(self):
if self.type == ChoreTypeEnum.group and self.group_id is None:
raise ValueError("group_id is required for group chores")
if values.data.get('type') == ChoreTypeEnum.personal and v is not None:
raise ValueError("group_id must be None for personal chores")
return v
if self.type == ChoreTypeEnum.personal and self.group_id is not None:
# Automatically clear group_id for personal chores instead of raising an error
self.group_id = None
return self
class ChoreUpdate(BaseModel):
name: Optional[str] = None
@ -75,16 +64,17 @@ class ChoreUpdate(BaseModel):
next_due_date: Optional[date] = None # Allow updating next_due_date directly if needed
type: Optional[ChoreTypeEnum] = None
group_id: Optional[int] = None
parent_chore_id: Optional[int] = None # Allow moving a chore under a parent or removing association
# last_completed_at should generally not be updated directly by user
@field_validator('group_id')
@classmethod
def validate_group_id(cls, v, values):
if values.data.get('type') == ChoreTypeEnum.group and v is None:
@model_validator(mode='after')
def validate_group_id_with_type(self):
if self.type == ChoreTypeEnum.group and self.group_id is None:
raise ValueError("group_id is required for group chores")
if values.data.get('type') == ChoreTypeEnum.personal and v is not None:
raise ValueError("group_id must be None for personal chores")
return v
if self.type == ChoreTypeEnum.personal and self.group_id is not None:
# Automatically clear group_id for personal chores instead of raising an error
self.group_id = None
return self
class ChorePublic(ChoreBase):
id: int

View File

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

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
)
from app.schemas.settlement_activity import SettlementActivityCreate as SettlementActivityCreateSchema
from app.core.exceptions import OverpaymentError
@pytest.fixture
@ -356,6 +357,73 @@ async def test_create_settlement_activity_overall_status_becomes_partially_paid(
# Since one split is paid and the other is unpaid, the overall expense status should be partially_paid
assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid
@pytest.mark.asyncio
async def test_create_settlement_activity_overpayment_protection(
db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit
):
"""Test that settlement activities prevent overpayment beyond owed amount."""
# Test split owes 10.00, attempt to pay 15.00 directly
activity_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("15.00") # More than the 10.00 owed
)
# Should raise OverpaymentError
with pytest.raises(OverpaymentError):
await create_settlement_activity(
db=db_session,
settlement_activity_in=activity_data,
current_user_id=test_user2.id
)
@pytest.mark.asyncio
async def test_create_settlement_activity_overpayment_protection_partial(
db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit
):
"""Test overpayment protection with multiple payments."""
# First payment of 7.00
activity1_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("7.00")
)
activity1 = await create_settlement_activity(
db=db_session,
settlement_activity_in=activity1_data,
current_user_id=test_user2.id
)
assert activity1 is not None
# Second payment of 5.00 should be rejected (7 + 5 = 12 > 10 owed)
activity2_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("5.00")
)
with pytest.raises(OverpaymentError):
await create_settlement_activity(
db=db_session,
settlement_activity_in=activity2_data,
current_user_id=test_user2.id
)
# But a payment of 3.00 should work (7 + 3 = 10 = exact amount owed)
activity3_data = SettlementActivityCreateSchema(
expense_split_id=test_expense_split_user2_owes.id,
paid_by_user_id=test_user2.id,
amount_paid=Decimal("3.00")
)
activity3 = await create_settlement_activity(
db=db_session,
settlement_activity_in=activity3_data,
current_user_id=test_user2.id
)
assert activity3 is not None
# Example of a placeholder for db_session fixture if not provided by conftest.py
# @pytest.fixture
# async def db_session() -> AsyncGenerator[AsyncSession, None]:

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

View File

@ -131,11 +131,29 @@ export const API_ENDPOINTS = {
},
CHORES: {
// Generic
ALL: '/chores/all',
BASE: '/chores',
BY_ID: (id: number) => `/chores/${id}`,
UPDATE_ANY_TYPE: (id: number) => `/chores/${id}`,
HISTORY: (id: number) => `/chores/${id}/history`,
ASSIGNMENTS: (choreId: number) => `/chores/${choreId}/assignments`,
// Personal chore shortcuts
PERSONAL: '/chores/personal',
PERSONAL_BY_ID: (id: number) => `/chores/personal/${id}`,
// Group chore shortcuts
GROUP_CHORES: (groupId: number) => `/chores/groups/${groupId}/chores`,
GROUP_CHORE_BY_ID: (groupId: number, choreId: number) => `/chores/groups/${groupId}/chores/${choreId}`,
// Assignment centric paths
ASSIGNMENTS_BASE: '/chores/assignments',
ASSIGNMENT_BY_ID: (id: number) => `/chores/assignments/${id}`,
MY_ASSIGNMENTS: (includeCompleted: boolean) => `/chores/assignments/my?include_completed=${includeCompleted}`,
ASSIGNMENT_COMPLETE: (id: number) => `/chores/assignments/${id}/complete`,
// Time tracking
TIME_ENTRIES: (assignmentId: number) => `/chores/assignments/${assignmentId}/time-entries`,
TIME_ENTRY: (id: number) => `/chores/time-entries/${id}`,
},

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { format, startOfDay, isEqual, isToday as isTodayDate, formatDistanceToNow, parseISO } from 'date-fns'
import { choreService } from '../services/choreService'
@ -11,6 +11,7 @@ import ChoreItem from '@/components/ChoreItem.vue';
import { useTimeEntryStore, type TimeEntry } from '../stores/timeEntryStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
import type { UserPublic } from '@/types/user';
const { t } = useI18n()
@ -28,6 +29,7 @@ interface ChoreFormData {
type: 'personal' | 'group';
group_id: number | undefined;
parent_chore_id?: number | null;
assigned_to_user_id?: number | null;
}
const notificationStore = useNotificationStore()
@ -45,6 +47,10 @@ const selectedChoreHistory = ref<ChoreHistory[]>([])
const selectedChoreAssignments = ref<ChoreAssignment[]>([])
const loadingHistory = ref(false)
const loadingAssignments = ref(false)
const groupMembers = ref<UserPublic[]>([])
const loadingMembers = ref(false)
const choreFormGroupMembers = ref<UserPublic[]>([])
const loadingChoreFormMembers = ref(false)
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
@ -59,13 +65,14 @@ const initialChoreFormState: ChoreFormData = {
type: 'personal',
group_id: undefined,
parent_chore_id: null,
assigned_to_user_id: null,
}
const choreForm = ref({ ...initialChoreFormState })
const isLoading = ref(true)
const authStore = useAuthStore();
const { isGuest } = storeToRefs(authStore);
const { isGuest, user } = storeToRefs(authStore);
const timeEntryStore = useTimeEntryStore();
const { timeEntries, loading: timeEntryLoading, error: timeEntryError } = storeToRefs(timeEntryStore);
@ -89,17 +96,28 @@ const loadChores = async () => {
try {
const fetchedChores = await choreService.getAllChores()
const currentUserId = user.value?.id ? Number(user.value.id) : null;
const mappedChores = fetchedChores.map(c => {
const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null;
// Prefer the assignment that belongs to the current user, otherwise fallback to the first assignment
const userAssignment = c.assignments?.find(a => a.assigned_to_user_id === currentUserId) ?? null;
const displayAssignment = userAssignment ?? (c.assignments?.[0] ?? null);
return {
...c,
current_assignment_id: currentAssignment?.id ?? null,
is_completed: currentAssignment?.is_complete ?? false,
completed_at: currentAssignment?.completed_at ?? null,
assigned_user_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
completed_by_name: currentAssignment?.assigned_user?.name || currentAssignment?.assigned_user?.email || 'Unknown',
current_assignment_id: userAssignment?.id ?? null,
is_completed: userAssignment?.is_complete ?? false,
completed_at: userAssignment?.completed_at ?? null,
assigned_user_name:
displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
completed_by_name:
displayAssignment?.assigned_user?.name ||
displayAssignment?.assigned_user?.email ||
'Unknown',
updating: false,
}
} as ChoreWithCompletion;
});
chores.value = mappedChores;
cachedChores.value = mappedChores;
@ -129,6 +147,37 @@ const loadTimeEntries = async () => {
});
};
// Watch for type changes to clear group_id when switching to personal
watch(() => choreForm.value.type, (newType) => {
if (newType === 'personal') {
choreForm.value.group_id = undefined
}
})
// Fetch group members when a group is selected in the form
watch(() => choreForm.value.group_id, async (newGroupId) => {
if (newGroupId && choreForm.value.type === 'group') {
loadingChoreFormMembers.value = true;
try {
choreFormGroupMembers.value = await groupService.getGroupMembers(newGroupId);
} catch (error) {
console.error('Failed to load group members for form:', error);
choreFormGroupMembers.value = [];
} finally {
loadingChoreFormMembers.value = false;
}
} else {
choreFormGroupMembers.value = [];
}
});
// Reload chores once the user information becomes available (e.g., after login refresh)
watch(user, (newUser, oldUser) => {
if (newUser && !oldUser) {
loadChores().then(loadTimeEntries);
}
});
onMounted(() => {
loadChores().then(loadTimeEntries);
loadGroups()
@ -293,6 +342,7 @@ const openEditChoreModal = (chore: ChoreWithCompletion) => {
type: chore.type,
group_id: chore.group_id ?? undefined,
parent_chore_id: chore.parent_chore_id,
assigned_to_user_id: chore.assigned_to_user_id,
}
showChoreModal.value = true
}
@ -302,10 +352,18 @@ const handleFormSubmit = async () => {
let createdChore;
if (isEditing.value && selectedChore.value) {
const updateData: ChoreUpdate = { ...choreForm.value };
createdChore = await choreService.updateChore(selectedChore.value.id, updateData);
// Ensure group_id is properly set based on type
if (updateData.type === 'personal') {
updateData.group_id = undefined;
}
createdChore = await choreService.updateChore(selectedChore.value.id, updateData, selectedChore.value);
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
} else {
const createData = { ...choreForm.value };
// Ensure group_id is properly set based on type
if (createData.type === 'personal') {
createData.group_id = undefined;
}
createdChore = await choreService.createChore(createData as ChoreCreate);
// Create an assignment for the new chore
@ -400,6 +458,7 @@ const toggleCompletion = async (chore: ChoreWithCompletion) => {
const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
groupMembers.value = []; // Reset
// Load assignments for this chore
loadingAssignments.value = true;
@ -414,6 +473,22 @@ const openChoreDetailModal = async (chore: ChoreWithCompletion) => {
} finally {
loadingAssignments.value = false;
}
// If it's a group chore, load members
if (chore.type === 'group' && chore.group_id) {
loadingMembers.value = true;
try {
groupMembers.value = await groupService.getGroupMembers(chore.group_id);
} catch (error) {
console.error('Failed to load group members:', error);
notificationStore.addNotification({
message: 'Failed to load group members.',
type: 'error'
});
} finally {
loadingMembers.value = false;
}
}
};
const openHistoryModal = async (chore: ChoreWithCompletion) => {
@ -479,6 +554,46 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
await timeEntryStore.stopTimeEntry(chore.current_assignment_id, timeEntryId);
}
};
const handleAssignChore = async (userId: number) => {
if (!selectedChore.value) return;
try {
await choreService.createAssignment({
chore_id: selectedChore.value.id,
assigned_to_user_id: userId,
due_date: selectedChore.value.next_due_date
});
notificationStore.addNotification({ message: 'Chore assigned successfully!', type: 'success' });
// Refresh assignments
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
await loadChores(); // Also reload all chores to update main list
} catch (error) {
console.error('Failed to assign chore:', error);
notificationStore.addNotification({ message: 'Failed to assign chore.', type: 'error' });
}
};
const handleUnassignChore = async (assignmentId: number) => {
if (!selectedChore.value) return;
try {
await choreService.deleteAssignment(assignmentId);
notificationStore.addNotification({ message: 'Chore unassigned successfully!', type: 'success' });
selectedChoreAssignments.value = await choreService.getChoreAssignments(selectedChore.value.id);
await loadChores();
} catch (error) {
console.error('Failed to unassign chore:', error);
notificationStore.addNotification({ message: 'Failed to unassign chore.', type: 'error' });
}
}
const isUserAssigned = (userId: number) => {
return selectedChoreAssignments.value.some(a => a.assigned_to_user_id === userId);
};
const getAssignmentIdForUser = (userId: number): number | null => {
const assignment = selectedChoreAssignments.value.find(a => a.assigned_to_user_id === userId);
return assignment ? assignment.id : null;
};
</script>
<template>
@ -590,6 +705,17 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div v-if="choreForm.type === 'group' && choreForm.group_id" class="form-group">
<label class="form-label" for="chore-assign-user">{{ t('choresPage.form.assignTo', 'Assign To (Optional)')
}}</label>
<div v-if="loadingChoreFormMembers">Loading members...</div>
<select v-else id="chore-assign-user" v-model="choreForm.assigned_to_user_id" class="form-input">
<option :value="null">Don't assign now</option>
<option v-for="member in choreFormGroupMembers" :key="member.id" :value="member.id">
{{ member.name || member.email }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="parent-chore">{{ t('choresPage.form.parentChore', 'Parent Chore')
}}</label>
@ -700,6 +826,45 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
</div>
</div>
</div>
<!-- Assign Chore Section -->
<div class="detail-section" v-if="selectedChore.type === 'group' && selectedChore.group_id">
<h4>Assign to Member</h4>
<div v-if="loadingMembers" class="loading-spinner">Loading members...</div>
<div v-else-if="groupMembers.length === 0" class="no-data">No other members in this group.</div>
<div v-else class="member-list">
<div v-for="member in groupMembers" :key="member.id" class="member-item">
<div class="member-info">
<span class="member-name">{{ member.name || member.email }}</span>
<span v-if="member.id === user?.id" class="badge badge-you">You</span>
</div>
<button v-if="isUserAssigned(member.id)" class="btn btn-sm btn-danger"
@click="handleUnassignChore(getAssignmentIdForUser(member.id)!)">
Unassign
</button>
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(member.id)">
Assign
</button>
</div>
</div>
</div>
<div class="detail-section" v-if="selectedChore.type === 'personal' && user?.id">
<h4>Assign to Me</h4>
<div class="member-list">
<div class="member-item">
<span class="member-name">{{ user.name || user.email }}</span>
<button v-if="isUserAssigned(Number(user.id))" class="btn btn-sm btn-danger"
@click="handleUnassignChore(getAssignmentIdForUser(Number(user.id))!)">
Unassign
</button>
<button v-else class="btn btn-sm btn-primary" @click="handleAssignChore(Number(user.id))">
Assign to Me
</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreDetailModal = false">Close</button>
@ -1030,6 +1195,12 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
color: white;
}
.badge-you {
background-color: var(--secondary);
color: white;
margin-left: 0.5rem;
}
.chore-description {
font-size: 0.875rem;
color: var(--dark);
@ -1187,4 +1358,38 @@ const stopTimer = async (chore: ChoreWithCompletion, timeEntryId: number) => {
opacity: 0.7;
font-style: italic;
}
.assignments-list,
.member-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item,
.member-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background-color: #f9fafb;
display: flex;
justify-content: space-between;
align-items: center;
}
.member-info {
display: flex;
align-items: center;
}
.member-name {
font-weight: 500;
}
.assignment-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
</style>

View File

@ -7,6 +7,14 @@
</button>
</header>
<div class="mb-4 flex items-center gap-2 justify-between" v-if="!loading && !error">
<div class="text-sm font-medium" v-if="authStore.getUser">
Your outstanding balance: <span class="font-mono">{{ formatCurrency(userOutstanding, 'USD') }}</span>
</div>
<label class="flex items-center text-sm"><input type="checkbox" v-model="showRecurringOnly"
class="mr-2">Show recurring only</label>
</div>
<div v-if="loading" class="flex justify-center">
<div class="spinner-dots">
<span></span>
@ -38,6 +46,12 @@
<div class="neo-item-content">
<div class="flex-grow cursor-pointer" @click="toggleExpenseDetails(expense.id)">
<span class="checkbox-text-span">{{ expense.description }}</span>
<span v-if="isExpenseRecurring(expense)"
class="ml-2 inline-block rounded-full bg-indigo-100 text-indigo-700 px-2 py-0.5 text-xs font-semibold">Recurring
<template v-if="getNextOccurrence(expense)">
next {{ getNextOccurrence(expense) }}
</template>
</span>
<div class="item-subtext">
Paid by {{ expense.paid_by_user?.full_name || expense.paid_by_user?.email ||
'N/A'
@ -64,11 +78,27 @@
expense.split_type.replace('_', ' ') }})</h3>
<ul class="space-y-1">
<li v-for="split in expense.splits" :key="split.id"
class="flex justify-between items-center py-1 text-sm">
class="flex justify-between items-center py-1 text-sm gap-2 flex-wrap">
<span class="text-gray-600">{{ split.user?.full_name || split.user?.email
|| 'N/A' }} owes</span>
<span class="font-mono text-gray-800 font-semibold">{{
formatCurrency(split.owed_amount, expense.currency) }}</span>
<!-- Settlement progress -->
<span
v-if="split.settlement_activities && split.settlement_activities.length"
class="text-xs text-gray-500">Paid: {{
formatCurrency(calculatePaidAmount(split), expense.currency) }}</span>
<span v-if="calculateRemainingAmount(split) > 0"
class="text-xs text-red-600">Remaining: {{
formatCurrency(calculateRemainingAmount(split), expense.currency)
}}</span>
<!-- Settle button for current user -->
<button
v-if="authStore.getUser && authStore.getUser.id === split.user_id && calculateRemainingAmount(split) > 0"
@click.stop="handleSettleSplit(split, expense)"
class="ml-auto btn btn-sm btn-primary">Settle</button>
</li>
</ul>
</div>
@ -175,60 +205,20 @@
<script setup lang="ts">
import { ref, onMounted, reactive, computed } from 'vue'
import { expenseService, type CreateExpenseData, type UpdateExpenseData } from '@/services/expenseService'
import { apiClient } from '@/services/api'
import { useAuthStore } from '@/stores/auth'
import { useNotificationStore } from '@/stores/notifications'
import type { Expense, ExpenseSplit, SettlementActivity } from '@/types/expense'
const props = defineProps<{
groupId?: number | string;
}>();
// Types are kept local to this component
interface UserPublic {
id: number;
email: string;
full_name?: string;
}
interface ExpenseSplit {
id: number;
expense_id: number;
user_id: number;
owed_amount: string; // Decimal is string
share_percentage?: string;
share_units?: number;
user?: UserPublic;
created_at: string;
updated_at: string;
status: 'unpaid' | 'paid' | 'partially_paid';
paid_at?: string;
}
export type SplitType = 'EQUAL' | 'EXACT_AMOUNTS' | 'PERCENTAGE' | 'SHARES' | 'ITEM_BASED';
interface Expense {
id: number;
description: string;
total_amount: string; // Decimal is string
currency: string;
expense_date?: string;
split_type: SplitType;
list_id?: number;
group_id?: number;
item_id?: number;
paid_by_user_id: number;
is_recurring: boolean;
recurrence_pattern?: any;
created_at: string;
updated_at: string;
version: number;
created_by_user_id: number;
splits: ExpenseSplit[];
paid_by_user?: UserPublic;
overall_settlement_status: 'unpaid' | 'paid' | 'partially_paid';
next_occurrence?: string;
last_occurrence?: string;
parent_expense_id?: number;
generated_expenses: Expense[];
}
// Pinia store for current user context
const authStore = useAuthStore()
const notifStore = useNotificationStore()
// Reactive state collections
const expenses = ref<Expense[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
@ -237,6 +227,24 @@ const showModal = ref(false)
const editingExpense = ref<Expense | null>(null)
const formError = ref<string | null>(null)
// UI-level filters
const showRecurringOnly = ref(false)
// Aggregate outstanding balance for current user across expenses
const userOutstanding = computed(() => {
const userId = authStore.getUser?.id
if (!userId) return 0
let remaining = 0
expenses.value.forEach((exp) => {
exp.splits.forEach((sp) => {
if (sp.user_id === userId) {
remaining += calculateRemainingAmount(sp)
}
})
})
return remaining
})
const initialFormState: CreateExpenseData = {
description: '',
total_amount: '',
@ -252,12 +260,16 @@ const initialFormState: CreateExpenseData = {
const formState = reactive<any>({ ...initialFormState })
const filteredExpenses = computed(() => {
let data = expenses.value
if (props.groupId) {
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId;
return expenses.value.filter(expense => expense.group_id === groupIdNum);
const groupIdNum = typeof props.groupId === 'string' ? parseInt(props.groupId) : props.groupId
data = data.filter((expense) => expense.group_id === groupIdNum)
}
return expenses.value;
});
if (showRecurringOnly.value) {
data = data.filter((expense) => (expense as any).isRecurring || (expense as any).is_recurring)
}
return data
})
onMounted(async () => {
try {
@ -333,6 +345,15 @@ const getStatusClass = (status: string) => {
return statusMap[status] || 'bg-gray-100 text-gray-800'
}
const getNextOccurrence = (expense: Expense): string | null => {
const raw = (expense as any).next_occurrence ?? (expense as any).nextOccurrence ?? null
return raw ? formatDate(raw) : null
}
const isExpenseRecurring = (expense: Expense): boolean => {
return Boolean((expense as any).isRecurring ?? (expense as any).is_recurring)
}
const openCreateExpenseModal = () => {
editingExpense.value = null
Object.assign(formState, initialFormState)
@ -351,7 +372,7 @@ const openEditExpenseModal = (expense: Expense) => {
formState.total_amount = expense.total_amount
formState.currency = expense.currency
formState.split_type = expense.split_type
formState.isRecurring = expense.is_recurring
formState.isRecurring = (expense as any).is_recurring ?? (expense as any).isRecurring ?? false
formState.list_id = expense.list_id
formState.group_id = expense.group_id
formState.item_id = expense.item_id
@ -385,13 +406,15 @@ const handleFormSubmit = async () => {
if (index !== -1) {
expenses.value[index] = updatedExpense
}
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense updated', type: 'success' })
} else {
const newExpense = (await expenseService.createExpense(data as CreateExpenseData)) as any as Expense;
expenses.value.unshift(newExpense)
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
notifStore.addNotification({ message: 'Expense created', type: 'success' })
}
closeModal()
// re-fetch all expenses to ensure data consistency after create/update
expenses.value = (await expenseService.getExpenses()) as any as Expense[]
} catch (err: any) {
formError.value = err.response?.data?.detail || 'An error occurred during the operation.'
console.error(err)
@ -404,11 +427,46 @@ const handleDeleteExpense = async (expenseId: number) => {
// Note: The service deleteExpense could be enhanced to take a version for optimistic locking.
await expenseService.deleteExpense(expenseId)
expenses.value = expenses.value.filter(e => e.id !== expenseId)
notifStore.addNotification({ message: 'Expense deleted', type: 'info' })
} catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to delete expense.'
console.error(err)
}
}
// -----------------------------
// Settlement-related helpers
// -----------------------------
const calculatePaidAmount = (split: ExpenseSplit): number =>
(split.settlement_activities || []).reduce((sum: number, act: SettlementActivity) => sum + parseFloat(act.amount_paid), 0)
const calculateRemainingAmount = (split: ExpenseSplit): number =>
parseFloat(split.owed_amount) - calculatePaidAmount(split)
const handleSettleSplit = async (split: ExpenseSplit, parentExpense: Expense) => {
if (!authStore.getUser?.id) {
alert('You need to be logged in to settle an expense.')
return
}
const remaining = calculateRemainingAmount(split)
if (remaining <= 0) return
if (!confirm(`Settle ${formatCurrency(remaining, parentExpense.currency)} now?`)) return
try {
await apiClient.post(`/financials/expense_splits/${split.id}/settle`, {
expense_split_id: split.id,
paid_by_user_id: authStore.getUser.id,
amount_paid: remaining.toFixed(2),
})
// refresh expense list to get updated data
expenses.value = (await expenseService.getExpenses()) as Expense[]
} catch (err: any) {
console.error('Failed to settle split', err)
alert(err.response?.data?.detail || 'Failed to settle split.')
}
}
</script>
<style scoped lang="scss">
@ -690,6 +748,11 @@ select.form-input {
background-color: #b91c1c;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.spinner-dots {
display: flex;
justify-content: center;
@ -758,4 +821,9 @@ select.form-input {
margin-bottom: 1rem;
letter-spacing: 0.5px;
}
.badge-recurring {
background-color: #e0e7ff;
color: #4338ca;
}
</style>

View File

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

View File

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

View File

@ -1,8 +1,12 @@
import { choreService } from '../choreService'; // Adjust path
import { api } from '../api'; // Actual axios instance from api.ts
/// <reference types="vitest" />
// @ts-nocheck
import { choreService } from '../choreService';
import { api } from '../api';
import { groupService } from '../groupService';
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType } from '../../types/chore'; // Adjust path
import type { Group } from '../groupService';
import { API_ENDPOINTS } from '@/services/api';
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType } from '@/types/chore';
import type { Group } from '@/types/group';
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock the api module (axios instance)
vi.mock('../api', () => ({
@ -36,7 +40,7 @@ describe('Chore Service', () => {
it('should fetch personal chores successfully', async () => {
mockApi.get.mockResolvedValue({ data: [mockPersonalChore] });
const chores = await choreService.getPersonalChores();
expect(mockApi.get).toHaveBeenCalledWith('/api/v1/chores/personal');
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL);
expect(chores).toEqual([mockPersonalChore]);
});
@ -52,7 +56,7 @@ describe('Chore Service', () => {
it('should fetch chores for a specific group successfully', async () => {
mockApi.get.mockResolvedValue({ data: [mockGroupChore] });
const chores = await choreService.getChores(groupId);
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${groupId}/chores`);
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(groupId));
expect(chores).toEqual([mockGroupChore]);
});
@ -70,7 +74,7 @@ describe('Chore Service', () => {
mockApi.post.mockResolvedValue({ data: createdChore });
const result = await choreService.createChore(newPersonalChore);
expect(mockApi.post).toHaveBeenCalledWith('/api/v1/chores/personal', newPersonalChore);
expect(mockApi.post).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL, newPersonalChore);
expect(result).toEqual(createdChore);
});
@ -80,7 +84,7 @@ describe('Chore Service', () => {
mockApi.post.mockResolvedValue({ data: createdChore });
const result = await choreService.createChore(newGroupChore);
expect(mockApi.post).toHaveBeenCalledWith(`/api/v1/chores/groups/${newGroupChore.group_id}/chores`, newGroupChore);
expect(mockApi.post).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(newGroupChore.group_id), newGroupChore);
expect(result).toEqual(createdChore);
});
@ -109,28 +113,51 @@ describe('Chore Service', () => {
mockApi.put.mockResolvedValue({ data: responseChore });
const result = await choreService.updateChore(choreId, updatedPersonalChoreData);
expect(mockApi.put).toHaveBeenCalledWith(`/api/v1/chores/personal/${choreId}`, updatedPersonalChoreData);
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId), updatedPersonalChoreData);
expect(result).toEqual(responseChore);
});
it('should update a group chore', async () => {
const updatedGroupChoreData: ChoreUpdate = { name: 'Updated Group', type: 'group', group_id: 10, description: 'new desc' };
const originalChore: Chore = { id: choreId, type: 'group', group_id: 10, name: 'Original', created_by_id: 1, next_due_date: '2024-01-01', frequency: 'daily' } as Chore;
const responseChore = { ...updatedGroupChoreData, id: choreId };
mockApi.put.mockResolvedValue({ data: responseChore });
const result = await choreService.updateChore(choreId, updatedGroupChoreData);
expect(mockApi.put).toHaveBeenCalledWith(`/api/v1/chores/groups/${updatedGroupChoreData.group_id}/chores/${choreId}`, updatedGroupChoreData);
const result = await choreService.updateChore(choreId, updatedGroupChoreData, originalChore);
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(updatedGroupChoreData.group_id, choreId), updatedGroupChoreData);
expect(result).toEqual(responseChore);
});
it('should use general endpoint for type conversion from personal to group', async () => {
const conversionData: ChoreUpdate = { name: 'Converted to Group', type: 'group', group_id: 10 };
const originalChore: Chore = { id: choreId, type: 'personal', name: 'Original Personal', created_by_id: 1, next_due_date: '2024-01-01', frequency: 'daily' } as Chore;
const responseChore = { ...conversionData, id: choreId };
mockApi.put.mockResolvedValue({ data: responseChore });
const result = await choreService.updateChore(choreId, conversionData, originalChore);
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.UPDATE_ANY_TYPE(choreId), conversionData);
expect(result).toEqual(responseChore);
});
it('should use general endpoint for type conversion from group to personal', async () => {
const conversionData: ChoreUpdate = { name: 'Converted to Personal', type: 'personal' };
const originalChore: Chore = { id: choreId, type: 'group', group_id: 10, name: 'Original Group', created_by_id: 1, next_due_date: '2024-01-01', frequency: 'daily' } as Chore;
const responseChore = { ...conversionData, id: choreId };
mockApi.put.mockResolvedValue({ data: responseChore });
const result = await choreService.updateChore(choreId, conversionData, originalChore);
expect(mockApi.put).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.UPDATE_ANY_TYPE(choreId), conversionData);
expect(result).toEqual(responseChore);
});
it('should throw error for invalid type or missing group_id for group chore update', async () => {
// @ts-expect-error testing invalid type
const invalidTypeUpdate: ChoreUpdate = { name: 'Invalid', type: 'unknown' };
await expect(choreService.updateChore(choreId, invalidTypeUpdate)).rejects.toThrow('Invalid chore type or missing group_id for group chore update');
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');
});
// @ts-expect-error testing invalid type
const invalidTypeUpdate: ChoreUpdate = { name: 'Invalid', type: 'unknown' };
await expect(choreService.updateChore(choreId, invalidTypeUpdate)).rejects.toThrow('Invalid chore type for chore update');
const missingGroupIdUpdate: ChoreUpdate = { name: 'Missing GroupId', type: 'group' };
await expect(choreService.updateChore(choreId, missingGroupIdUpdate)).rejects.toThrow('Missing group_id for group chore update');
});
it('should propagate API error on failure', async () => {
const updatedChoreData: ChoreUpdate = { name: 'Test Update', type: 'personal' };
@ -145,22 +172,22 @@ describe('Chore Service', () => {
it('should delete a personal chore', async () => {
mockApi.delete.mockResolvedValue({ data: null }); // delete often returns no content
await choreService.deleteChore(choreId, 'personal');
expect(mockApi.delete).toHaveBeenCalledWith(`/api/v1/chores/personal/${choreId}`);
expect(mockApi.delete).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId));
});
it('should delete a group chore', async () => {
const groupId = 10;
mockApi.delete.mockResolvedValue({ data: null });
await choreService.deleteChore(choreId, 'group', groupId);
expect(mockApi.delete).toHaveBeenCalledWith(`/api/v1/chores/groups/${groupId}/chores/${choreId}`);
expect(mockApi.delete).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId));
});
it('should throw error for invalid type or missing group_id for group chore deletion', async () => {
// @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, 'group')).rejects.toThrow('Invalid chore type or missing group_id for group chore deletion'); // Missing groupId
});
// @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, 'group')).rejects.toThrow('Invalid chore type or missing group_id for group chore deletion'); // Missing groupId
});
it('should propagate API error on failure', async () => {
const error = new Error('API Delete Failed');
@ -174,16 +201,16 @@ describe('Chore Service', () => {
{ id: 1, name: 'Family', members: [], owner_id: 1 },
{ id: 2, name: 'Work', members: [], owner_id: 1 },
];
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 mockWorkChores: Chore[] = [{ id: 102, name: 'Team Meeting Prep', type: 'group', group_id: 2, 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 mockWorkChores: Chore[] = [{ id: 102, name: 'Team Meeting Prep', type: 'group', group_id: 2, description: '' }];
beforeEach(() => {
// Mock the direct API calls made by getPersonalChores and getChores
// if we are not spying/mocking those specific choreService methods.
// 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.
});
// Mock the direct API calls made by getPersonalChores and getChores
// if we are not spying/mocking those specific choreService methods.
// 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.
});
it('should fetch all personal and group chores successfully', async () => {
mockApi.get
@ -194,45 +221,45 @@ describe('Chore Service', () => {
const allChores = await choreService.getAllChores();
expect(mockApi.get).toHaveBeenCalledWith('/api/v1/chores/personal');
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.PERSONAL);
expect(mockGroupService.getUserGroups).toHaveBeenCalled();
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${mockUserGroups[0].id}/chores`);
expect(mockApi.get).toHaveBeenCalledWith(`/api/v1/chores/groups/${mockUserGroups[1].id}/chores`);
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(mockUserGroups[0].id));
expect(mockApi.get).toHaveBeenCalledWith(API_ENDPOINTS.CHORES.GROUP_CHORES(mockUserGroups[1].id));
expect(allChores).toEqual([...mockPersonalChores, ...mockFamilyChores, ...mockWorkChores]);
});
it('should return partial results if fetching chores for one group fails', async () => {
const groupFetchError = new Error('Failed to fetch group 2 chores');
mockApi.get
.mockResolvedValueOnce({ data: mockPersonalChores }) // Personal chores
.mockResolvedValueOnce({ data: mockFamilyChores }) // Group 1 chores
.mockRejectedValueOnce(groupFetchError); // Group 2 chores fail
mockGroupService.getUserGroups.mockResolvedValue(mockUserGroups);
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
});
const groupFetchError = new Error('Failed to fetch group 2 chores');
mockApi.get
.mockResolvedValueOnce({ data: mockPersonalChores }) // Personal chores
.mockResolvedValueOnce({ data: mockFamilyChores }) // Group 1 chores
.mockRejectedValueOnce(groupFetchError); // Group 2 chores fail
mockGroupService.getUserGroups.mockResolvedValue(mockUserGroups);
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
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);
});
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 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 { useAuthStore } from '@/stores/auth' // Import the auth store
import type { SettlementActivityCreate, SettlementActivityPublic } from '@/types/expense' // Import the types for the payload and response
import { stringify } from 'qs';
import { stringify } from 'qs'
// Create axios instance
const api = axios.create({
@ -79,11 +79,15 @@ api.interceptors.response.use(
// Set refreshing state and create refresh promise
authStore.isRefreshing = true
refreshPromise = api.post(API_ENDPOINTS.AUTH.REFRESH, { refresh_token: refreshTokenValue }, {
headers: {
'Content-Type': 'application/json',
}
})
refreshPromise = api.post(
API_ENDPOINTS.AUTH.REFRESH,
{ refresh_token: refreshTokenValue },
{
headers: {
'Content-Type': 'application/json',
},
},
)
try {
const response = await refreshPromise

View File

@ -1,13 +1,21 @@
import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
import { api, apiClient, API_ENDPOINTS } from '@/services/api'
import type {
Chore,
ChoreCreate,
ChoreUpdate,
ChoreType,
ChoreAssignment,
ChoreAssignmentCreate,
ChoreAssignmentUpdate,
ChoreHistory,
} from '@/types/chore'
import { groupService } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { Group } from '@/types/group'
export const choreService = {
async getAllChores(): Promise<Chore[]> {
try {
const response = await api.get('/chores/all')
const response = await api.get(API_ENDPOINTS.CHORES.ALL)
return response.data
} catch (error) {
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
@ -38,78 +46,93 @@ export const choreService = {
},
async getChores(groupId: number): Promise<Chore[]> {
const response = await api.get(`/api/v1/chores/groups/${groupId}/chores`)
const response = await api.get(API_ENDPOINTS.CHORES.GROUP_CHORES(groupId))
return response.data
},
async createChore(chore: ChoreCreate): Promise<Chore> {
if (chore.type === 'personal') {
const response = await api.post('/api/v1/chores/personal', chore)
const response = await api.post(API_ENDPOINTS.CHORES.PERSONAL, chore)
return response.data
} else if (chore.type === 'group' && chore.group_id) {
const response = await api.post(`/api/v1/chores/groups/${chore.group_id}/chores`, chore)
const response = await api.post(API_ENDPOINTS.CHORES.GROUP_CHORES(chore.group_id), chore)
return response.data
} else {
throw new Error('Invalid chore type or missing group_id for group chore')
}
},
async updateChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
if (chore.type === 'personal') {
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
async updateChore(choreId: number, chore: ChoreUpdate, originalChore?: Chore): Promise<Chore> {
// Check if this is a type conversion (personal to group or group to personal)
const isTypeConversion = originalChore && chore.type && originalChore.type !== chore.type;
if (isTypeConversion) {
// Use the new general endpoint for type conversions
const response = await api.put(API_ENDPOINTS.CHORES.UPDATE_ANY_TYPE(choreId), chore)
return response.data
} else if (chore.type === 'group' && chore.group_id) {
}
if (chore.type === 'personal') {
const response = await api.put(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId), chore)
return response.data
} else if (chore.type === 'group') {
// For group chores, we need to use the original group_id for the endpoint path
// but can send the new group_id in the payload for group changes
const currentGroupId = originalChore?.group_id ?? chore.group_id;
if (!currentGroupId) {
throw new Error('Missing group_id for group chore update')
}
const response = await api.put(
`/api/v1/chores/groups/${chore.group_id}/chores/${choreId}`,
API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(currentGroupId, choreId),
chore,
)
return response.data
} else {
throw new Error('Invalid chore type or missing group_id for group chore update')
throw new Error('Invalid chore type for chore update')
}
},
async deleteChore(choreId: number, choreType: ChoreType, groupId?: number): Promise<void> {
if (choreType === 'personal') {
await api.delete(`/api/v1/chores/personal/${choreId}`)
await api.delete(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId))
} else if (choreType === 'group' && groupId) {
await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`)
await api.delete(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId))
} else {
throw new Error('Invalid chore type or missing group_id for group chore deletion')
}
},
async getPersonalChores(): Promise<Chore[]> {
const response = await api.get('/api/v1/chores/personal')
const response = await api.get(API_ENDPOINTS.CHORES.PERSONAL)
return response.data
},
async createAssignment(assignment: ChoreAssignmentCreate): Promise<ChoreAssignment> {
const response = await api.post('/api/v1/chores/assignments', assignment)
const response = await api.post(API_ENDPOINTS.CHORES.ASSIGNMENTS_BASE, assignment)
return response.data
},
async getMyAssignments(includeCompleted: boolean = false): Promise<ChoreAssignment[]> {
const response = await api.get(`/api/v1/chores/assignments/my?include_completed=${includeCompleted}`)
const response = await api.get(API_ENDPOINTS.CHORES.MY_ASSIGNMENTS(includeCompleted))
return response.data
},
async getChoreAssignments(choreId: number): Promise<ChoreAssignment[]> {
const response = await api.get(`/api/v1/chores/chores/${choreId}/assignments`)
const response = await api.get(API_ENDPOINTS.CHORES.ASSIGNMENTS(choreId))
return response.data
},
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
const response = await apiClient.put(API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId), update)
return response.data
},
async deleteAssignment(assignmentId: number): Promise<void> {
await api.delete(`/api/v1/chores/assignments/${assignmentId}`)
await api.delete(API_ENDPOINTS.CHORES.ASSIGNMENT_BY_ID(assignmentId))
},
async completeAssignment(assignmentId: number): Promise<ChoreAssignment> {
const response = await api.patch(`/api/v1/chores/assignments/${assignmentId}/complete`)
const response = await api.patch(API_ENDPOINTS.CHORES.ASSIGNMENT_COMPLETE(assignmentId))
return response.data
},
@ -118,21 +141,21 @@ export const choreService = {
choreId: number,
chore: ChoreUpdate,
): Promise<Chore> {
const response = await api.put(`/api/v1/chores/groups/${groupId}/chores/${choreId}`, chore)
const response = await api.put(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId), chore)
return response.data
},
async _original_deleteGroupChore(groupId: number, choreId: number): Promise<void> {
await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`)
await api.delete(API_ENDPOINTS.CHORES.GROUP_CHORE_BY_ID(groupId, choreId))
},
async _updatePersonalChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
const response = await api.put(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId), chore)
return response.data
},
async _deletePersonalChore(choreId: number): Promise<void> {
await api.delete(`/api/v1/chores/personal/${choreId}`)
await api.delete(API_ENDPOINTS.CHORES.PERSONAL_BY_ID(choreId))
},
async getChoreHistory(choreId: number): Promise<ChoreHistory[]> {

View File

@ -32,7 +32,23 @@ export interface UpdateExpenseData extends Partial<CreateExpenseData> {
export const expenseService = {
async createExpense(data: CreateExpenseData): Promise<Expense> {
const response = await api.post<Expense>(API_ENDPOINTS.FINANCIALS.EXPENSES, data)
// Convert camelCase keys to snake_case expected by backend
const payload: Record<string, any> = { ...data }
payload.is_recurring = data.isRecurring
delete payload.isRecurring
if (data.recurrencePattern) {
payload.recurrence_pattern = {
...data.recurrencePattern,
// daysOfWeek -> days_of_week, endDate -> end_date, maxOccurrences -> max_occurrences
days_of_week: data.recurrencePattern.daysOfWeek,
end_date: data.recurrencePattern.endDate,
max_occurrences: data.recurrencePattern.maxOccurrences,
}
delete payload.recurrencePattern
}
const response = await api.post<Expense>(API_ENDPOINTS.FINANCIALS.EXPENSES, payload)
return response.data
},

View File

@ -1,9 +1,10 @@
import { apiClient, API_ENDPOINTS } from '@/services/api';
import type { Group, GroupCreate, GroupUpdate } from '@/types/group';
import type { Group, GroupCreate, GroupUpdate, GroupPublic } from '@/types/group';
import type { ChoreHistory } from '@/types/chore';
import type { UserPublic } from '@/types/user';
export const groupService = {
async getUserGroups(): Promise<Group[]> {
async getUserGroups(): Promise<GroupPublic[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
return response.data;
},
@ -22,9 +23,18 @@ export const groupService = {
return response.data;
},
async createGroup(groupData: Group): Promise<Group> {
async createGroup(groupData: GroupCreate): Promise<Group> {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
return response.data;
},
}
async updateGroup(id: number, groupData: GroupUpdate): Promise<Group> {
const response = await apiClient.put(API_ENDPOINTS.GROUPS.BY_ID(String(id)), groupData);
return response.data;
},
async getGroupMembers(groupId: number): Promise<UserPublic[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.MEMBERS(String(groupId)));
return response.data;
},
};

View File

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

View File

@ -1,6 +1,7 @@
// fe/src/types/group.ts
import type { AuthState } from '@/stores/auth';
import type { ChoreHistory } from './chore';
import type { UserPublic } from './user';
export interface Group {
id: number;
@ -10,3 +11,24 @@ export interface Group {
members: AuthState['user'][];
chore_history?: ChoreHistory[];
}
export interface GroupCreate {
name: string;
description?: string;
}
export interface GroupUpdate {
name?: string;
description?: string;
}
export interface GroupPublic {
id: number;
name: string;
description?: string | null;
created_by_id: number;
created_at: string;
creator?: UserPublic;
members: UserPublic[];
is_member?: boolean;
}

View File

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