# P0 Root Cause Analysis: Expense Edit Modal Data Binding Failure **Date:** 2026-07-26 **Scope:** Frontend `CostEntryWizard.vue` + Backend `expenses.py` **Symptom:** Edit modal defaults category to "Finanszírozás", fails to bind vehicle/provider, missing specific details (fuel amount). --- ## 1. Data Storage: How Expenses & Specific Details Are Persisted ### DB Table: `fleet_finance.asset_costs` | Column | Type | Stores | |--------|------|--------| | `category_id` | `int4` | FK → `fleet_finance.cost_categories.id` — **leaf/subcategory ID**, not parent | | `service_provider_id` | `int4` | FK → `marketplace.service_providers.id` — the selected provider | | `vendor_organization_id` | `int4` | FK → `fleet.organizations.id` — B2B vendor org | | `external_vendor_name` | `varchar(200)` | Free-text vendor name | | `data` | `JSONB` | **All specific details:** `liters`, `net_amount`, `vat_rate`, `invoice_number`, `invoice_date`, `payment_method`, `payment_deadline`, `mileage_at_cost`, `vendor_id`, `vendor_name`, `vendor_source` | | `amount_gross` | `numeric(18,2)` | Gross (Bruttó) amount | | `amount_net` | `numeric(18,2)` | Net amount | | `invoice_number` | `varchar(100)` | Dedicated column (duplicated in `data`) | **Key Finding:** Specific details like fuel liters, payment method, invoice date, etc. are stored in the `data` JSONB column. There is **no separate related table** for fuel-specific details. --- ## 2. Backend API Analysis ### 2A. No Single-Expense Read Endpoint Exists The backend has: - `GET /expenses/` — lists all expenses (paginated) - `GET /expenses/{asset_id}` — lists expenses for a specific asset - `PUT /expenses/{expense_id}` — updates an expense **There is NO `GET /expenses/by-id/{expense_id}` endpoint** that returns a single enriched expense with all resolved relationships. ### 2B. List Endpoints Return Raw ORM Objects Both list endpoints return `result.scalars().all()` — raw SQLAlchemy `AssetCost` objects serialized by FastAPI's default JSON encoder. The `AssetCostResponse` Pydantic schema (which includes `category_name`, `category_code`, `vendor_name`, `description`, `mileage_at_cost`) is **defined but never used** by the list endpoints. This means: - `data` (JSONB) is serialized as a raw dict — this part actually works - But `category_name`, `category_code`, `vendor_name` are **not resolved** (they require JOINs or relationship loading) - The response lacks the parent category ID (only the leaf `category_id` is present) ### 2C. PUT Endpoint Has Partial Data Loss Risk The `update_expense` function handles `mileage_at_cost` and `description` inside `data` JSONB correctly, but the frontend's `updateExpense()` in `cost.ts` sends `service_provider_id` only if it's in the payload. The `PUT` endpoint's `AssetCostUpdate` schema does include `service_provider_id: Optional[int]`, so if the frontend sends it, it would work. --- ## 3. Frontend Data Flow: The Edit Path ### Step 1: User clicks Edit → `CostsView.vue` line 527 ```typescript function openEditWizard(cost: any) { const vehicleId = cost.asset_id || cost.vehicle_id editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null editCostData.value = cost // ← passes raw list-response object directly showEditWizard.value = true } ``` The `cost` object comes from the `GET /expenses` list response — a raw SQLAlchemy object with fields: `id`, `asset_id`, `organization_id`, `category_id`, `amount_net`, `amount_gross`, `vat_rate`, `currency`, `date`, `invoice_number`, `status`, `data`, `service_provider_id`, `vendor_organization_id`, `external_vendor_name`, `invoice_date`, `fulfillment_date`. ### Step 2: Hydration watcher → `CostEntryWizard.vue` lines 972-988 ```typescript watch(() => props.editCost, (cost) => { if (!cost) return form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString() form.mainCategory = String(cost.category_id || '') // ← BUG #1 form.subCategory = String(cost.sub_category_id || '') // ← BUG #1 (never exists) form.netAmount = cost.data?.net_amount || cost.amount_net || 0 form.vatRate = cost.data?.vat_rate || 27 form.grossAmount = cost.amount_gross || 0 form.currency = cost.currency || 'HUF' form.invoiceNumber = cost.data?.invoice_number || '' form.invoiceDate = cost.data?.invoice_date || '' form.paymentDeadline = cost.data?.payment_deadline || '' form.paymentMethod = cost.data?.payment_method || 'TRANSFER' form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null form.liters = cost.data?.liters || null // ← BUG #2 area // NOTE: selectedProvider is NEVER set from cost.service_provider_id! ← BUG #3 // NOTE: selectedVehicleId is NEVER set from cost.asset_id! ← BUG #4 }) ``` --- ## 4. Root Cause Map: 5 Specific Bugs ### 🔴 BUG #1 — Category Hierarchy Collapse (CRITICAL) **Location:** `CostEntryWizard.vue:976` **What happens:** - `category_id` in the DB is the **leaf/subcategory** ID (e.g., `id=4` for "Üzemanyag / Benzin") - The wizard has a **two-level** selector: `mainCategory` (parent) → `subCategory` (leaf) - The hydration sets `form.mainCategory = String(cost.category_id || '')` — puts the leaf ID into the parent dropdown - `form.subCategory = String(cost.sub_category_id || '')` — `sub_category_id` doesn't exist in the response, so it's always `''` - When the component renders, `mainCategory` is set to an ID that doesn't match any main category (since it's a leaf), so the `` element - This calls `@change="onMainCategoryChange"` which does: ```typescript form.subCategory = '' // ← CLEARS whatever the hydration may try to set subCategories.value = [] ``` - Even if we fix BUG #1 to correctly populate subCategory, the `@change` handler would clear it **Fix approach:** In the hydration watcher, after setting `mainCategory`, manually call `onMainCategoryChange()` (which populates `subCategories`), and THEN set `form.subCategory` to the correct leaf ID. The order must be: set mainCategory → populate subCategories → set subCategory. --- ## 5. Data Flow Diagram ``` ┌─────────────────────────────────────────────────────────────────────┐ │ BACKEND │ │ │ │ GET /expenses?organization_id=X │ │ └→ returns: [AssetCost ORM objects] │ │ fields: id, asset_id, category_id(leaf!), service_provider_id, │ │ amount_gross, date, data{liters, net_amount, ...}, │ │ external_vendor_name, invoice_date, ... │ │ │ │ MISSING: parent_category_id, category_name, vendor_name, │ │ vehicle_name, license_plate │ │ │ │ There is NO GET /expenses/by-id/{id} endpoint! │ └──────────────────────┬──────────────────────────────────────────────┘ │ raw ORM objects (FastAPI jsonable_encoder) ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ FRONTEND │ │ │ │ CostsView.vue │ │ └→ openEditWizard(cost) │ │ editCostData.value = cost ← raw list item │ │ editVehicle.value = store.find(v => v.id === cost.asset_id) │ │ │ │ CostEntryWizard.vue │ │ └→ watch(editCost): │ │ form.mainCategory = String(cost.category_id) ← BUG: leaf ID │ │ form.subCategory = String(cost.sub_category_id) ← BUG: undef │ │ form.liters = cost.data?.liters │ │ selectedProvider = ??? ← BUG: never │ │ selectedVehicleId = ??? ← BUG: never │ └─────────────────────────────────────────────────────────────────────┘ ``` --- ## 6. Recommended Fix Plan ### Phase 1: Backend (Minimal — No Schema Changes) 1. **Create `GET /expenses/by-id/{expense_id}` endpoint** in `expenses.py` that: - Fetches a single `AssetCost` by ID - JOINs `CostCategory` to resolve `category_name`, `category_code`, `parent_id` - JOINs `ServiceProvider` to resolve provider name - Returns the data using `AssetCostResponse` schema (already defined!) - Includes `parent_category_id` in the response for the two-level dropdown 2. **Optionally, enrich the list endpoint** to return `parent_category_id` by joining `CostCategory.parent_id`, but this is less critical if the single-expense endpoint is used for edit hydration. ### Phase 2: Frontend (Edit Path) 1. **In `CostsView.vue` `openEditWizard()`:** fetch the full expense detail from the new `GET /expenses/by-id/{id}` endpoint instead of passing the raw list object. 2. **In `CostEntryWizard.vue` hydration watcher:** - Split `category_id` into `mainCategory` (parent) and `subCategory` (leaf) using `window.__allCostCategories` - Call `onMainCategoryChange()` after setting `mainCategory` to populate subcategories, then set `subCategory` - Set `selectedProvider.value` from `cost.service_provider_id` + resolved provider name - Set `selectedVehicleId.value` from `cost.asset_id` when vehicle prop is null - Defensive null-check on `cost.data?.liters` ### Phase 3: Edge Cases 1. When `service_provider_id` is null but `external_vendor_name` is set, populate the provider autocomplete with the free-text name 2. Handle the case where `vendor_organization_id` points to a B2B org (different from `service_provider_id`) --- ## 7. Files That Need Changes | File | Change Type | Priority | |------|-------------|----------| | `backend/app/api/v1/endpoints/expenses.py` | Add `GET /expenses/by-id/{id}` endpoint | P0 | | `backend/app/schemas/asset_cost.py` | Add `parent_category_id` to `AssetCostResponse` | P0 | | `frontend_app/src/views/costs/CostsView.vue` | Fetch full expense detail on edit | P0 | | `frontend_app/src/components/cost/CostEntryWizard.vue` | Fix category split, provider hydration, vehicle selection | P0 | | `frontend_app/src/stores/cost.ts` | Add `fetchExpenseById()` action (optional, if store-based) | P1 |