159 lines
6.9 KiB
Markdown
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'` |
|