# P0 Cost Pipeline Visibility Audit Report **Date:** 2026-06-23 **Author:** Fast Coder (Core Developer) **Status:** Complete **Gitea Card:** #282 --- ## Executive Summary The `/dashboard/costs` page is **empty** for User 28 (tester_pro@profibot.hu) despite Org 1 having **5 confirmed records** in `fleet_finance.asset_costs` (for vehicles BMW123 and QWE432). The root cause is a **backend organization resolution bug** in the `GET /expenses/` endpoint. --- ## 1. Frontend Audit ### CostsView.vue - **File:** [`frontend/src/views/costs/CostsView.vue`](frontend/src/views/costs/CostsView.vue:299) - Calls `api.get('/expenses', { params })` at line 299 - **Does NOT pass any `organization_id` parameter** — only sends `page`, `page_size`, and optionally `asset_id` - The frontend relies entirely on the backend to resolve the correct organization ### Pinia Stores - **`cost.ts`** ([`frontend/src/stores/cost.ts`](frontend/src/stores/cost.ts)) — Only handles POST (create expense), not fetching - **`expense.ts`** ([`frontend/src/stores/expense.ts`](frontend/src/stores/expense.ts)) — Fetches by asset ID (`/expenses/{asset_id}`), but is NOT used by CostsView.vue ### Data Mapping The frontend template reads these keys from the API response: - `cost.created_at` or `cost.date` (line 127) - `cost.category_code` or `cost.cost_type` (line 134) - `cost.category_name` or `cost.cost_type` (line 136) - `cost.amount_gross` (line 143) - `cost.currency` (line 143) - `cost.vehicle_name` or `cost.license_plate` (line 149) - `cost.description` (line 155) The backend API returns all these keys correctly (see lines 193-218 of expenses.py). **No mapping mismatch found.** --- ## 2. Backend API Audit ### Endpoint: `GET /expenses/` (list_all_expenses) - **File:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:115-239) ### 🔴 ROOT CAUSE: Organization Resolution Bug At lines 130-139, the endpoint resolves the user's organization: ```python 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 ``` **Problem:** This query uses `.limit(1)` without an `ORDER BY`, which means PostgreSQL returns the **first matching row by physical storage order**. For User 28, this resolves to **organization_id = 63** (membership ID 26, ADMIN role), which has **ZERO asset_costs**. ### User 28's Memberships (in DB order): | Membership ID | Organization ID | Role | Status | Has Costs? | |---------------|----------------|-------|--------|------------| | 26 | 63 | ADMIN | active | **NO** | | 27 | 21 | OWNER | active | **NO** | | 49 | 1 | OWNER | active | **YES (5 records)** | ### The Fix Should Be: The endpoint should use the user's **`active_organization_id`** (from `identity.users` table) instead of blindly picking the first membership. The `active_organization_id` is already tracked on the user profile (see [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:70) — `active_organization_id: number | null`). --- ## 3. Database Verification ### Org 1 Asset Costs (Confirmed) | Asset ID | License Plate | Amount | Category | Status | |----------|--------------|--------|----------|--------| | bcfed768... | BMW123 | 69,850 | 4 (INSURANCE) | APPROVED | | bcfed768... | BMW123 | 69,850 | 4 (INSURANCE) | APPROVED | | c6afc130... | QWE432 | 10,000 | 1 (FUEL) | APPROVED | | c6afc130... | QWE432 | 10,000 | 1 (FUEL) | APPROVED | | c6afc130... | QWE432 | 5,000 | 1 (FUEL) | APPROVED | **Total: 5 records, all APPROVED, all in Org 1.** ### Backend Query Test When the exact same SQLAlchemy query is run with `organization_id = 1` explicitly, it returns **all 5 rows** with correct vehicle info (license_plate, brand, model). The data pipeline from DB → ORM → API response is **fully functional**. --- ## 4. Data Flow Summary ``` CostsView.vue └─ GET /expenses (no org_id param) └─ Backend resolves org via first active membership └─ Finds org_id = 63 (User 28's first membership) └─ Query: AssetCost.organization_id == 63 └─ Returns 0 rows → Empty table ``` **Expected flow:** ``` CostsView.vue └─ GET /expenses (no org_id param) └─ Backend resolves org via active_organization_id └─ Finds org_id = 1 (User 28's active org) └─ Query: AssetCost.organization_id == 1 └─ Returns 5 rows → Table populated ``` --- ## 5. Recommended Fix ### Immediate Fix (Backend) Modify [`list_all_expenses()`](backend/app/api/v1/endpoints/expenses.py:130-139) to use the user's `active_organization_id` instead of the first active membership: ```python # Use active_organization_id from user profile org_id = current_user.active_organization_id if not org_id: # Fallback: try to find any active membership 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 ``` ### Alternative Fix (Frontend) Pass `organization_id` explicitly in the API call from CostsView.vue, reading it from the auth store's `active_organization_id`. However, this is less robust as it requires every API consumer to know about org switching. ### Recommended Approach **Fix the backend** — it's the single source of truth for organization resolution and will fix the issue for ALL frontend components that call `GET /expenses/`. --- ## 6. Files Touched During Audit | File | Purpose | |------|---------| | [`frontend/src/views/costs/CostsView.vue`](frontend/src/views/costs/CostsView.vue) | Frontend costs page — calls GET /expenses | | [`frontend/src/stores/cost.ts`](frontend/src/stores/cost.ts) | Pinia store for creating expenses | | [`frontend/src/stores/expense.ts`](frontend/src/stores/expense.ts) | Pinia store for per-asset expense fetching | | [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts) | Auth store — has `active_organization_id` | | [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) | **Backend endpoint with the bug** (lines 130-139) | | `fleet_finance.asset_costs` | Database table — 5 records confirmed for Org 1 | | `fleet.organization_members` | User 28 has 3 memberships (org 63, 21, 1) | | `identity.users` | User 28 has `scope_id = None`, `scope_level = 'individual'` |