# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py import logging from decimal import Decimal from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc from app.api.deps import get_db, get_current_user, RequireOrgCapability from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization from app.schemas.asset_cost import AssetCostCreate from datetime import datetime, timezone logger = logging.getLogger(__name__) # Cost category IDs that are service/maintenance/repair related # These trigger automatic AssetEvent creation SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES # Fuel category IDs - auto-approved even for DRIVER role FUEL_CATEGORY_IDS = {1} # FUEL router = APIRouter() async def _resolve_user_role_in_org( db: AsyncSession, user_id: int, organization_id: int ) -> str: """ Resolve the user's role within the given organization. Returns: str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.) Returns 'MEMBER' as default if no membership found. """ stmt = select(OrganizationMember.role).where( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == organization_id, OrganizationMember.status == "active" ).limit(1) result = await db.execute(stmt) role = result.scalar_one_or_none() return role or "MEMBER" async def _check_org_capability( db: AsyncSession, user_id: int, organization_id: int, capability_name: str ) -> bool: """ RBAC Phase 2: JSONB-alapú képesség-ellenőrzés. Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla permissions JSONB oszlopából ellenőrzi a kért képességet. Returns: bool: True ha a user rendelkezik a képességgel. """ # 1. Get user's org role member_stmt = select(OrganizationMember.role).where( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == organization_id, OrganizationMember.status == "active" ).limit(1) member_result = await db.execute(member_stmt) org_role_name = member_result.scalar_one_or_none() if not org_role_name: return False # 2. Get permissions from fleet.org_roles table role_stmt = select(OrgRole.permissions).where( OrgRole.name_key == org_role_name, OrgRole.is_active == True ).limit(1) role_result = await db.execute(role_stmt) permissions = role_result.scalar_one_or_none() if not permissions: # Fallback to OrganizationMember.permissions member_perm_stmt = select(OrganizationMember.permissions).where( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == organization_id, OrganizationMember.status == "active" ).limit(1) member_perm_result = await db.execute(member_perm_stmt) permissions = member_perm_result.scalar_one_or_none() or {} if not isinstance(permissions, dict): permissions = {} return permissions.get(capability_name, False) def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal: """ Calculate net amount from gross amount and VAT rate. GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth. Net is calculated back from gross. Formula: net = gross / (1 + vat_rate / 100) If vat_rate is 0 or None, net = gross (0% VAT content). """ if vat_rate is None or vat_rate == 0: return amount_gross return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01")) @router.get("") async def list_all_expenses( db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(20, ge=1, le=100, description="Items per page"), asset_id: Optional[str] = Query(None, description="Filter by asset UUID"), organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."), ): """ List all expenses across all assets for the current user's organization, ordered by date descending with pagination. Supports optional asset_id filter for vehicle-specific cost views. Supports optional organization_id filter for cross-org views (user must be a member). Returns enriched expense data including vehicle info, category name, and vendor. """ # Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org if organization_id is not None: # Verify user is a member of the requested organization org_stmt = select(OrganizationMember).where( OrganizationMember.user_id == current_user.id, OrganizationMember.organization_id == organization_id, OrganizationMember.status == "active" ).limit(1) org_result = await db.execute(org_stmt) membership = org_result.scalar_one_or_none() if not membership: raise HTTPException( status_code=403, detail="You are not an active member of the requested organization." ) org_id = organization_id else: # Fallback: resolve user's first active organization org_stmt = select(OrganizationMember).where( OrganizationMember.user_id == current_user.id, OrganizationMember.status == "active" ).limit(1) org_result = await db.execute(org_stmt) membership = org_result.scalar_one_or_none() if not membership: raise HTTPException(status_code=403, detail="No active organization membership found.") org_id = membership.organization_id # Calculate offset offset = (page - 1) * page_size # Build base query with joins base_select = ( select( AssetCost, CostCategory.code, CostCategory.name, Organization.name, Asset.license_plate, Asset.brand, Asset.model, ) .outerjoin(CostCategory, AssetCost.category_id == CostCategory.id) .outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id) .join(Asset, AssetCost.asset_id == Asset.id) ) # Apply filters filters = [AssetCost.organization_id == org_id] if asset_id: filters.append(AssetCost.asset_id == asset_id) expense_stmt = ( base_select .where(*filters) .order_by(desc(AssetCost.date)) .offset(offset) .limit(page_size) ) expense_result = await db.execute(expense_stmt) rows = expense_result.all() # Build enriched response expenses = [] for row in rows: cost = row[0] cat_code = row[1] cat_name = row[2] vendor_name = row[3] license_plate = row[4] brand = row[5] model = row[6] data = cost.data or {} description = data.get("description") mileage_at_cost = data.get("mileage_at_cost") # Build vehicle display name vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown" expenses.append({ "id": str(cost.id), "asset_id": str(cost.asset_id), "organization_id": cost.organization_id, "category_id": cost.category_id, "category_code": cat_code, "category_name": cat_name, "amount_gross": str(cost.amount_gross) if cost.amount_gross else None, "amount_net": str(cost.amount_net), "vat_rate": str(cost.vat_rate) if cost.vat_rate else None, "currency": cost.currency, "date": cost.date.isoformat() if cost.date else None, "status": cost.status, "description": description, "mileage_at_cost": mileage_at_cost, "invoice_number": cost.invoice_number, "linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None, "vendor_organization_id": cost.vendor_organization_id, "external_vendor_name": cost.external_vendor_name, "vendor_name": vendor_name, "invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None, "fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None, # Vehicle info "vehicle_name": vehicle_name, "license_plate": license_plate, }) # Count total for pagination (respect asset_id filter) count_filters = [AssetCost.organization_id == org_id] if asset_id: count_filters.append(AssetCost.asset_id == asset_id) count_stmt = ( select(func.count(AssetCost.id)) .where(*count_filters) ) count_result = await db.execute(count_stmt) total = count_result.scalar() total_pages = max(1, (total + page_size - 1) // page_size) return { "data": expenses, "total": total, "page": page, "page_size": page_size, "total_pages": total_pages, } @router.get("/{asset_id}") async def list_asset_expenses( asset_id: UUID, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user), limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"), offset: int = Query(0, ge=0, description="Number of expenses to skip"), ): """ List all expenses for a specific asset, ordered by date descending. Returns enriched expense data including category name, code, and vendor info. Used by the OverviewTab and CostManagerModal to display real expense data. """ # Validate asset exists stmt = select(Asset).where(Asset.id == asset_id) result = await db.execute(stmt) asset = result.scalar_one_or_none() if not asset: raise HTTPException(status_code=404, detail="Asset not found.") # Fetch expenses with category join and vendor organization join expense_stmt = ( select(AssetCost, CostCategory.code, CostCategory.name, Organization.name) .outerjoin(CostCategory, AssetCost.category_id == CostCategory.id) .outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id) .where(AssetCost.asset_id == asset_id) .order_by(desc(AssetCost.date)) .offset(offset) .limit(limit) ) expense_result = await db.execute(expense_stmt) rows = expense_result.all() # Build enriched response expenses = [] for row in rows: cost = row[0] # AssetCost instance cat_code = row[1] # CostCategory.code cat_name = row[2] # CostCategory.name vendor_name = row[3] # Organization.name (resolved from vendor_organization_id) # Extract description and mileage from data JSONB data = cost.data or {} description = data.get("description") mileage_at_cost = data.get("mileage_at_cost") expenses.append({ "id": str(cost.id), "asset_id": str(cost.asset_id), "organization_id": cost.organization_id, "category_id": cost.category_id, "category_code": cat_code, "category_name": cat_name, "amount_gross": str(cost.amount_gross) if cost.amount_gross else None, "amount_net": str(cost.amount_net), "vat_rate": str(cost.vat_rate) if cost.vat_rate else None, "currency": cost.currency, "date": cost.date.isoformat() if cost.date else None, "status": cost.status, "description": description, "mileage_at_cost": mileage_at_cost, "invoice_number": cost.invoice_number, "linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None, # === B2B VENDOR FIELDS === "vendor_organization_id": cost.vendor_organization_id, "external_vendor_name": cost.external_vendor_name, "vendor_name": vendor_name, # Enriched from fleet.organizations.name # === INVOICE DATES === "invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None, "fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None, }) # Count total for pagination count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id) count_result = await db.execute(count_stmt) total = count_result.scalar() return { "data": expenses, "total": total, "limit": limit, "offset": offset, } @router.post("/", status_code=201) async def create_expense( expense: AssetCostCreate, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user) ): """ Create a new expense (fuel, service, tax, insurance) for an asset. Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc. **Role-Based Auto-Approval:** - OWNER/ADMIN → status = APPROVED - DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved) **Smart Linking (Double-Entry Avoidance):** - If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES), an AssetEvent is automatically created with MISSING_TECH_DATA status and linked via linked_asset_event_id / linked_expense_id. """ # Validate asset exists stmt = select(Asset).where(Asset.id == expense.asset_id) result = await db.execute(stmt) asset = result.scalar_one_or_none() if not asset: raise HTTPException(status_code=404, detail="Asset not found.") # Dynamic Gatekeeper: Check draft expense limit if asset.status == "draft": # 1. Get VEHICLE_DRAFT_MAX_EXPENSES parameter param_stmt = select(SystemParameter).where( SystemParameter.key == "VEHICLE_DRAFT_MAX_EXPENSES", SystemParameter.scope_level == "global" ) param_result = await db.execute(param_stmt) param = param_result.scalar_one_or_none() if param: limit = param.value.get("limit", 10) # Default to 10 if not found else: limit = 10 # Default fallback # 2. Count existing expenses for this asset count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset.id) count_result = await db.execute(count_stmt) expense_count = count_result.scalar() # 3. Check if limit reached if expense_count >= limit: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses." ) # Determine organization_id: prefer explicit from payload, fallback to asset fields organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id if not organization_id: # B2C fallback: if the asset is owned by a person (not an org), # try to find the current user's default organization org_stmt = select(OrganizationMember).where( OrganizationMember.user_id == current_user.id, OrganizationMember.is_verified == True ).limit(1) org_result = await db.execute(org_stmt) org_member = org_result.scalar_one_or_none() if org_member: organization_id = org_member.organization_id else: # Last resort: any membership (even unverified) org_stmt = select(OrganizationMember).where( OrganizationMember.user_id == current_user.id ).limit(1) org_result = await db.execute(org_stmt) org_member = org_result.scalar_one_or_none() if org_member: organization_id = org_member.organization_id else: raise HTTPException(status_code=400, detail="Asset has no associated organization.") # ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ── # JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id) # Check if user has can_approve_expense capability → auto-approved can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense") # Determine expense status based on capabilities if can_approve: # User has can_approve_expense → auto-approved expense_status = "APPROVED" elif expense.category_id in FUEL_CATEGORY_IDS: # Fuel costs are auto-approved even without can_approve_expense expense_status = "APPROVED" else: # User lacks can_approve_expense → pending approval expense_status = "PENDING_APPROVAL" logger.info( f"Capability-based approval for user {current_user.id} (role={user_role}) " f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}" ) # ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ── # In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth. amount_gross = expense.amount_gross vat_rate = expense.vat_rate amount_net = expense.amount_net # Auto-calculate net from gross if vat_rate is provided but net is not if vat_rate is not None and amount_net is None: amount_net = _calculate_net_from_gross(amount_gross, vat_rate) # Auto-calculate vat_rate if net is provided but vat_rate is not elif amount_net is not None and vat_rate is None and amount_gross > 0: # vat_rate = ((gross / net) - 1) * 100 vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100") vat_rate = vat_rate.quantize(Decimal("0.01")) # If only gross is provided (no net, no vat) → net = gross (0% VAT) elif amount_net is None and vat_rate is None: amount_net = amount_gross vat_rate = Decimal("0") # Prepare data JSON for extra fields (mileage_at_cost, description, etc.) data = expense.data.copy() if expense.data else {} if expense.mileage_at_cost is not None: data["mileage_at_cost"] = expense.mileage_at_cost if expense.description: data["description"] = expense.description try: # Create AssetCost instance with new fields new_cost = AssetCost( asset_id=expense.asset_id, organization_id=organization_id, category_id=expense.category_id, amount_net=amount_net, amount_gross=amount_gross, vat_rate=vat_rate, currency=expense.currency, date=expense.cost_date, invoice_number=data.get("invoice_number"), status=expense_status, data=data, # === B2B VENDOR FIELDS === vendor_organization_id=expense.vendor_organization_id, external_vendor_name=expense.external_vendor_name, # === INVOICE DATES === invoice_date=expense.invoice_date, fulfillment_date=expense.fulfillment_date, ) db.add(new_cost) await db.flush() # Flush to get new_cost.id # ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ── event_id = None if expense.category_id in SERVICE_RELATED_CATEGORY_IDS: # Map cost category to event type event_type = _map_category_to_event_type(expense.category_id) description = expense.description or data.get("description", f"Service cost: {expense.category_id}") mileage = expense.mileage_at_cost # Determine event status based on capability # can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA" new_event = AssetEvent( asset_id=expense.asset_id, user_id=getattr(current_user, 'id', None), organization_id=organization_id, event_type=event_type, odometer_reading=mileage, description=description, cost_id=new_cost.id, linked_expense_id=new_cost.id, # Bidirectional link status=event_status, event_date=expense.cost_date or datetime.now(timezone.utc), ) db.add(new_event) await db.flush() # Flush to get new_event.id event_id = new_event.id # Update the cost record with the link back to the event new_cost.linked_asset_event_id = new_event.id logger.info( f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) " f"for AssetCost {new_cost.id} (category_id={expense.category_id}). " f"Bidirectional link established." ) # Update Asset.current_mileage if mileage_at_cost is higher if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0): asset.current_mileage = expense.mileage_at_cost await db.commit() await db.refresh(new_cost) return { "status": "success", "id": new_cost.id, "asset_id": new_cost.asset_id, "category_id": new_cost.category_id, "amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None, "amount_net": str(new_cost.amount_net), "vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None, "expense_status": new_cost.status, "date": new_cost.date.isoformat() if new_cost.date else None, "event_id": str(event_id) if event_id else None, } except Exception as e: await db.rollback() logger.error(f"Expense creation error for asset {expense.asset_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Hiba a költség rögzítésekor" ) def _map_category_to_event_type(category_id: int) -> str: """ Map a cost category ID to the appropriate AssetEvent event_type. Returns: str: The event type string (SERVICE, REPAIR, etc.) """ category_event_map = { 2: "MAINTENANCE", # MAINTENANCE 16: "SERVICE", # MAINT_SERVICE 17: "SERVICE", # MAINT_OIL 18: "REPAIR", # MAINT_BRAKES } return category_event_map.get(category_id, "MAINTENANCE")