Files
service-finder/docs/cost_pipeline_audit.md
2026-06-23 21:11:21 +00:00

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_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

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)

🔴 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.tsactive_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

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.

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'