6.9 KiB
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 - Calls
api.get('/expenses', { params })at line 299 - Does NOT pass any
organization_idparameter — only sendspage,page_size, and optionallyasset_id - The frontend relies entirely on the backend to resolve the correct organization
Pinia Stores
cost.ts(frontend/src/stores/cost.ts) — Only handles POST (create expense), not fetchingexpense.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_atorcost.date(line 127)cost.category_codeorcost.cost_type(line 134)cost.category_nameorcost.cost_type(line 136)cost.amount_gross(line 143)cost.currency(line 143)cost.vehicle_nameorcost.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)
🔴 ROOT CAUSE: Organization Resolution Bug
At lines 130-139, the endpoint resolves the user's organization:
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 — 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() to use the user's active_organization_id instead of the first active membership:
# 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 costs page — calls GET /expenses |
frontend/src/stores/cost.ts |
Pinia store for creating expenses |
frontend/src/stores/expense.ts |
Pinia store for per-asset expense fetching |
frontend/src/stores/auth.ts |
Auth store — has active_organization_id |
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' |