From 727394a0eb8c04b674202b7910210c2523d7a29f Mon Sep 17 00:00:00 2001 From: mohamad Date: Thu, 3 Apr 2025 01:24:23 +0200 Subject: [PATCH] end of phase 6 --- ...8_add_expense_tracking_tables_and_item_.py | 89 +++ be/app/api/v1/api.py | 2 + be/app/api/v1/endpoints/expenses.py | 45 ++ be/app/api/v1/endpoints/items.py | 19 +- be/app/api/v1/endpoints/lists.py | 21 +- be/app/crud/expense.py | 88 +++ be/app/crud/item.py | 26 +- be/app/models.py | 65 +- be/app/schemas/expense.py | 49 ++ fe/src/lib/apiClient.ts | 1 + fe/src/lib/components/ItemDisplay.svelte | 264 +++---- fe/src/lib/schemas/expense.ts | 59 ++ .../routes/(app)/lists/[listId]/+page.svelte | 684 ++++++++++++------ fe/src/routes/(app)/lists/[listId]/+page.ts | 111 ++- 14 files changed, 1121 insertions(+), 402 deletions(-) create mode 100644 be/alembic/versions/ebbe5cdba808_add_expense_tracking_tables_and_item_.py create mode 100644 be/app/api/v1/endpoints/expenses.py create mode 100644 be/app/crud/expense.py create mode 100644 be/app/schemas/expense.py create mode 100644 fe/src/lib/schemas/expense.ts diff --git a/be/alembic/versions/ebbe5cdba808_add_expense_tracking_tables_and_item_.py b/be/alembic/versions/ebbe5cdba808_add_expense_tracking_tables_and_item_.py new file mode 100644 index 0000000..1b2d3e9 --- /dev/null +++ b/be/alembic/versions/ebbe5cdba808_add_expense_tracking_tables_and_item_.py @@ -0,0 +1,89 @@ +"""Add expense tracking tables and item price columns + +Revision ID: ebbe5cdba808 +Revises: d25788f63e2c +Create Date: 2025-04-02 23:51:31.432547 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ebbe5cdba808' +down_revision: Union[str, None] = 'd25788f63e2c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('expense_records', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('list_id', sa.Integer(), nullable=False), + sa.Column('calculated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('calculated_by_id', sa.Integer(), nullable=False), + sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('participants', sa.ARRAY(sa.Integer()), nullable=False), + sa.Column('split_type', sa.Enum('equal', name='splittypeenum'), nullable=False), + sa.Column('is_settled', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['calculated_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_expense_records_id'), 'expense_records', ['id'], unique=False) + op.create_index(op.f('ix_expense_records_list_id'), 'expense_records', ['list_id'], unique=False) + op.create_table('expense_shares', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('expense_record_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('amount_owed', sa.Numeric(precision=10, scale=2), nullable=False), + sa.Column('is_paid', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['expense_record_id'], ['expense_records.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('expense_record_id', 'user_id', name='uq_expense_share_user') + ) + op.create_index(op.f('ix_expense_shares_expense_record_id'), 'expense_shares', ['expense_record_id'], unique=False) + op.create_index(op.f('ix_expense_shares_id'), 'expense_shares', ['id'], unique=False) + op.create_index(op.f('ix_expense_shares_user_id'), 'expense_shares', ['user_id'], unique=False) + op.create_table('settlement_activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('expense_record_id', sa.Integer(), nullable=False), + sa.Column('payer_user_id', sa.Integer(), nullable=False), + sa.Column('affected_user_id', sa.Integer(), nullable=False), + sa.Column('activity_type', sa.Enum('marked_paid', 'marked_unpaid', name='settlementactivitytypeenum'), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['affected_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['expense_record_id'], ['expense_records.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['payer_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_settlement_activities_expense_record_id'), 'settlement_activities', ['expense_record_id'], unique=False) + op.create_index(op.f('ix_settlement_activities_id'), 'settlement_activities', ['id'], unique=False) + op.add_column('items', sa.Column('price_added_by_id', sa.Integer(), nullable=True)) + op.add_column('items', sa.Column('price_added_at', sa.DateTime(timezone=True), nullable=True)) + op.create_foreign_key(None, 'items', 'users', ['price_added_by_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'items', type_='foreignkey') + op.drop_column('items', 'price_added_at') + op.drop_column('items', 'price_added_by_id') + op.drop_index(op.f('ix_settlement_activities_id'), table_name='settlement_activities') + op.drop_index(op.f('ix_settlement_activities_expense_record_id'), table_name='settlement_activities') + op.drop_table('settlement_activities') + op.drop_index(op.f('ix_expense_shares_user_id'), table_name='expense_shares') + op.drop_index(op.f('ix_expense_shares_id'), table_name='expense_shares') + op.drop_index(op.f('ix_expense_shares_expense_record_id'), table_name='expense_shares') + op.drop_table('expense_shares') + op.drop_index(op.f('ix_expense_records_list_id'), table_name='expense_records') + op.drop_index(op.f('ix_expense_records_id'), table_name='expense_records') + op.drop_table('expense_records') + # ### end Alembic commands ### diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index 640c569..34932b6 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -9,6 +9,7 @@ from app.api.v1.endpoints import invites from app.api.v1.endpoints import lists from app.api.v1.endpoints import items from app.api.v1.endpoints import ocr +from app.api.v1.endpoints import expenses api_router_v1 = APIRouter() @@ -20,5 +21,6 @@ api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"] api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) api_router_v1.include_router(items.router, tags=["Items"]) api_router_v1.include_router(ocr.router, prefix="/ocr", tags=["OCR"]) +api_router_v1.include_router(expenses.router, tags=["Expenses"]) # Add other v1 endpoint routers here later # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/expenses.py b/be/app/api/v1/endpoints/expenses.py new file mode 100644 index 0000000..35cf206 --- /dev/null +++ b/be/app/api/v1/endpoints/expenses.py @@ -0,0 +1,45 @@ +# app/api/v1/endpoints/expenses.py +import logging +from typing import List as PyList + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +from app.models import User as UserModel, SettlementActivityTypeEnum +from app.schemas.expense import ( + ExpenseRecordPublic, + ExpenseSharePublic, + SettleShareRequest +) +from app.schemas.message import Message +from app.crud import expense as crud_expense + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.get("/lists/{list_id}/expenses", response_model=PyList[ExpenseRecordPublic], tags=["Expenses"]) +async def read_list_expense_records( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Retrieves all historical expense calculation records for a specific list.""" + records = await crud_expense.get_expense_records_for_list(db, list_id=list_id) + return records + +@router.post("/expenses/{expense_record_id}/settle", response_model=Message, tags=["Expenses"]) +async def settle_expense_share( + expense_record_id: int, + settle_request: SettleShareRequest, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Marks a specific user's share within an expense record as paid.""" + affected_user_id = settle_request.affected_user_id + share_to_update = await crud_expense.get_expense_share(db, record_id=expense_record_id, user_id=affected_user_id) + if not share_to_update: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expense share not found") + await crud_expense.mark_share_as_paid(db, share_id=share_to_update.id, is_paid_status=True) + return Message(detail="Share successfully marked as paid") \ No newline at end of file diff --git a/be/app/api/v1/endpoints/items.py b/be/app/api/v1/endpoints/items.py index 7cff34b..b06e46e 100644 --- a/be/app/api/v1/endpoints/items.py +++ b/be/app/api/v1/endpoints/items.py @@ -98,18 +98,13 @@ async def read_list_items( return items -@router.put( - "/items/{item_id}", # Operate directly on item ID - response_model=ItemPublic, - summary="Update Item", - tags=["Items"] -) +@router.put("/items/{item_id}", response_model=ItemPublic, summary="Update Item", tags=["Items"]) async def update_item( - item_id: int, # Item ID from path + item_id: int, item_in: ItemUpdate, - item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access + item_db: ItemModel = Depends(get_item_and_verify_access), db: AsyncSession = Depends(get_db), - current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by + current_user: UserModel = Depends(get_current_user), ): """ Updates an item's details (name, quantity, is_complete, price). @@ -117,11 +112,7 @@ async def update_item( Sets/unsets `completed_by_id` based on `is_complete` flag. """ logger.info(f"User {current_user.email} attempting to update item ID: {item_id}") - # Permission check is handled by get_item_and_verify_access dependency - - updated_item = await crud_item.update_item( - db=db, item_db=item_db, item_in=item_in, user_id=current_user.id - ) + updated_item = await crud_item.update_item(db=db, item_db=item_db, item_in=item_in, user_id=current_user.id) logger.info(f"Item {item_id} updated successfully by user {current_user.email}.") return updated_item diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index 25d833b..efa0763 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -11,8 +11,10 @@ from app.models import User as UserModel from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail from app.schemas.message import Message # For simple responses from app.crud import list as crud_list +from app.crud import expense as crud_expense from app.crud import group as crud_group # Need for group membership check from app.schemas.list import ListStatus +from app.schemas.expense import ExpenseRecordPublic logger = logging.getLogger(__name__) router = APIRouter() @@ -208,4 +210,21 @@ async def read_list_status( logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found") - return list_status \ No newline at end of file + return list_status + +@router.post("/{list_id}/calculate-split", response_model=ExpenseRecordPublic, summary="Calculate and Record Expense Split", status_code=status.HTTP_201_CREATED, tags=["Expenses", "Lists"]) +async def calculate_list_split( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + priced_items = await crud_expense.get_priced_items_for_list(db, list_id) + total_amount = sum(item.price for item in priced_items if item.price is not None) + participant_ids = await crud_expense.get_group_member_ids(db, list_id.group_id) + return await crud_expense.create_expense_record_and_shares( + db=db, + list_id=list_id, + calculated_by_id=current_user.id, + total_amount=total_amount, + participant_ids=participant_ids + ) \ No newline at end of file diff --git a/be/app/crud/expense.py b/be/app/crud/expense.py new file mode 100644 index 0000000..bc9d348 --- /dev/null +++ b/be/app/crud/expense.py @@ -0,0 +1,88 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload, joinedload +from typing import List as PyList, Sequence, Optional +from decimal import Decimal, ROUND_HALF_UP + +from app.models import ( + Item as ItemModel, + User as UserModel, + UserGroup as UserGroupModel, + ExpenseRecord as ExpenseRecordModel, + ExpenseShare as ExpenseShareModel, + SettlementActivity as SettlementActivityModel, + SplitTypeEnum, +) + +async def get_priced_items_for_list(db: AsyncSession, list_id: int) -> Sequence[ItemModel]: + result = await db.execute(select(ItemModel).where(ItemModel.list_id == list_id, ItemModel.price.is_not(None))) + return result.scalars().all() + +async def get_group_member_ids(db: AsyncSession, group_id: int) -> PyList[int]: + result = await db.execute(select(UserModel.user_id).where(UserGroupModel.group_id == group_id)) + return result.scalars().all() + +async def create_expense_record_and_shares( + db: AsyncSession, + list_id: int, + calculated_by_id: int, + total_amount: Decimal, + participant_ids: PyList[int], + split_type: SplitTypeEnum = SplitTypeEnum.equal +) -> ExpenseRecordModel: + if not participant_ids or total_amount <= Decimal("0.00"): + raise ValueError("Invalid participants or total amount.") + + db_expense_record = ExpenseRecordModel( + list_id=list_id, + calculated_by_id=calculated_by_id, + total_amount=total_amount, + participants=participant_ids, + split_type=split_type, + is_settled=False + ) + db.add(db_expense_record) + await db.flush() + + num_participants = len(participant_ids) + individual_share = (total_amount / Decimal(num_participants)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + total_calculated = individual_share * (num_participants - 1) + last_share = total_amount - total_calculated + + shares_to_add = [ + ExpenseShareModel(expense_record_id=db_expense_record.id, user_id=user_id, amount_owed=(last_share if i == num_participants - 1 else individual_share), is_paid=False) + for i, user_id in enumerate(participant_ids) + ] + + db.add_all(shares_to_add) + await db.commit() + await db.refresh(db_expense_record, attribute_names=['shares']) + return db_expense_record + +# Fetch all expense records for a list +async def get_expense_records_for_list(db: AsyncSession, list_id: int) -> Sequence[ExpenseRecordModel]: + result = await db.execute( + select(ExpenseRecordModel) + .where(ExpenseRecordModel.list_id == list_id) + .options( + selectinload(ExpenseRecordModel.shares).selectinload(ExpenseShareModel.user), + selectinload(ExpenseRecordModel.settlement_activities) + ) + .order_by(ExpenseRecordModel.calculated_at.desc()) + ) + return result.scalars().unique().all() + +# Fetch a specific expense record by ID +async def get_expense_record_by_id(db: AsyncSession, record_id: int) -> Optional[ExpenseRecordModel]: + result = await db.execute( + select(ExpenseRecordModel) + .where(ExpenseRecordModel.id == record_id) + .options( + selectinload(ExpenseRecordModel.shares).selectinload(ExpenseShareModel.user), + selectinload(ExpenseRecordModel.settlement_activities).options( + joinedload(SettlementActivityModel.payer), + joinedload(SettlementActivityModel.affected_user) + ) + ) + ) + return result.scalars().first() \ No newline at end of file diff --git a/be/app/crud/item.py b/be/app/crud/item.py index c340665..d94eca0 100644 --- a/be/app/crud/item.py +++ b/be/app/crud/item.py @@ -4,7 +4,6 @@ from sqlalchemy.future import select from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases from typing import Optional, List as PyList from datetime import datetime, timezone - from app.models import Item as ItemModel from app.schemas.item import ItemCreate, ItemUpdate @@ -38,24 +37,27 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]: async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: """Updates an existing item record.""" - update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields + update_data = item_in.model_dump(exclude_unset=True) + now_utc = datetime.now(timezone.utc) - # Special handling for is_complete if 'is_complete' in update_data: - if update_data['is_complete'] is True: - # Mark as complete: set completed_by_id if not already set - if item_db.completed_by_id is None: - update_data['completed_by_id'] = user_id - else: - # Mark as incomplete: clear completed_by_id + if update_data['is_complete'] is True and item_db.completed_by_id is None: + update_data['completed_by_id'] = user_id + elif update_data['is_complete'] is False: update_data['completed_by_id'] = None - # Ensure updated_at is refreshed (handled by onupdate in model, but explicit is fine too) - # update_data['updated_at'] = datetime.now(timezone.utc) + + if 'price' in update_data: + if update_data['price'] is not None: + update_data['price_added_by_id'] = user_id + update_data['price_added_at'] = now_utc + else: + update_data['price_added_by_id'] = None + update_data['price_added_at'] = None for key, value in update_data.items(): setattr(item_db, key, value) - db.add(item_db) # Add to session to track changes + db.add(item_db) await db.commit() await db.refresh(item_db) return item_db diff --git a/be/app/models.py b/be/app/models.py index 851b8c0..3d8125a 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -19,7 +19,8 @@ from sqlalchemy import ( func, text as sa_text, Text, # <-- Add Text for description - Numeric # <-- Add Numeric for price + Numeric, # <-- Add Numeric for price + ARRAY ) from sqlalchemy.orm import relationship @@ -30,6 +31,16 @@ class UserRoleEnum(enum.Enum): owner = "owner" member = "member" +class SplitTypeEnum(enum.Enum): + equal = "equal" + # Add other types later if needed (e.g., custom, percentage) + # custom = "custom" + +class SettlementActivityTypeEnum(enum.Enum): + marked_paid = "marked_paid" + marked_unpaid = "marked_unpaid" + # Add other activity types later if needed + # --- User Model --- class User(Base): __tablename__ = "users" @@ -123,7 +134,6 @@ class List(Base): group = relationship("Group", back_populates="lists") # Link to Group.lists items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes - # === NEW: Item Model === class Item(Base): __tablename__ = "items" @@ -134,6 +144,8 @@ class Item(Base): quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") is_complete = Column(Boolean, default=False, nullable=False) price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) + price_added_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + price_added_at = Column(DateTime(timezone=True), nullable=True) added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) @@ -142,4 +154,51 @@ class Item(Base): # --- Relationships --- list = relationship("List", back_populates="items") # Link to List.items added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items - completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items \ No newline at end of file + completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items + +# === NEW: ExpenseRecord Model === +class ExpenseRecord(Base): + __tablename__ = "expense_records" + id = Column(Integer, primary_key=True, index=True) + list_id = Column(Integer, ForeignKey("lists.id"), index=True, nullable=False) + calculated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + calculated_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + total_amount = Column(Numeric(10, 2), nullable=False) + participants = Column(ARRAY(Integer), nullable=False) + split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False, default=SplitTypeEnum.equal) + is_settled = Column(Boolean, default=False, nullable=False) + + # Relationships + list = relationship("List") + calculator = relationship("User") + shares = relationship("ExpenseShare", back_populates="expense_record", cascade="all, delete-orphan") + settlement_activities = relationship("SettlementActivity", back_populates="expense_record", cascade="all, delete-orphan") + +class ExpenseShare(Base): + __tablename__ = "expense_shares" + __table_args__ = (UniqueConstraint('expense_record_id', 'user_id', name='uq_expense_share_user'),) + + id = Column(Integer, primary_key=True, index=True) + expense_record_id = Column(Integer, ForeignKey("expense_records.id", ondelete="CASCADE"), index=True, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), index=True, nullable=False) + amount_owed = Column(Numeric(10, 2), nullable=False) + is_paid = Column(Boolean, default=False, nullable=False) + + # Relationships + expense_record = relationship("ExpenseRecord", back_populates="shares") + user = relationship("User") + + +class SettlementActivity(Base): + __tablename__ = "settlement_activities" + id = Column(Integer, primary_key=True, index=True) + expense_record_id = Column(Integer, ForeignKey("expense_records.id", ondelete="CASCADE"), index=True, nullable=False) + payer_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + affected_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + activity_type = Column(SAEnum(SettlementActivityTypeEnum, name="settlementactivitytypeenum", create_type=True), nullable=False) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # Relationships + expense_record = relationship("ExpenseRecord", back_populates="settlement_activities") + payer = relationship("User", foreign_keys=[payer_user_id]) + affected_user = relationship("User", foreign_keys=[affected_user_id]) \ No newline at end of file diff --git a/be/app/schemas/expense.py b/be/app/schemas/expense.py new file mode 100644 index 0000000..3b37dba --- /dev/null +++ b/be/app/schemas/expense.py @@ -0,0 +1,49 @@ +# app/schemas/expense.py +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import List, Optional +from decimal import Decimal + +from .user import UserPublic # Assuming UserPublic schema exists +from app.models import SplitTypeEnum, SettlementActivityTypeEnum # Import Enums from models + +# Represents a single user's share of an expense +class ExpenseSharePublic(BaseModel): + id: int + expense_record_id: int + user_id: int + amount_owed: Decimal + is_paid: bool + user: Optional[UserPublic] = None # Include user details for context + + model_config = ConfigDict(from_attributes=True) + +# Represents a log of settlement actions +class SettlementActivityPublic(BaseModel): + id: int + expense_record_id: int + payer_user_id: int # Who marked it paid/unpaid + affected_user_id: int # Whose share status changed + activity_type: SettlementActivityTypeEnum # Use the Enum + timestamp: datetime + + model_config = ConfigDict(from_attributes=True) + +# Represents a finalized expense split record for a list +class ExpenseRecordPublic(BaseModel): + id: int + list_id: int + calculated_at: datetime + calculated_by_id: int + total_amount: Decimal + split_type: SplitTypeEnum # Use the Enum + is_settled: bool + participants: List[int] # List of user IDs who participated + shares: List[ExpenseSharePublic] = [] # Include the individual shares + settlement_activities: List[SettlementActivityPublic] = [] # Include settlement history + + model_config = ConfigDict(from_attributes=True) + +# Schema for the request body of the settle endpoint +class SettleShareRequest(BaseModel): + affected_user_id: int # The ID of the user whose share is being settled \ No newline at end of file diff --git a/fe/src/lib/apiClient.ts b/fe/src/lib/apiClient.ts index 2ed431d..ee5ac00 100644 --- a/fe/src/lib/apiClient.ts +++ b/fe/src/lib/apiClient.ts @@ -21,6 +21,7 @@ if (!BASE_URL && browser) { // Only log error in browser, avoid during SSR build export class ApiClientError extends Error { status: number; // HTTP status code errorData: unknown; // Parsed error data from response body (if any) + body: any; constructor(message: string, status: number, errorData: unknown = null) { super(message); // Pass message to the base Error class diff --git a/fe/src/lib/components/ItemDisplay.svelte b/fe/src/lib/components/ItemDisplay.svelte index 899ae6b..b91bb28 100644 --- a/fe/src/lib/components/ItemDisplay.svelte +++ b/fe/src/lib/components/ItemDisplay.svelte @@ -1,22 +1,20 @@ -
  • {#if isEditing} -
    - - - - + +
    {:else} @@ -272,9 +261,9 @@ type="checkbox" checked={item.is_complete} disabled={isToggling || isDeleting} + on:change={handleToggleComplete} aria-label="Mark {item.name} as {item.is_complete ? 'incomplete' : 'complete'}" class="h-5 w-5 flex-shrink-0 rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50" - on:change={handleToggleComplete} />
    {/if} + {#if item.is_complete && item.price != null} + + ${item.price.toFixed(2)} + + {/if}
    + +
    + {#if item.is_complete} +
    + + { + if (e.key === 'Enter') handleSavePrice(); + }} + class="w-24 rounded border border-gray-300 px-2 py-1 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + disabled={isSavingPrice} + aria-label="Item price" + /> + {#if isSavingPrice} + ... + {/if} +
    + {/if} +
    {/if} @@ -561,13 +727,19 @@ list.updated_at ).toLocaleString()}

    + + {#if totalCost > 0} +
    + Total Cost (Priced Items): ${totalCost.toFixed(2)} +
    + {/if}
    {#if isRefreshing} Refreshing... {/if} - +
    - {#if ocrError || confirmOcrError} - + {#if ocrError || confirmOcrError || calculateSplitError} + {/if} @@ -690,7 +826,7 @@ {/if} {#if list.items && list.items.length > 0}