# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py """ Expense (AssetCost) API endpoints. P0 Smart Expense Workflow (2026-06-20): - POST /expenses/ — Create expense with odometer normalization, provider validation, smart linking to AssetEvent, and gamification hooks. - GET /expenses/ — List all expenses (admin). - GET /expenses/{asset_id} — List expenses for a specific asset. - PUT /expenses/{expense_id} — Update an existing expense. GROSS-FIRST (Masterbook 2.0.1): - amount_gross is the primary field (Bruttó). - amount_net is optional — calculated back from gross + vat_rate. - All VAT calculations happen in the service layer, not in the schema. P0 HYBRID VENDOR REFACTOR (2026-07-01): - service_provider_id (marketplace.service_providers) is the primary vendor FK. - vendor_organization_id (fleet.organizations) is secondary (B2B). - external_vendor_name is the fallback for free-text typed names. P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation. - A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként, ami FK violation-t okoz. Itt validáljuk, hogy a megadott ID létezik-e a fleet.organizations táblában. Ha nem, átirányítjuk service_provider_id-ra. """ import uuid import logging from datetime import datetime, timezone, date from decimal import Decimal from typing import Any, Dict, List, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, func, and_, or_, cast, Text, case, literal, union_all from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db, get_current_user from app.models.fleet_finance.models import AssetCost, CostCategory from app.models.vehicle.asset import Asset from app.models.vehicle.asset_event import AssetEvent from app.models.vehicle.odometer import OdometerReading from app.models.fleet.organization import Organization from app.models.fleet.org_member import OrganizationMember from app.models.marketplace.service import ServiceProvider, ServiceProfile, ServiceExpertise from app.models.system.system_parameter import SystemParameter from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse from app.services.gamification_service import GamificationService from app.services.provider_service import find_or_create_provider_by_name logger = logging.getLogger(__name__) router = APIRouter() # ── FUEL CATEGORY IDS ── # These are the category IDs that represent fuel costs. # Used for auto-approval logic: fuel costs are always auto-approved. FUEL_CATEGORY_IDS = {1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} # ── SERVICE-RELATED CATEGORY IDS ── # These categories trigger automatic AssetEvent creation (Smart Linking). SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} gamification_service = GamificationService() # ── HELPER FUNCTIONS ── async def _resolve_user_role_in_org( db: AsyncSession, user_id: int, organization_id: int, ) -> Optional[str]: """Resolve the user's role in the given organization. Returns the role name (e.g. 'owner', 'admin', 'driver') or None if not found. """ stmt = select(OrganizationMember).where( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == organization_id, OrganizationMember.status == "active", ) result = await db.execute(stmt) member = result.scalar_one_or_none() if member: return member.role return None async def _check_org_capability( db: AsyncSession, user_id: int, organization_id: int, capability: str, ) -> bool: """Check if a user has a specific capability in an organization. Uses the JSONB permissions field from fleet.org_roles. Returns True if the user's role has the capability, False otherwise. """ from app.models.fleet.org_role import OrgRole stmt = ( select(OrgRole.permissions) .join(OrganizationMember, OrganizationMember.role == OrgRole.name) .where( OrganizationMember.user_id == user_id, OrganizationMember.organization_id == organization_id, OrganizationMember.status == "active", ) ) result = await db.execute(stmt) row = result.scalar_one_or_none() if row and isinstance(row, dict): return row.get(capability, False) return False def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal: """Calculate net amount from gross amount and VAT rate. Formula: net = gross / (1 + vat_rate/100) Args: amount_gross: The gross amount (Bruttó) vat_rate: The VAT rate in percent (e.g., 27.00 for 27%) Returns: The net amount (Nettó) """ if vat_rate == 0: return amount_gross divisor = Decimal("1") + (vat_rate / Decimal("100")) return (amount_gross / divisor).quantize(Decimal("0.01")) # ── ENDPOINTS ── @router.get("") async def list_all_expenses( db: AsyncSession = Depends(get_db), current_user=Depends(get_current_user), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), asset_id: Optional[uuid.UUID] = Query(None), organization_id: Optional[int] = Query(None), category_id: Optional[int] = Query(None), status: Optional[str] = Query(None), sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc, amount_desc, amount_asc"), ): """ List all expenses (admin endpoint). Supports filtering by asset_id, organization_id, category_id, status. Supports sorting by date or amount. """ stmt = select(AssetCost) if asset_id: stmt = stmt.where(AssetCost.asset_id == asset_id) if organization_id: stmt = stmt.where(AssetCost.organization_id == organization_id) if category_id: stmt = stmt.where(AssetCost.category_id == category_id) if status: stmt = stmt.where(AssetCost.status == status) # Sorting if sort == "date_asc": stmt = stmt.order_by(AssetCost.date.asc()) elif sort == "amount_desc": stmt = stmt.order_by(AssetCost.amount_gross.desc()) elif sort == "amount_asc": stmt = stmt.order_by(AssetCost.amount_gross.asc()) else: stmt = stmt.order_by(AssetCost.date.desc()) # Count total count_stmt = select(func.count()).select_from(stmt.subquery()) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # Paginate stmt = stmt.offset(offset).limit(limit) result = await db.execute(stmt) expenses = result.scalars().all() return { "status": "success", "total": total, "offset": offset, "limit": limit, "data": expenses, } @router.put("/{expense_id}", status_code=200) async def update_expense( expense_id: uuid.UUID, expense_update: AssetCostUpdate, db: AsyncSession = Depends(get_db), current_user=Depends(get_current_user), ): """ Update an existing expense (PUT /expenses/{expense_id}). All fields in AssetCostUpdate are optional — only provided fields will be updated. The expense_id is passed via path parameter. """ # Fetch existing expense stmt = select(AssetCost).where(AssetCost.id == expense_id) result = await db.execute(stmt) existing = result.scalar_one_or_none() if not existing: raise HTTPException(status_code=404, detail="Expense not found.") # Update only provided fields update_data = expense_update.model_dump(exclude_unset=True) # Handle special fields if "date" in update_data: update_data["date"] = update_data.pop("date") if "mileage_at_cost" in update_data: # mileage_at_cost is stored inside data JSONB if existing.data is None: existing.data = {} existing.data["mileage_at_cost"] = update_data.pop("mileage_at_cost") if "description" in update_data: if existing.data is None: existing.data = {} existing.data["description"] = update_data.pop("description") # Apply updates for field, value in update_data.items(): setattr(existing, field, value) await db.commit() await db.refresh(existing) return { "status": "success", "id": existing.id, "message": "Expense updated successfully.", } @router.get("/{asset_id}") async def list_asset_expenses( asset_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user=Depends(get_current_user), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc"), ): """ List expenses for a specific asset. Supports pagination and sorting by date. """ stmt = ( select(AssetCost) .where(AssetCost.asset_id == asset_id) ) # Sorting if sort == "date_asc": stmt = stmt.order_by(AssetCost.date.asc()) else: stmt = stmt.order_by(AssetCost.date.desc()) # Count total count_stmt = select(func.count()).select_from(stmt.subquery()) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # Paginate stmt = stmt.offset(offset).limit(limit) result = await db.execute(stmt) expenses = result.scalars().all() return { "status": "success", "total": total, "offset": offset, "limit": limit, "data": expenses, } @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." ) # P0 ARCHITECTURE FIX: organization_id MUST follow the Asset, not the user. # The cost belongs to the asset's current organization (e.g., Aprilia garage = org 21). # The vendor is stored separately via vendor_organization_id. # # Resolution priority: # 1. asset.current_organization_id (the asset's current fleet/garage) # 2. asset.owner_org_id (the asset's owning organization) # 3. Fallback: user's active organization from membership if asset.current_organization_id: organization_id = asset.current_organization_id elif asset.owner_org_id: organization_id = asset.owner_org_id else: # Fallback: user's active organization from membership org_stmt = select(OrganizationMember).where( OrganizationMember.user_id == current_user.id, OrganizationMember.status == "active" ).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 # ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ── # Ha external_vendor_name meg van adva, de service_provider_id nincs, # automatikusan felfedezzük vagy létrehozzuk a providert. # # P0 ARCHITECTURE CLEANUP (2026-07-01): # A gamification/XP logika ELTÁVOLÍTVA a service rétegből. # A find_or_create_provider_by_name() már csak (provider, created) tuple-t ad vissza. # Ha created=True (új provider felfedezve), itt az API rétegben hívjuk meg # a gamification_service.award_points()-t a PROVIDER_DISCOVERY action_key-kel. resolved_provider_id = expense.service_provider_id if expense.external_vendor_name and not expense.service_provider_id: try: provider, created = await find_or_create_provider_by_name( db=db, name=expense.external_vendor_name, added_by_user_id=current_user.id, ) resolved_provider_id = provider.id # P0 ARCHITECTURE CLEANUP: Gamification XP kiosztása az API rétegben # Csak akkor adunk XP-t, ha a provider újonnan lett felfedezve (created=True) if created: await gamification_service.award_points( db=db, user_id=current_user.id, amount=0, reason="PROVIDER_DISCOVERY", commit=False, # A hívó (create_expense) kezeli a commit-ot action_key="PROVIDER_DISCOVERY", ) logger.info( f"Gamification XP awarded for PROVIDER_DISCOVERY: " f"name='{expense.external_vendor_name}', " f"provider_id={provider.id}, user_id={current_user.id}" ) logger.info( f"Provider auto-discovery: name='{expense.external_vendor_name}', " f"provider_id={provider.id}, created={created}, " f"user_id={current_user.id}" ) except Exception as e: # Ha a provider felderítés hibázik, ne blokkolja a költség rögzítését logger.warning( f"Provider auto-discovery failed for '{expense.external_vendor_name}': {e}. " f"Expense will be created without provider link." ) try: # ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ── # A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként, # ami FK violation-t okoz, mert az FK a fleet.organizations táblára hivatkozik. # Itt validáljuk, hogy a megadott vendor_organization_id létezik-e. resolved_vendor_org_id = expense.vendor_organization_id if resolved_vendor_org_id is not None: try: org_check_stmt = select(Organization.id).where( Organization.id == resolved_vendor_org_id, Organization.is_deleted == False, ) org_check_result = await db.execute(org_check_stmt) org_exists = org_check_result.scalar_one_or_none() if org_exists is None: # A megadott ID nem létezik fleet.organizations-ben. # Valószínűleg ServiceProvider.id-t küldött a frontend. logger.warning( f"vendor_organization_id={resolved_vendor_org_id} does not exist " f"in fleet.organizations. Setting to None and using service_provider_id." ) resolved_vendor_org_id = None # Ha nincs resolved_provider_id, akkor a vendor_organization_id-t # használjuk service_provider_id-ként (P0 Hybrid Vendor Refactor) if resolved_provider_id is None: resolved_provider_id = expense.vendor_organization_id except Exception as org_check_e: logger.warning( f"vendor_organization_id validation failed for " f"id={resolved_vendor_org_id}: {org_check_e}. Setting to None." ) resolved_vendor_org_id = None # ── PHASE 1: 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=resolved_vendor_org_id, # P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben # Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk service_provider_id=resolved_provider_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: ODOMETER NORMALIZATION ── # P0 Smart Expense Workflow: Create a dedicated OdometerReading record # linked to the asset_id and the newly created cost_id. odometer_record = None if expense.mileage_at_cost is not None: odometer_record = OdometerReading( asset_id=expense.asset_id, reading=expense.mileage_at_cost, source="expense", cost_id=new_cost.id, ) db.add(odometer_record) await db.flush() logger.info( f"Odometer normalization: created reading {odometer_record.id} " f"for asset {expense.asset_id}, reading={expense.mileage_at_cost}, " f"linked to cost {new_cost.id}" ) # ── PHASE 3: PROVIDER VALIDATION SCORE INCREMENT ── # P0 Smart Expense Workflow: If service_provider_id is provided, # increment validation_score by 10. If it reaches 100, set is_verified. provider_validated = False provider_id_for_gamification = resolved_provider_id or expense.service_provider_id if provider_id_for_gamification is not None: try: provider_stmt = select(ServiceProvider).where( ServiceProvider.id == provider_id_for_gamification ) provider_result = await db.execute(provider_stmt) provider = provider_result.scalar_one_or_none() if provider: old_score = provider.validation_score or 0 if old_score < 100: new_score = min(old_score + 10, 100) provider.validation_score = new_score provider_validated = True logger.info( f"Provider validation score incremented: provider_id={provider.id}, " f"old_score={old_score}, new_score={new_score}" ) # If validation_score reaches 100, mark the provider as verified if new_score >= 100: # Update ServiceProvider status to approved from app.models.identity.social import ModerationStatus provider.status = ModerationStatus.approved # Also update the linked ServiceProfile.is_verified if exists profile_stmt = select(ServiceProfile).where( ServiceProfile.organization_id == provider.id ) profile_result = await db.execute(profile_stmt) profile = profile_result.scalar_one_or_none() if profile: profile.is_verified = True logger.info( f"Provider fully verified: provider_id={provider.id}, " f"profile_id={profile.id}" ) except Exception as prov_e: # Provider validation failure should not block expense creation logger.warning( f"Provider validation score increment failed for " f"provider_id={provider_id_for_gamification}: {prov_e}" ) # ── PHASE 4: 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 # ── PHASE 5: GAMIFICATION HOOKS ── # P0 Smart Expense Workflow: Award XP for expense logging and provider validation. # All gamification calls use commit=False to stay within the outer transaction. # 5a. Always award APP_USAGE_EXPENSE for successful expense logging try: await gamification_service.award_points( db=db, user_id=current_user.id, amount=0, reason="APP_USAGE_EXPENSE", commit=False, action_key="APP_USAGE_EXPENSE", ) logger.info( f"Gamification XP awarded for APP_USAGE_EXPENSE: " f"user_id={current_user.id}, cost_id={new_cost.id}" ) except Exception as gam_e: logger.warning( f"Gamification APP_USAGE_EXPENSE failed for user {current_user.id}: {gam_e}" ) # 5b. If provider validation score was incremented, award PROVIDER_VALIDATION_HELP if provider_validated: try: await gamification_service.award_points( db=db, user_id=current_user.id, amount=0, reason="PROVIDER_VALIDATION_HELP", commit=False, action_key="PROVIDER_VALIDATION_HELP", ) logger.info( f"Gamification XP awarded for PROVIDER_VALIDATION_HELP: " f"user_id={current_user.id}, provider_id={provider_id_for_gamification}" ) except Exception as gam_e: logger.warning( f"Gamification PROVIDER_VALIDATION_HELP failed for " f"user {current_user.id}: {gam_e}" ) # ── FINAL COMMIT: Single atomic transaction ── 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, "odometer_reading_id": str(odometer_record.id) if odometer_record else None, "provider_validated": provider_validated, } 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")