9.3 KiB
P0 READ & COMPARE — Vendor and Expense Architecture Audit Report
Date: 2026-06-23
Author: Fast Coder (Core Developer)
Scope: Vendor creation flow + Expense creation flow
Status: COMPLETE — No code modified, pure audit
1. AUDIT: Vendor Creation Flow
1.1. Endpoint: POST /providers/quick-add
File: backend/app/api/v1/endpoints/providers.py:241
This is the primary vendor creation endpoint used by the frontend's CostEntryWizard when a user adds a new service provider (vendor) via crowdsourcing.
Actual Code Flow (in backend/app/services/provider_service.py:468):
| Step | What happens | Line |
|---|---|---|
| 1 | Organization created with org_type=OrgType.service_provider |
:526 |
| 2 | owner_id=None — explicitly set to None |
:541 |
| 3 | status="pending_verification", is_verified=False |
:527-528 |
| 4 | ServiceProfile created with status="ghost" |
:561-570 |
| 5 | Branch created with address data | :618-630 |
| 6 | OrganizationMember created with role=OrgUserRole.ADMIN |
:639-646 |
1.2. Comparison: Actual vs Expected
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|---|---|---|---|
org_type |
SERVICE_PROVIDER |
OrgType.service_provider ✅ |
CORRECT |
owner_id |
NULL (ideally) |
owner_id=None ✅ |
CORRECT |
| Creator in members? | NO — creator should NOT be a member | BUG: Creator IS added as OrganizationMember with role=ADMIN ❌ |
DEVIATION |
1.3. The Member Problem
In backend/app/services/provider_service.py:639-646:
member = OrganizationMember(
organization_id=org.id,
user_id=user_id,
role=OrgUserRole.ADMIN, # <-- ADMIN, not OWNER
is_permanent=True,
is_verified=True,
)
The code comment at line 635 says:
"A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER). Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL). Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég nem jelenik meg a 'Cégeim' garázsválasztóban."
However, the expected business logic says:
"A létrehozó felhasználó NEM kerülhet be a tagok (organization_members) közé!"
This is a deliberate design decision — the current code adds the creator as ADMIN. The expected logic says NO membership at all. This needs Architect clarification.
1.4. Secondary Endpoint: POST /organizations/onboard
File: backend/app/api/v1/endpoints/organizations.py:38
This endpoint is for business onboarding (not vendor creation). It creates:
org_type=OrgType.business(line 84)owner_idis NOT set (implicitly NULL)- Creator is added as
OrganizationMemberwithrole="OWNER"(line 116-121)
This is correct for business onboarding — the creator SHOULD be the owner.
2. AUDIT: Expense Creation Flow
2.1. Endpoint: POST /expenses/
File: backend/app/api/v1/endpoints/expenses.py:345
2.2. How organization_id is Resolved
Actual Code (lines 398-429):
# CRITICAL FIX: organization_id MUST be the user's active organization context,
# NOT blindly taken from the payload.
#
# Resolution strategy:
# 1. First, try to resolve the user's active organization from their membership
# 2. Fallback to asset.current_organization_id or asset.owner_org_id
# 3. Ignore expense.organization_id from payload to prevent vendor-org pollution
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
org_member = org_result.scalar_one_or_none()
if org_member:
organization_id = org_member.organization_id # Priority 1
elif asset.current_organization_id:
organization_id = asset.current_organization_id # Priority 2
elif asset.owner_org_id:
organization_id = asset.owner_org_id # Priority 3
else:
# B2C fallback: any membership (even unverified)
...
2.3. How vendor_organization_id is Set
Actual Code (line 495):
vendor_organization_id=expense.vendor_organization_id,
The payload's vendor_organization_id is passed directly to the model. This is the vendor's organization ID.
2.4. Comparison: Actual vs Expected
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|---|---|---|---|
organization_id source |
Asset's garage (asset.current_organization_id) |
User's active org membership (Priority 1), then asset.current_organization_id (Priority 2) |
PARTIAL DEVIATION |
Payload organization_id |
Should be IGNORED | IS ignored — backend resolves from user context ✅ | CORRECT |
vendor_organization_id |
The invoice issuer (vendor) | Set from expense.vendor_organization_id ✅ |
CORRECT |
2.5. The organization_id Deviation Explained
The expected logic says:
"A költség organization_id mezőjének (a költség gazdájának) a jármű saját garázsát (asset.current_organization_id) kell tükröznie."
The actual code uses Priority 1: user's active org membership. This means if a user belongs to Organization A (their fleet manager org) but the asset is currently assigned to Organization B (a different garage), the expense will be saved under Organization A, not Organization B.
This is a potential bug in multi-org scenarios where a user manages vehicles across multiple organizations. The asset's current_organization_id should be the authoritative source.
2.6. Frontend Payload Analysis
File: frontend/src/components/cost/CostEntryWizard.vue:690-723
The frontend:
- Does NOT send
vendor_organization_idin the payload (line 690-714) - Sends
organization_id= user'sactive_organization_id(line 720-723) - Stores vendor info inside
data.vendor_idanddata.vendor_name(JSONB) (line 707-710)
File: frontend/src/stores/cost.ts:64-73
The cost store also injects organization_id from authStore.user?.active_organization_id.
This means: The frontend never sends vendor_organization_id to the backend! The vendor info is stored in the JSONB data field, but the dedicated vendor_organization_id column on AssetCost remains NULL.
3. Summary of Findings
🟢 CORRECT (Matches Expected Logic)
| # | Finding | Location |
|---|---|---|
| 1 | POST /providers/quick-add creates org with org_type=service_provider |
provider_service.py:526 |
| 2 | owner_id=None for crowdsourced providers |
provider_service.py:541 |
| 3 | Backend POST /expenses/ ignores payload organization_id |
expenses.py:398-429 |
| 4 | vendor_organization_id is correctly mapped from schema |
expenses.py:495 |
🟡 DEVIATION (Needs Architect Decision)
| # | Finding | Location | Impact |
|---|---|---|---|
| 1 | Vendor creator IS added as OrganizationMember (ADMIN) — expected: NO membership | provider_service.py:639-646 |
Vendor appears in user's org list? |
| 2 | organization_id resolved from user's active org (Priority 1) — expected: asset.current_organization_id |
expenses.py:406-414 |
Wrong org in multi-org scenarios |
| 3 | Frontend never sends vendor_organization_id — vendor info only in JSONB data |
CostEntryWizard.vue:690-714 |
vendor_organization_id column always NULL |
🔴 CRITICAL GAP
| # | Finding | Location |
|---|---|---|
| 1 | Frontend CostEntryWizard does NOT populate vendor_organization_id in the POST payload |
CostEntryWizard.vue:707 — vendor_id stored only in data.vendor_id |
| 2 | The cost.ts store also doesn't map vendor_organization_id |
cost.ts:53-73 |
4. Recommended Actions
-
Architect Decision Needed: Should
quick_add_provideradd the creator asOrganizationMember(current behavior) or NOT (expected logic)? The current ADMIN role prevents the org from appearing in "My Companies" garage selector, but the user still has a membership record. -
Fix
organization_idResolution: Change Priority 1 from "user's active org membership" toasset.current_organization_idto match the expected business logic exactly. -
Fix Frontend: Add
vendor_organization_idto the POST/expenses/payload when a vendor is selected from the dropdown, so the dedicated column is populated instead of only storing it in JSONBdata.
End of Audit Report — No code was modified during this analysis.