6.5 KiB
P0 Root Cause Analysis: POST /api/v1/expenses/ 500 Internal Server Error
Date: 2026-07-26 Gitea Issue: #421 Severity: P0 (Critical — breaks entire expense creation flow) Status: Architect Analysis Complete — Awaiting Code Fix
📋 Executive Summary
The POST /api/v1/expenses/ endpoint crashes with a 500 Internal Server Error because the helper function _check_org_capability() contains a broken import path and a wrong join column name. Both defects are located in backend/app/api/v1/endpoints/expenses.py.
🔍 Root Cause Trace
Traceback (from docker compose logs sf_api)
File "/app/app/api/v1/endpoints/expenses.py", line 377, in create_expense
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
File "/app/app/api/v1/endpoints/expenses.py", line 104, in _check_org_capability
from app.models.fleet.org_role import OrgRole
ModuleNotFoundError: No module named 'app.models.fleet.org_role'
Call Flow
flowchart TD
A["POST /api/v1/expenses/"] --> B["expenses.py: create_expense() line 377"]
B --> C["_check_org_capability() line 93"]
C --> D["line 104: from app.models.fleet.org_role import OrgRole"]
D --> E["ModuleNotFoundError 💥"]
E --> F["500 Internal Server Error"]
🐛 Bug #1: Wrong Import Path (line 104)
Broken Code
# File: backend/app/api/v1/endpoints/expenses.py, line 104
from app.models.fleet.org_role import OrgRole
Why It Fails
There is no directory backend/app/models/fleet/ and no file org_role.py at that path.
Correct Import
The OrgRole model lives in backend/app/models/marketplace/organization.py:
from app.models.marketplace.organization import OrgRole
Evidence: Other Files Import Correctly
| File | Import | Status |
|---|---|---|
backend/app/api/deps.py:13 |
from app.models.marketplace.organization import OrgRole, OrganizationMember |
✅ Correct |
backend/app/api/v1/endpoints/assets.py:14 |
from app.models import ... OrgRole (via __init__.py) |
✅ Correct |
backend/app/api/v1/endpoints/organizations.py:23 |
from app.models.marketplace.organization import ... OrgRole |
✅ Correct |
backend/app/api/v1/endpoints/expenses.py:104 |
from app.models.fleet.org_role import OrgRole |
❌ BROKEN |
🐛 Bug #2: Wrong Join Column (line 108)
Broken Code
# File: backend/app/api/v1/endpoints/expenses.py, lines 106-114
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name) # ← BUG
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
Why It Fails
The OrgRole model's column is name_key (line 55 of the model), not name:
class OrgRole(Base):
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
# ... no 'name' column exists
Attempting OrgRole.name would raise an AttributeError after the import is fixed, causing a second crash.
Correct Join
.join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
Evidence: assets.py Has the Correct Pattern
The assets.py endpoint uses the identical capability-check pattern correctly:
# assets.py line 80-83 — CORRECT pattern
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name, # ← Uses name_key, not .name
OrgRole.is_active == True
).limit(1)
🧩 Impact Analysis
What's Broken
- Entire
POST /api/v1/expenses/endpoint — all expense creation requests fail with 500 - The crash occurs at the RBAC Phase 2 capability check (JSONB-based
can_approve_expense) - This blocks the P0 Smart Expense Workflow (odometer normalization, VAT calculation, gamification hooks, AssetEvent linking)
What Still Works
GET /api/v1/expenses/— list all expenses (does not call_check_org_capability)GET /api/v1/expenses/{asset_id}— list asset expenses (does not call_check_org_capability)PUT /api/v1/expenses/{expense_id}— update expense (does not call_check_org_capability)
Why This Was Not Caught
- The function uses a lazy import inside the function body — the import error only surfaces at runtime, not at module load time.
- No e2e test exercises the
POST /expenses/path through this function. - The
backend/app/tests/e2e/test_expense_flow.pytest targetsPOST /api/v1/expenses/add, which is a different, legacy endpoint — not the currentPOST /api/v1/expenses/.
🔧 Fix Plan (2 Lines, 1 File)
File: backend/app/api/v1/endpoints/expenses.py
Change 1 — Line 104: Replace the broken import:
- from app.models.fleet.org_role import OrgRole
+ from app.models.marketplace.organization import OrgRole
Change 2 — Line 108: Fix the join column:
- .join(OrganizationMember, OrganizationMember.role == OrgRole.name)
+ .join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
Optional: Hoist to top-level imports
Since OrganizationMember is already imported at the top of the file (line 46), move OrgRole there too:
# line 46 area — add OrgRole
from app.models.marketplace.organization import OrganizationMember, OrgRole
Then remove the lazy import body from _check_org_capability().
🧪 Verification
- Unit test:
POST /api/v1/expenses/with valid payload → 201 Created - Capability check: User with
can_approve_expense: true→ expenseAPPROVED - Capability check: User without
can_approve_expense→PENDING_APPROVAL - No ModuleNotFoundError at any point
📊 Summary
| Item | Detail |
|---|---|
| Endpoint | POST /api/v1/expenses/ |
| Error | ModuleNotFoundError: No module named 'app.models.fleet.org_role' |
| Crash File | backend/app/api/v1/endpoints/expenses.py |
| Crash Line | 104 |
| Root Cause 1 | Wrong import: app.models.fleet.org_role → app.models.marketplace.organization |
| Root Cause 2 | Wrong column: OrgRole.name → OrgRole.name_key |
| Gitea Issue | #421 |
| Fix Complexity | Trivial (2 lines) |
| Risk | Minimal |
Analysis by: Service Finder Rendszer-Architect — 2026-07-26