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

159 lines
6.9 KiB
Markdown

# 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'` |