diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index 587a1b8..adc28cc 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -19,7 +19,7 @@ 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(costs.router, prefix="/costs", tags=["Costs"]) -api_router_v1.include_router(financials.router) +api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"]) api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"]) # 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/financials.py b/be/app/api/v1/endpoints/financials.py index 2f9a9ad..0013e41 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -39,7 +39,7 @@ router = APIRouter() # --- Helper for permissions --- async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_id: int, action: str = "access financial data for"): try: - await crud_list.check_list_permission(db=db, list_id=list_id, user_id=user_id, require_member=True) + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=user_id, require_creator=False) except ListPermissionError as e: logger.warning(f"ListPermissionError in check_list_access_for_financials for list {list_id}, user {user_id}, action '{action}': {e.detail}") raise ListPermissionError(list_id, action=action) @@ -135,17 +135,41 @@ async def get_expense( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to view this expense") return expense -@router.get("/lists/{list_id}/expenses", response_model=PyList[ExpensePublic], summary="List Expenses for a List", tags=["Expenses", "Lists"]) -async def list_list_expenses( - list_id: int, +@router.get("/expenses", response_model=PyList[ExpensePublic], summary="List Expenses", tags=["Expenses"]) +async def list_expenses( + list_id: Optional[int] = Query(None, description="Filter by list ID"), + group_id: Optional[int] = Query(None, description="Filter by group ID"), + isRecurring: Optional[bool] = Query(None, description="Filter by recurring expenses"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): - logger.info(f"User {current_user.email} listing expenses for list ID {list_id}") - await check_list_access_for_financials(db, list_id, current_user.id) - expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) + """ + List expenses with optional filters. + If list_id is provided, returns expenses for that list (user must have list access). + If group_id is provided, returns expenses for that group (user must be group member). + If both are provided, returns expenses for the list (list_id takes precedence). + If neither is provided, returns all expenses the user has access to. + """ + logger.info(f"User {current_user.email} listing expenses with filters: list_id={list_id}, group_id={group_id}, isRecurring={isRecurring}") + + if list_id: + # Use existing list expenses endpoint logic + await check_list_access_for_financials(db, list_id, current_user.id) + expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) + elif group_id: + # Use existing group expenses endpoint logic + await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list expenses for") + expenses = await crud_expense.get_expenses_for_group(db, group_id=group_id, skip=skip, limit=limit) + else: + # Get all expenses the user has access to (user's personal expenses + group expenses + 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 + if isRecurring is not None: + expenses = [expense for expense in expenses if bool(expense.recurrence_rule) == isRecurring] + return expenses @router.get("/groups/{group_id}/expenses", response_model=PyList[ExpensePublic], summary="List Expenses for a Group", tags=["Expenses", "Groups"]) diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index 07e1d2f..2b28884 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -13,6 +13,7 @@ from app.schemas.message import Message # For simple responses from app.crud import list as crud_list from app.crud import group as crud_group # Need for group membership check from app.schemas.list import ListStatus +from app.schemas.expense import ExpensePublic # Import ExpensePublic from app.core.exceptions import ( GroupMembershipError, ListNotFoundError, @@ -215,24 +216,53 @@ async def read_list_status( current_user: UserModel = Depends(current_active_user), ): """ - Retrieves the last update time for the list and its items, plus item count. - Used for polling to check if a full refresh is needed. - Requires user to have permission to view the list. + Retrieves the completion status for a specific list + if the user has permission (creator or group member). """ - # Verify user has access to the list first + logger.info(f"User {current_user.email} requesting status for list ID: {list_id}") list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) - if not list_db: - # Check if list exists at all for correct error code - exists = await crud_list.get_list_by_id(db, list_id) - if not exists: - raise ListNotFoundError(list_id) - raise ListPermissionError(list_id, "access this list's status") + + # Calculate status + total_items = len(list_db.items) + completed_items = sum(1 for item in list_db.items if item.is_complete) + + try: + completion_percentage = (completed_items / total_items * 100) if total_items > 0 else 0 + except ZeroDivisionError: + completion_percentage = 0 + + return ListStatus( + list_id=list_db.id, + total_items=total_items, + completed_items=completed_items, + completion_percentage=completion_percentage, + last_updated=list_db.updated_at + ) - # Fetch the status details - list_status = await crud_list.get_list_status(db=db, list_id=list_id) - if not list_status: - # Should not happen if check_list_permission passed, but handle defensively - logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.") - raise ListStatusNotFoundError(list_id) - - return list_status \ No newline at end of file +@router.get( + "/{list_id}/expenses", + response_model=PyList[ExpensePublic], + summary="Get Expenses for List", + tags=["Lists", "Expenses"] +) +async def read_list_expenses( + list_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Retrieves expenses associated with a specific list + if the user has permission (creator or group member). + """ + from app.crud import expense as crud_expense + + logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}") + + # Check if user has permission to access this list + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + + # Get expenses for this list + expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) + return expenses \ No newline at end of file diff --git a/be/app/crud/expense.py b/be/app/crud/expense.py index 5af9656..7d00901 100644 --- a/be/app/crud/expense.py +++ b/be/app/crud/expense.py @@ -203,7 +203,8 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us selectinload(ExpenseModel.list), selectinload(ExpenseModel.group), selectinload(ExpenseModel.item), - selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user) + selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user), + selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.settlement_activities) ) ) result = await db.execute(stmt) @@ -535,6 +536,7 @@ async def get_expense_by_id(db: AsyncSession, expense_id: int) -> Optional[Expen select(ExpenseModel) .options( selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)), selectinload(ExpenseModel.paid_by_user), selectinload(ExpenseModel.list), selectinload(ExpenseModel.group), @@ -550,7 +552,10 @@ async def get_expenses_for_list(db: AsyncSession, list_id: int, skip: int = 0, l .where(ExpenseModel.list_id == list_id) .order_by(ExpenseModel.expense_date.desc(), ExpenseModel.created_at.desc()) .offset(skip).limit(limit) - .options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user))) # Also load user for each split + .options( + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)) + ) ) return result.scalars().all() @@ -560,7 +565,49 @@ async def get_expenses_for_group(db: AsyncSession, group_id: int, skip: int = 0, .where(ExpenseModel.group_id == group_id) .order_by(ExpenseModel.expense_date.desc(), ExpenseModel.created_at.desc()) .offset(skip).limit(limit) - .options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user))) + .options( + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)) + ) + ) + return result.scalars().all() + +async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int = 0, limit: int = 100) -> Sequence[ExpenseModel]: + """ + Get all expenses that a user has access to: + - Expenses they paid for + - Expenses in groups they are members of + - Expenses for lists they have access to + """ + from app.models import UserGroup as UserGroupModel, List as ListModel # Import here to avoid circular imports + + # Build the query for accessible expenses + # 1. Expenses paid by the user + paid_by_condition = ExpenseModel.paid_by_user_id == user_id + + # 2. Expenses in groups where user is a member + group_member_subquery = select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) + group_expenses_condition = ExpenseModel.group_id.in_(group_member_subquery) + + # 3. Expenses for lists where user is creator or has access (simplified to creator for now) + user_lists_subquery = select(ListModel.id).where(ListModel.created_by_id == user_id) + list_expenses_condition = ExpenseModel.list_id.in_(user_lists_subquery) + + # Combine all conditions with OR + combined_condition = paid_by_condition | group_expenses_condition | list_expenses_condition + + result = await db.execute( + select(ExpenseModel) + .where(combined_condition) + .order_by(ExpenseModel.expense_date.desc(), ExpenseModel.created_at.desc()) + .offset(skip).limit(limit) + .options( + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)), + selectinload(ExpenseModel.paid_by_user), + selectinload(ExpenseModel.list), + selectinload(ExpenseModel.group) + ) ) return result.scalars().all() diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts index 052aade..8b5b022 100644 --- a/fe/src/config/api-config.ts +++ b/fe/src/config/api-config.ts @@ -2,7 +2,7 @@ export const API_VERSION = 'v1' // API Base URL -export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' +export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000' // API Endpoints export const API_ENDPOINTS = { @@ -34,6 +34,7 @@ export const API_ENDPOINTS = { BY_ID: (id: string) => `/lists/${id}`, ITEMS: (listId: string) => `/lists/${listId}/items`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, + EXPENSES: (listId: string) => `/lists/${listId}/expenses`, SHARE: (listId: string) => `/lists/${listId}/share`, UNSHARE: (listId: string) => `/lists/${listId}/unshare`, COMPLETE: (listId: string) => `/lists/${listId}/complete`, diff --git a/fe/src/main.ts b/fe/src/main.ts index a517298..30196f2 100644 --- a/fe/src/main.ts +++ b/fe/src/main.ts @@ -1,25 +1,27 @@ -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; -import * as Sentry from '@sentry/vue'; -import { BrowserTracing } from '@sentry/tracing'; -import App from './App.vue'; -import router from './router'; -import { createI18n } from 'vue-i18n'; -import enMessages from './i18n/en.json'; // Import en.json directly -import deMessages from './i18n/de.json'; -import frMessages from './i18n/fr.json'; -import esMessages from './i18n/es.json'; +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import * as Sentry from '@sentry/vue' +import { BrowserTracing } from '@sentry/tracing' +import App from './App.vue' +import router from './router' +import { createI18n } from 'vue-i18n' +import enMessages from './i18n/en.json' // Import en.json directly +import deMessages from './i18n/de.json' +import frMessages from './i18n/fr.json' +import esMessages from './i18n/es.json' // Global styles -import './assets/main.scss'; +import './assets/main.scss' // API client (from your axios boot file) -import { api, globalAxios } from '@/services/api'; // Renamed from boot/axios to services/api -import { useAuthStore } from '@/stores/auth'; +import { api, globalAxios } from '@/services/api' // Renamed from boot/axios to services/api +import { useAuthStore } from '@/stores/auth' // Vue I18n setup (from your i18n boot file) // // export type MessageLanguages = keyof typeof messages; // // export type MessageSchema = (typeof messages)['en-US']; +// // export type MessageLanguages = keyof typeof messages; +// // export type MessageSchema = (typeof messages)['en-US']; // // declare module 'vue-i18n' { // // export interface DefineLocaleMessage extends MessageSchema {} @@ -29,52 +31,52 @@ import { useAuthStore } from '@/stores/auth'; // // export interface DefineNumberFormat {} // // } const i18n = createI18n({ - legacy: false, // Recommended for Vue 3 - locale: 'en', // Default locale - fallbackLocale: 'en', // Fallback locale - messages: { - en: enMessages, - de: deMessages, - fr: frMessages, - es: esMessages, - }, -}); + legacy: false, // Recommended for Vue 3 + locale: 'en', // Default locale + fallbackLocale: 'en', // Fallback locale + messages: { + en: enMessages, + de: deMessages, + fr: frMessages, + es: esMessages, + }, +}) -const app = createApp(App); -const pinia = createPinia(); -app.use(pinia); +const app = createApp(App) +const pinia = createPinia() +app.use(pinia) // Initialize Sentry Sentry.init({ - app, - dsn: import.meta.env.VITE_SENTRY_DSN, - integrations: [ - new BrowserTracing({ - routingInstrumentation: Sentry.vueRouterInstrumentation(router), - tracingOrigins: ['localhost', /^\//], - }), - ], - // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. - // We recommend adjusting this value in production - tracesSampleRate: 1.0, - // Set environment - environment: import.meta.env.MODE, -}); + app, + dsn: import.meta.env.VITE_SENTRY_DSN, + integrations: [ + new BrowserTracing({ + routingInstrumentation: Sentry.vueRouterInstrumentation(router), + tracingOrigins: ['localhost', /^\//], + }), + ], + // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. + // We recommend adjusting this value in production + tracesSampleRate: 1.0, + // Set environment + environment: import.meta.env.MODE, +}) // Initialize auth state before mounting the app -const authStore = useAuthStore(); +const authStore = useAuthStore() if (authStore.accessToken) { - authStore.fetchCurrentUser().catch(error => { - console.error('Failed to initialize current user state:', error); - // The fetchCurrentUser action handles token clearing on failure. - }); + authStore.fetchCurrentUser().catch((error) => { + console.error('Failed to initialize current user state:', error) + // The fetchCurrentUser action handles token clearing on failure. + }) } -app.use(router); -app.use(i18n); +app.use(router) +app.use(i18n) // Make API instance globally available (optional, prefer provide/inject or store) -app.config.globalProperties.$api = api; -app.config.globalProperties.$axios = globalAxios; // The original axios instance if needed +app.config.globalProperties.$api = api +app.config.globalProperties.$axios = globalAxios // The original axios instance if needed -app.mount('#app'); \ No newline at end of file +app.mount('#app') diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index 947c442..d382ffa 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -9,7 +9,7 @@ + :empty-title="t(noListsMessageKey)">