Files
service-finder/docs/p0_vendor_expense_architecture_audit_report.md
2026-06-24 11:29:45 +00:00

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_id is NOT set (implicitly NULL)
  • Creator is added as OrganizationMember with role="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:

  1. Does NOT send vendor_organization_id in the payload (line 690-714)
  2. Sends organization_id = user's active_organization_id (line 720-723)
  3. Stores vendor info inside data.vendor_id and data.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

  1. Architect Decision Needed: Should quick_add_provider add the creator as OrganizationMember (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.

  2. Fix organization_id Resolution: Change Priority 1 from "user's active org membership" to asset.current_organization_id to match the expected business logic exactly.

  3. Fix Frontend: Add vendor_organization_id to the POST /expenses/ payload when a vendor is selected from the dropdown, so the dedicated column is populated instead of only storing it in JSONB data.


End of Audit Report — No code was modified during this analysis.