# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py import logging from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.api.deps import get_db, get_current_user, RequireOrgCapability from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole 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.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.date, invoice_number=data.get("invoice_number"), status=expense_status, data=data ) 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.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")