14 KiB
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 assetPUT /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_nameare not resolved (they require JOINs or relationship loading) - The response lacks the parent category ID (only the leaf
category_idis 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
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
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_idin the DB is the leaf/subcategory ID (e.g.,id=4for "Ü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_iddoesn't exist in the response, so it's always''- When the component renders,
mainCategoryis set to an ID that doesn't match any main category (since it's a leaf), so the<select>defaults to the first option: "Finanszírozás" - Subcategories are never populated because
onMainCategoryChange()was never triggered with the correct parent ID
User experience: Category dropdown shows "Finanszírozás" (first item) instead of the actual category.
Fix approach:
- Backend must return
parent_category_idalongsidecategory_idin the response, OR - Frontend must look up the parent from the flattened category tree (
window.__allCostCategories) and splitcategory_idinto main+sub correctly
🔴 BUG #2 — Vehicle Not Bound When Not in Store (HIGH)
Location: CostsView.vue:530 + CostEntryWizard.vue:93
What happens:
openEditWizardfinds the vehicle bycost.asset_idinvehicleStore.vehicles- If the vehicle is not in the store (e.g., filtered by another org, or not loaded),
editVehicleisnull - In
CostEntryWizard.vue:77, wheneditVehicleis null ANDuserVehicles.length > 0, the global vehicle dropdown is shown - But
selectedVehicleIdis never set fromcost.asset_idin the hydration watcher - Result: vehicle dropdown shows "Select Vehicle..." with nothing selected
Fix approach: In the hydration watcher, set selectedVehicleId.value = cost.asset_id when props.vehicle is null.
🔴 BUG #3 — Provider/ServiceProvider Not Hydrated (HIGH)
Location: CostEntryWizard.vue:972-988 (absence of provider hydration)
What happens:
- The DB stores
service_provider_id(int) andexternal_vendor_name(string) directly on the AssetCost row - The list response includes these fields
- But the hydration watcher never reads
cost.service_provider_idorcost.external_vendor_name selectedProviderref is never populated, soProviderAutocompleteshows empty- The user sees no provider selected even though one was saved
Fix approach: In the hydration watcher, if cost.service_provider_id exists, construct a ProviderSearchResult object and set selectedProvider.value. If only external_vendor_name exists, set it as the provider name.
🟡 BUG #4 — data.liters May Be Lost in List Serialization (MEDIUM)
Location: CostEntryWizard.vue:987
What happens:
- The hydration reads
cost.data?.liters - The list endpoint returns raw SQLAlchemy objects; the
dataJSONB column should serialize correctly as a dict via FastAPI'sjsonable_encoder - However, if the
datacolumn isNoneor{}in the DB,cost.data?.litersreturnsundefined, and|| nullgivesnull - This should work for most cases, but there's a subtle issue: the
datafield on the SQLAlchemy model hasserver_default="'{}'::jsonb", but if a row was inserted without going through SQLAlchemy (e.g., raw SQL),datacould beNone
Fix approach: Add defensive null-check: (cost.data && cost.data.liters) ? cost.data.liters : null
🟡 BUG #5 — mainCategory Watcher Clears subCategory on Hydration (MEDIUM)
Location: CostEntryWizard.vue:818-819
What happens:
- The hydration watcher runs and sets
form.mainCategory = String(cost.category_id || '') - Vue's reactivity triggers the
v-modelchange on the<select>element - This calls
@change="onMainCategoryChange"which does:form.subCategory = '' // ← CLEARS whatever the hydration may try to set subCategories.value = [] - Even if we fix BUG #1 to correctly populate subCategory, the
@changehandler 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)
-
Create
GET /expenses/by-id/{expense_id}endpoint inexpenses.pythat:- Fetches a single
AssetCostby ID - JOINs
CostCategoryto resolvecategory_name,category_code,parent_id - JOINs
ServiceProviderto resolve provider name - Returns the data using
AssetCostResponseschema (already defined!) - Includes
parent_category_idin the response for the two-level dropdown
- Fetches a single
-
Optionally, enrich the list endpoint to return
parent_category_idby joiningCostCategory.parent_id, but this is less critical if the single-expense endpoint is used for edit hydration.
Phase 2: Frontend (Edit Path)
-
In
CostsView.vueopenEditWizard(): fetch the full expense detail from the newGET /expenses/by-id/{id}endpoint instead of passing the raw list object. -
In
CostEntryWizard.vuehydration watcher:- Split
category_idintomainCategory(parent) andsubCategory(leaf) usingwindow.__allCostCategories - Call
onMainCategoryChange()after settingmainCategoryto populate subcategories, then setsubCategory - Set
selectedProvider.valuefromcost.service_provider_id+ resolved provider name - Set
selectedVehicleId.valuefromcost.asset_idwhen vehicle prop is null - Defensive null-check on
cost.data?.liters
- Split
Phase 3: Edge Cases
- When
service_provider_idis null butexternal_vendor_nameis set, populate the provider autocomplete with the free-text name - Handle the case where
vendor_organization_idpoints to a B2B org (different fromservice_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 |