diff --git a/.roo/history.md b/.roo/history.md
index e7650c8..44da577 100644
--- a/.roo/history.md
+++ b/.roo/history.md
@@ -446,3 +446,508 @@ Két P0 szintű probléma javítása: (1) "Univerzális Szolgáltatások" blokkb
- Seed script: ✅ PASS (134 rekord, ebből 12 univerzális: 4 Level 1 + 8 Level 2)
- Autocomplete API `q=Benzinkút`: ✅ PASS (1 találat: Benzinkút, level=2)
- Autocomplete API `q=Étterem`: ✅ PASS (3 találat: Étterem, Gyorsétterem / Büfé, Étterem / Büfé)
+
+## 2026-06-18 - P0 Critical: Financial Math Reversal (Gross-First Accounting)
+
+### 🎯 Cél
+A Phase 2 Financial Architecture felülvizsgálata során a Vezető Tervező (Architect) jelezte, hogy a pénzügyi logika fordítva van: az EU-s számvitelben a BRUTTÓ (Gross) az elsődleges forrásigazság (Source of Truth), nem a Nettó. A teljes matematika megfordítása: `_calculate_gross_from_net()` → `_calculate_net_from_gross()`, `amount_gross` kötelező, `amount_net` opcionális.
+
+### 🔧 Változtatások
+
+**1. SCHEMA - [`asset_cost.py`](backend/app/schemas/asset_cost.py:9):**
+- `amount_gross` áthelyezve kötelező mezővé (`Field(..., description="Bruttó összeg — KÖTELEZŐ, elsődleges forrás")`)
+- `amount_net` opcionális (`Optional[Decimal] = Field(None, ...)`)
+- `@model_validator(mode='after')` hozzáadva: ha `amount_net=None` és `vat_rate=None`, akkor `amount_net = amount_gross` és `vat_rate = 0`
+
+**2. SCHEMA - [`asset_event.py`](backend/app/schemas/asset_event.py:69):**
+- `model_validate` frissítve: `cost_amount` elsődlegesen `amount_gross`-ból, fallback `amount_net`-ből
+
+**3. SCHEMA - [`asset.py`](backend/app/schemas/asset.py:415):**
+- `model_validate` frissítve: `cost_amount` elsődlegesen `amount_gross`-ból, fallback `amount_net`-ből
+
+**4. ENDPOINT - [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:49):**
+- `_calculate_gross_from_net()` → `_calculate_net_from_gross(amount_gross, vat_rate)` átnevezve
+- Képlet megfordítva: `net = gross / (1 + vat_rate/100)`
+- VAT handling logika átírva: ha `vat_rate` adott és `amount_net` nincs, net kiszámítása gross-ból
+- Ha `amount_net` és `vat_rate` is adott, `vat_rate` felülírva a back-számított értékkel
+- Ha egyik sincs, `amount_net = amount_gross`, `vat_rate = 0`
+
+**5. ENDPOINT - [`assets.py`](backend/app/api/v1/endpoints/assets.py):**
+- `create_maintenance_record`: `cost` mező gross-ként kezelve, `amount_gross` és `amount_net` is a cost értékkel töltve
+- `create_asset_event`: `cost_amount` gross-ként kezelve
+- `list_asset_costs`: response-ban `amount_gross` az `amount_net` előtt
+
+### ✅ Verifikáció
+- Python szintaxis ellenőrzés: ✅ 5/5 fájl OK
+- Sync engine: ✅ 1087/1087 OK, 0 fix, 0 extra
+- Unit math tesztek: ✅ 5/5 passed (Gross=12700→Net=10000 27% VAT, Gross=10500→Net=10000 5% VAT, stb.)
+- E2E API tesztek: ✅ 3/3 passed (Gross only, Gross+27%, Gross+5%)
+- A teljes Gross-first matematika helyesen működik: a bruttó összeg a Source of Truth, a nettó és ÁFA visszaszámolva
+
+## 2026-06-18 - P0 Frontend Cost Payload Audit & Fix (Gross-First Compliance)
+
+### 🎯 Cél
+A backend már szigorúan Gross-first rendszer a pénzügyi adatokra, de a frontend űrlapok még `amount_net`-et küldtek a payloadban, ami `amount_gross: null`-t eredményezett az adatbázisban. Teljes audit és javítás.
+
+### 🔧 Változtatások
+
+**1. FRONTEND - [`cost.ts`](frontend/src/stores/cost.ts:8):**
+- `AddExpensePayload` interface: `amount_net` → `amount_gross` (elsődleges mező)
+- `ExpenseResponse` interface: `amount_net` → `amount_gross`
+- `addExpense()` body builder: `amount_net: payload.amount_net` → `amount_gross: payload.amount_gross`
+
+**2. FRONTEND - [`ComplexExpenseModal.vue`](frontend/src/components/dashboard/ComplexExpenseModal.vue:268):**
+- Payload `amount_net: form.amount` → `amount_gross: form.amount`
+
+**3. FRONTEND - [`SimpleFuelModal.vue`](frontend/src/components/dashboard/SimpleFuelModal.vue:193):**
+- Payload `amount_net: form.amount` → `amount_gross: form.amount`
+
+**4. BACKEND - [`asset_cost.py`](backend/app/schemas/asset_cost.py:27):**
+- `@model_validator(mode='before')` safety net hozzáadva: ha a bejövő JSON `amount_net`-et tartalmaz, de `amount_gross` hiányzik, automatikusan átmásolja az `amount_net` értékét az `amount_gross`-ba. Ez véd a régebbi cache-elt böngészők ellen.
+
+### ✅ Verifikáció
+- Vite build: ✅ Sikeres (216 module transformed, 0 error)
+- Backend schema import: ✅ Sikeres
+- Safety net tesztek: ✅ 3/3 passed
+ - `amount_net` only → `amount_gross` auto-copy: ✅
+ - Both fields present → no interference: ✅
+ - `amount_gross` only → no change: ✅
+
+### 🔍 Utólagos Adatjavítás (Data Fix)
+A felhasználó jelentése alapján az UOK795 (Honda CB1000R) járműnél az utolsó 2 rögzített költség 0 Ft-ot mutatott. Vizsgálat után kiderült:
+
+**Gyökér ok:** A P0 audit előtt rögzített rekordoknál a frontend `amount_net`-et küldött, de a backend `amount_gross`-t 0-ra állította (a `validate_gross_first` `mode='after'` validátor `None` → `Decimal("0")`-ra állította a hiányzó gross mezőt).
+
+**Érintett rekordok (országosan 2 db):**
+1. `3468e81a` - UOK795 (Honda CB1000R) - 12 000 Ft tankolás → `amount_gross` javítva 0→12000
+2. `ea0395ea` - QWE123 (APRILIA af1) - 4 000 Ft → `amount_gross` javítva 0→4000
+
+**Javítás:** `UPDATE vehicle.asset_costs SET amount_gross = amount_net, vat_rate = 0.00 WHERE amount_gross=0 AND amount_net>0`
+
+## 2026-06-18 - Jogosultságkezelés Audit (Permission System Audit)
+
+### 🎯 Cél
+Teljes körű audit a jogosultsági szintek kezeléséről, kiterjedve az adatbázis táblákra, a backend kódra és a frontend rétegre.
+
+### 🔍 Vizsgált Területek
+1. **Adatbázis (6 tábla):** identity.users, fleet.org_roles, fleet.organization_members, system.system_parameters, system.pending_actions, system.subscription_tiers
+2. **Backend:** UserRole enum, DEFAULT_RANK_MAP, RBAC osztály, get_current_admin, scoped RBAC, Dual Control (SecurityService), JWT token
+3. **Frontend:** authStore.ts (UserRole típus), Router Guard
+
+### 🔴 Feltárt Kritikus Problémák
+- **P1:** fleet.org_roles tábla ÜRES - dinamikus szerepkör rendszer nem seedelt
+- **P2:** DEFAULT_RANK_MAP inkonzisztens a UserRole enum-mal (6 rang soha nem kerül kiosztásra)
+- **P3:** Enum duplikáció az adatbázisban (kis/nagybetű variánsok)
+- **P4:** Frontend/backend UserRole eltérés (5 vs 10 role)
+- **P5:** parameter_scope enum 3 különböző sémában definiálva
+
+### 📄 Dokumentáció
+- [`docs/permission_system_audit_2026-06-18.md`](docs/permission_system_audit_2026-06-18.md) - Teljes audit jelentés Mermaid diagrammal
+
+---
+
+## 2026-06-18 - RBAC Phase 1: Foundation & Seeding (P0 Critical)
+
+### 🎯 Cél
+RBAC Phase 1 migráció Fine-Grained, Capability-Driven architektúrára. System-level (UserRole) és Organization-level (OrgUserRole) szerepkörök szétválasztása, SYSTEM_CAPABILITIES_MATRIX létrehozása, fleet.org_roles tábla seedelése.
+
+### 🔧 Változtatások
+
+**1. [`UserRole`](backend/app/models/identity/identity.py:24) enum tisztítás:**
+- 10 régi érték helyett 6 system-level role: `SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER`
+- Eltávolítva: `region_admin, country_admin, sales_agent, service_owner, fleet_manager, driver`
+- Hozzáadva: `SALES_REP, SERVICE_MGR`
+- Értékek UPPERCASE-re változtatva
+
+**2. [`SYSTEM_CAPABILITIES_MATRIX`](backend/app/core/capabilities.py:1) létrehozása:**
+- Új fájl: `backend/app/core/capabilities.py`
+- `Capability` osztály konstansokkal (~26 capability key)
+- `SYSTEM_CAPABILITIES_MATRIX` dict minden role-hoz boolean capability mátrix
+- Segédfüggvények: `get_capabilities_for_role()`, `role_has_capability()`
+
+**3. [`OrgUserRole`](backend/app/models/marketplace/organization.py:27) enum frissítés:**
+- Új értékek: `OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER`
+- Eltávolítva: `MANAGER, MEMBER, AGENT`
+
+**4. [`DEFAULT_RANK_MAP`](backend/app/core/security.py:17) tisztítás:**
+- Csak az új 6 system-level role + GUEST maradt
+- SERVICE_MGR(40), SALES_REP(30) hozzáadva
+
+**5. Függő kódok frissítése:**
+- [`deps.py`](backend/app/api/deps.py:106) - `check_resource_access()` és `get_current_admin()` frissítve
+- [`rbac.py`](backend/app/core/rbac.py:7) - `UserRole.SUPERADMIN` referencia javítva
+- [`billing_engine.py`](backend/app/services/billing_engine.py:37) - `RBAC_DISCOUNTS` tisztítva
+
+**6. PostgreSQL enum migráció:**
+- [`migrate_rbac_phase1_enums.py`](backend/app/scripts/migrate_rbac_phase1_enums.py) - CREATE TYPE → ALTER COLUMN → DROP TYPE minta
+- Régi értékek mapelése: `superadmin→SUPERADMIN, admin→ADMIN, moderator→MODERATOR, sales_agent→SALES_REP, user/service_owner/fleet_manager/driver→USER`
+- ✅ Sikeres: `identity.userrole` enum 6 értékkel, meglévő user role-ok helyesen átkonvertálva
+
+**7. [`seed_org_roles.py`](backend/app/scripts/seed_org_roles.py) - fleet.org_roles seedelés:**
+- 5 org role: OWNER(100), ADMIN(80), ACCOUNTANT(60), DRIVER(40), VIEWER(20)
+- Minden role JSON permissions objektummal
+- ✅ Sikeres: 5 rekord beszúrva
+
+### ✅ Verifikáció
+- Schema sync: **1087 items OK, 0 fixed, 0 shadow data**
+- `identity.userrole` enum: `SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER`
+- `fleet.org_roles`: 5 rekord (OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER)
+- Meglévő user role-ok: `USER, SUPERADMIN, ADMIN` - helyesen konvertálva
+
+## 2026-06-18 - P0 EMERGENCY: Superadmin User ID 1 Recreated from Scratch
+
+### 🎯 Cél
+A hard-deletelt User ID 1 rekord fizikai újraélesztése: SUPERADMIN joggal, a meglévő Person ID 1-hez kapcsolva, és a Profibot Test Fleet (Organization ID 15) garázshoz OWNER szerepkörrel hozzárendelve.
+
+### 🔧 Változtatások
+
+**1. Jelszó hash generálás:**
+- [`security.py`](backend/app/core/security.py:14): `get_password_hash('Superadmin123!')` használatával bcrypt hash generálva
+- Hash: `$2b$12$ozXD07/qiFl4eKg.RbadDeGcnMukY3tOpBPEgOza7VsqcbuUW7iq2`
+- Verifikáció: `verify_password('Superadmin123!', hash)` → `True`
+
+**2. Email konfliktus feloldása:**
+- User ID 29 (korábbi `superadmin@profibot.hu`) átnevezve → `superadmin_old@profibot.hu`
+
+**3. User ID 1 létrehozása (`identity.users`):**
+- `INSERT INTO identity.users (id=1, email='superadmin@profibot.hu', role='SUPERADMIN', person_id=1, ...)`
+- Kapcsolat a Person ID 1-hez (Super Admin személy)
+
+**4. Garázs hozzárendelés (`fleet.organization_members`):**
+- `INSERT INTO fleet.organization_members (organization_id=15, user_id=1, person_id=1, role='OWNER', ...)`
+- Organization: "Profibot Test Fleet" (ID 15)
+
+### ✅ Verifikáció
+- `identity.users WHERE id=1`: `email=superadmin@profibot.hu, role=SUPERADMIN, person_id=1, is_active=true`
+- `fleet.organization_members WHERE user_id=1`: `org_id=15 (Profibot Test Fleet), role=OWNER, status=active`
+- `identity.persons WHERE id=1`: `last_name=Admin, first_name=Super` (meglévő személy)
+- Jelszó hash verifikáció: `True`
+- Belépési adatok: `superadmin@profibot.hu` / `Superadmin123!`
+
+## 2026-06-18: P0 CRITICAL DEBUG - 500 Internal Server Error on POST /api/v1/auth/login (UserRole UPPERCASE migration fix)
+
+### 🎯 Cél
+A Phase 1 RBAC Enum UPPERCASE migráció után a `/api/v1/auth/login` endpoint 500-as hibát dobott `LookupError: 'ADMIN' is not among the defined enum values` üzenettel. A régi konténer cache-elt SQLAlchemy metaadatokat használt, és a `billing_engine.py`-ben lévő `UserRole.user` (kisbetűs) hivatkozás miatt az új konténer sem indult el.
+
+### 🔧 Változtatások (25 javítás 14 fájlban)
+
+**Kritikus javítások (startup-blokkoló):**
+- [`backend/app/services/billing_engine.py:70`](backend/app/services/billing_engine.py:70) — `UserRole.user` → `UserRole.USER` (2 helyen)
+- [`backend/app/services/social_auth_service.py:33`](backend/app/services/social_auth_service.py:33) — `UserRole.user` → `UserRole.USER`
+- [`backend/app/services/search_service.py:41`](backend/app/services/search_service.py:41) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
+- [`backend/app/services/auth_service.py:110`](backend/app/services/auth_service.py:110) — `UserRole.user` → `UserRole.USER`
+- [`backend/app/api/v1/endpoints/system_parameters.py:125`](backend/app/api/v1/endpoints/system_parameters.py:125) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
+- [`backend/app/schemas/admin.py:23`](backend/app/schemas/admin.py:23) — `UserRole.admin, UserRole.superadmin` → `UserRole.ADMIN, UserRole.SUPERADMIN`
+- [`backend/app/api/v1/endpoints/finance_admin.py:27`](backend/app/api/v1/endpoints/finance_admin.py:27) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
+- [`backend/app/api/v1/endpoints/admin.py:195`](backend/app/api/v1/endpoints/admin.py:195) — `UserRole.superadmin` → `UserRole.SUPERADMIN` (2 helyen)
+- [`backend/app/api/v1/endpoints/security.py:39`](backend/app/api/v1/endpoints/security.py:39) — `UserRole.admin, UserRole.superadmin` → `UserRole.ADMIN, UserRole.SUPERADMIN` (5 helyen)
+- [`backend/app/core/security.py:56`](backend/app/core/security.py:56) — `DEFAULT_RANK_MAP` már helyes (UPPERCASE kulcsok)
+- [`backend/app/tests/e2e/test_admin_security.py:21`](backend/app/tests/e2e/test_admin_security.py:21) — `UserRole.user` → `UserRole.USER`, `UserRole.admin` → `UserRole.ADMIN`
+- [`backend/app/scripts/seed_integration_data.py:158`](backend/app/scripts/seed_integration_data.py:158) — `UserRole.admin` → `UserRole.ADMIN`, `UserRole.superadmin` → `UserRole.SUPERADMIN`
+
+### ✅ Verifikáció
+- `POST /api/v1/auth/login` with `admin@profibot.hu` / `Admin123!` → **200 OK**
+- JWT token payload: `{"sub": "2", "role": "ADMIN", "rank": 90, "scope_level": "system", "scope_id": "2"}`
+- Konténer indítás: sikeres (nem omlik össze `AttributeError`-rel)
+- 3 db seed script nem javítható (EACCES jogosultsági hiba) — nem kritikus
+
+---
+
+## 2026-06-18 - RBAC Phase 2: Capability Dependencies (Kapuőrök)
+
+### 🎯 Cél
+P0 kritikus feladat: JSONB-alapú capability dependency-k bevezetése a hardcoded szerepkör-ellenőrzések helyett. Két új FastAPI dependency: `RequireSystemCapability` (system-level) és `RequireOrgCapability` (org-level).
+
+### 🔧 Változtatások
+
+**1. MODELL - [`organization.py`](backend/app/models/marketplace/organization.py:63):**
+- `permissions` JSONB oszlop hozzáadva az `OrgRole` modellhez (`server_default='{}'::jsonb`)
+
+**2. DEPENDENCY - [`deps.py`](backend/app/api/deps.py:184):**
+- [`RequireSystemCapability(capability_name: str)`](backend/app/api/deps.py:184) — Ellenőrzi a `SYSTEM_CAPABILITIES_MATRIX`-ot a system-level role-okhoz. SUPERADMIN automatikusan átmegy.
+- [`RequireOrgCapability(capability_name: str)`](backend/app/api/deps.py:221) — Lekérdezi a `fleet.org_roles.permissions` JSONB oszlopot. Fallback: `OrganizationMember.permissions`. SUPERADMIN bypass.
+
+**3. EXPENSES - [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:46):**
+- `AUTO_APPROVED_ROLES = {"OWNER", "ADMIN"}` eltávolítva
+- `_check_org_capability()` helper hozzáadva — JSONB-ből olvassa a `can_approve_expense` flag-et
+- Költség státusz meghatározása: `can_approve` → APPROVED, fuel (category_id=1) → APPROVED, egyéb → PENDING_APPROVAL
+
+**4. ASSETS - [`assets.py`](backend/app/api/v1/endpoints/assets.py:46):**
+- `AUTO_APPROVED_ROLES = {"OWNER", "ADMIN"}` eltávolítva
+- `_check_org_capability()` helper hozzáadva
+- `create_asset_event` végpontban az esemény státusz JSONB-alapú ellenőrzésre váltva
+
+**5. SEED - [`seed_org_roles.py`](backend/app/scripts/seed_org_roles.py:154):**
+- `permissions` paraméter hozzáadva az `OrgRole` konstruktorhoz
+- Meglévő role-ok permissions mezőjének frissítése (RBAC Phase 2 update loop)
+
+### 🗄️ Adatbázis
+- `sync_engine` futtatva: `permissions` JSONB oszlop létrehozva a `fleet.org_roles` táblában
+- 5 org role permissions adata feltöltve: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER
+
+### ✅ Verifikáció
+- Backend indítás: sikeres (nincs import error)
+- `POST /api/v1/expenses/` ADMIN userrel → **201 CREATED**, `expense_status: "APPROVED"` (mert ADMIN rendelkezik `can_approve_expense: True` képességgel)
+- `fleet.org_roles` JSONB permissions: mind az 5 role helyesen tárolva
+
+## 2026-06-18: RBAC Phase 3 - Frontend Reactivity & UI Capabilities Integration
+
+### 🎯 Cél
+P0 kritikus feladat: Frontend reaktivitás javítása (F5 Bug) és UI képesség-alapú megjelenítés bevezetése.
+
+### 🔧 Változtatások
+
+**Task 1 - F5 Bug javítás (Direct Mutation / Explicit Fetch pattern):**
+- [`CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue:110): `cost-saved` event emit hozzáadva, ami a költség mentése után értesíti a szülőt
+- [`DashboardView.vue`](frontend/src/views/DashboardView.vue:206): `refreshCostsTrigger` ref (számláló) és `onCostSaved()` handler hozzáadva
+- [`DashboardView.vue`](frontend/src/views/DashboardView.vue:130): `:refresh-trigger="refreshCostsTrigger"` prop átadva a VehicleDetailModal-nak
+- [`VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue:778): `refreshTrigger` prop és `watch` hozzáadva a költségek újratöltéséhez
+
+**Task 2 - Backend /users/me exposure:**
+- [`schemas/user.py`](backend/app/schemas/user.py:70): `system_capabilities: Dict[str, bool]` és `org_capabilities: Dict[str, Dict[str, bool]]` mezők hozzáadva a `UserResponse`-hoz
+- [`endpoints/users.py`](backend/app/api/v1/endpoints/users.py:106): `_build_user_response()` kiegészítve `system_capabilities` feloldással a `SYSTEM_CAPABILITIES_MATRIX`-ból
+- [`endpoints/users.py`](backend/app/api/v1/endpoints/users.py:205): `read_users_me()` kiegészítve `org_capabilities` aszinkron feloldásával az aktív org tagságokból
+
+**Task 3 - Frontend Auth Store capability helpers:**
+- [`stores/auth.ts`](frontend/src/stores/auth.ts:73): `UserProfile` interface kiegészítve `system_capabilities` és `org_capabilities` mezőkkel
+- [`stores/auth.ts`](frontend/src/stores/auth.ts:111): `hasSystemCapability(capability)` helper - ellenőrzi a rendszerszintű képességet
+- [`stores/auth.ts`](frontend/src/stores/auth.ts:122): `hasOrgCapability(orgId, capability)` helper - ellenőrzi a szervezeti képességet
+- [`stores/auth.ts`](frontend/src/stores/auth.ts:133): `getOrgCapabilities(orgId)` helper - visszaadja az összes szervezeti képességet
+
+**Task 4 - UI Integration Pilot:**
+- [`VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue:741): "Szerkesztés" gomb `v-if`-je kiegészítve `authStore.hasOrgCapability(orgId, 'can_approve_expense')` ellenőrzéssel
+
+### ✅ Verifikáció
+- Backend Python szintaxis: `user.py` és `users.py` hibátlanul lefordul
+- Frontend TypeScript: `vue-tsc --noEmit` sikeres (csak pre-existing `.vue.js` file hibák)
+- Event propagation chain teljes: Cost modal → CostsActionsCard → DashboardView → VehicleDetailModal
+
+## 2026-06-18 - P0 Legacy Garage Migration (RBAC Phase 3)
+
+### 🎯 Cél
+Adatbázis backfill szkript elkészítése és futtatása, amely a legacy `person_id`-alapú ownership struktúrát áttelepíti az új `organization_id`/`organization_members` RBAC struktúrába. Három feladat: (A) hiányzó személyes garázsok létrehozása, (B) userek bindolása a garázsukhoz OWNER role-lal, (C) gazdátlan járművek megmentése.
+
+### 🔧 Változtatások
+
+**1. BACKEND - [`migrate_legacy_garages.py`](backend/app/scripts/migrate_legacy_garages.py:1):**
+- **Task A:** Lekérdezi a `identity.users` táblában lévő usereket, akiknek nincs `INDIVIDUAL` típusú személyes garázsuk (`fleet.organizations`). Létrehozza a garázst minden szükséges oszloppal.
+- **Task B:** Minden userhez hozzárendeli a személyes garázsát `organization_members` táblában `OWNER` szerepkörrel. Frissíti a `scope_id` mezőt, ha az NULL.
+- **Task C:** Megkeresi a gazdátlan járműveket (`vehicle.assets` táblában ahol `owner_person_id` kitöltött, de `current_organization_id` vagy `owner_org_id` NULL), és átvezeti őket a tulajdonos személyes garázsába.
+- **Technikai részletek:** `asyncpg`-t használ közvetlen adatbázis kapcsolattal. Dinamikus schema discovery (`information_schema.columns`) a NOT NULL constraint-ek kezelésére.
+
+**2. BACKEND - [`verify_migration.py`](backend/app/scripts/verify_migration.py:1):**
+- Verifikációs szkript, amely a migráció után lekérdezi a kulcs metrikákat.
+
+### ✅ Eredmény
+- **6 új személyes garázs** létrehozva (Task A)
+- **16 member** hozzáadva `organization_members`-hez (Task B)
+- **10 scope_id** frissítve (Task B)
+- **1 gazdátlan jármű** megmentve (Task C)
+- **0 user** maradt személyes garázs nélkül
+- **0 user** NULL scope_id-vel
+- **0 orphaned jármű** maradt
+- **17** total INDIVIDUAL garázs
+- **22** organization member OWNER role-lal
+- **38/41** jármű organization-höz rendelve
+
+## 2026-06-18 - P0 Feature: Subscription & Package Assignment Bridge
+
+### 🎯 Cél
+Subscription Tier hozzárendelés szervezetekhez (fleet.organizations) a `subscription_tier_id` FK oszlopon keresztül. Teljes admin API + frontend integráció a csomagok kezeléséhez.
+
+### 🔧 Változtatások
+
+**1. ADATBÁZIS - [`Organization`](backend/app/models/marketplace/organization.py:146):**
+- `subscription_tier_id` FK oszlop hozzáadva (nullable, FK → `system.subscription_tiers.id`)
+- Sync engine sikeresen lefuttatva → oszlop létrejött
+
+**2. CAPABILITIES - [`capabilities.py`](backend/app/core/capabilities.py:61):**
+- `CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"` új capability
+- SUPERADMIN és ADMIN mátrixba bevéve
+
+**3. API ENDPOINT - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:730):**
+- `PUT /{org_id}/subscription` végpont `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` RBAC-cal
+- `SubscriptionAssignIn` Pydantic schema a body-hoz
+
+**4. BILLING ENGINE - [`billing_engine.py`](backend/app/services/billing_engine.py:805):**
+- `upgrade_org_subscription()` függvény: beállítja a `subscription_tier_id`-t, frissíti a `subscription_plan` és `base_asset_limit` mezőket, létrehozza az `OrganizationSubscription` audit rekordot
+
+**5. QUOTA ENGINE - [`evidence.py`](backend/app/api/v1/endpoints/evidence.py:17):**
+- scan-registration végpont most az org `subscription_tier_id` → `SubscriptionTier.rules.allowances.max_vehicles` értéket olvassa
+- Fallback: `org.base_asset_limit` ha nincs tier hozzárendelve
+
+**6. SCHEMA - [`organization.py`](backend/app/schemas/organization.py:66):**
+- `subscription_tier_id: Optional[int]` hozzáadva `OrganizationUpdate` és `OrganizationResponse` modellekhez
+
+**7. FRONTEND TYPES - [`organization.ts`](frontend/src/types/organization.ts:5):**
+- `subscription_tier_id?: number | null` hozzáadva `OrganizationItem` interfészhez
+
+**8. FRONTEND UI - [`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:1):**
+- Subscription tier dropdown (select + assign gomb)
+- `availableTiers` betöltése `GET /admin/packages/`-ből
+- `assignSubscription()` hívás `PUT /organizations/{id}/subscription`-ra
+
+### ✅ Verifikáció
+- `subscription_tier_id` oszlop létezik (`information_schema` ellenőrizve)
+- Org 45: `corp_premium_plus_v1` (tier_id=17, max_vehicles=50) ✅
+- Org 49: `corp_premium_v1` (tier_id=16, max_vehicles=20) ✅
+- 8 subscription tier elérhető a `system.subscription_tiers` táblában
+
+## 2026-06-18 - P0 Critical: Admin Button Fix & Orphaned Vehicle Rescue
+
+### 🎯 Cél
+5-lépéses P0 kritikus feladat: (1) Törött Admin/Vezérlőpult gomb javítása a frontenden, (2) Adatbázis felderítés: gazdátlan járművek keresése, (3) Admin felhasználó garázsainak ellenőrzése, (4) Gazdátlan járművek megmentése (UPDATE), (5) Verifikáció és jelentés.
+
+### 🔧 Változtatások
+
+**1. FRONTEND - [`auth.ts`](frontend/src/stores/auth.ts:97):**
+- **Root Cause:** A `ModeSwitcher.vue` a `stores/auth`-ból importálja az `authStore.isAdmin`-et, de a store-ból hiányzott az `isAdmin` computed getter. Mindig `undefined`-et adott vissza, így a gomb sosem jelent meg.
+- **Fix:** `isAdmin` computed property hozzáadva, amely ellenőrzi a `superadmin`, `admin`, `region_admin`, `country_admin`, `moderator` szerepköröket (kisbetűs összehasonlítással).
+
+**2. ADATBÁZIS - Gazdátlan járművek felderítése:**
+- **3 db orphaned vehicle** találva: TEST-API-01, ABB112, ABC-123 (mind `archived` státuszú, `owner_person_id = NULL`, `current_organization_id = NULL`)
+- **Admin user** (admin@profibot.hu, user_id=2, person_id=2, role=ADMIN) nem rendelkezik Corporate (fleet_owner/business) garázzsal OWNER joggal - csak "Admin Garázsa" (Org 67, individual típus)
+- Admin 17 járműve már helyesen a Test Company (Org 1, fleet_owner) alá van rendelve
+
+**3. ADATBÁZIS - Rescue UPDATE:**
+- 3 gazdátlan jármű átvezetve az Admin Garázsába (Org 67): `owner_person_id=2, owner_org_id=67, current_organization_id=67`
+- Parancs: `docker compose exec -T shared-postgres psql ... UPDATE vehicle.assets SET ... WHERE current_organization_id IS NULL AND owner_person_id IS NULL`
+- Eredmény: `UPDATE 3`
+
+### ✅ Verifikáció
+- `SELECT ... WHERE current_organization_id IS NULL` → **üres eredmény** (0 gazdátlan jármű) ✅
+- 3 rescued vehicle: TEST-API-01, ABB112, ABC-123 → Admin Garázsa (Org 67) ✅
+- Admin 17 egyéb járműve: Test Company (Org 1) alatt maradt ✅
+
+## 2026-06-18 - P0 CRITICAL: Fix Admin Panel Case Sensitivity (SUPERADMIN/ADMIN)
+
+### 🎯 Cél
+A backend UPPERCASE role értékei (SUPERADMIN, ADMIN) és a frontend lowercase összehasonlításai közötti mismatch javítása.
+
+### 🔧 Változtatások
+- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:99) — `isAdmin` getter: lowercase `[superadmin,admin,...]` → UPPERCASE `[SUPERADMIN,ADMIN,MODERATOR]` + `role.toUpperCase()`
+- [`frontend/src/router/index.ts`](frontend/src/router/index.ts:131) — Router guard: ugyanez a javítás a `requiresAdmin` meta ellenőrzésben
+- [`frontend/src/stores/authStore.ts`](frontend/src/stores/authStore.ts:27) — Secondary auth store `isAdmin`/`isSuperAdmin` getterek: `role.toUpperCase()`
+- [`frontend/src/components/header/HeaderProfile.vue`](frontend/src/components/header/HeaderProfile.vue:145) — Inline `isAdmin` computed: lowercase → UPPERCASE + `toUpperCase()`
+- [`frontend/src/views/admin/AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue:130,299) — Két inline `=== superadmin` check → `=== SUPERADMIN` + `toUpperCase()`
+
+### ✅ Verifikáció
+- Backend admin ping endpoint: `role: "ADMIN"` (UPPERCASE) ✅
+- Frontend Vite HMR: minden módosított fájl újratöltve ✅
+- Admin felület minden ponton (store getter, router guard, header button, users view) case-insensitive módon ellenőrzi a SUPERADMIN/ADMIN szerepköröket ✅
+
+## 2026-06-18 - P0 Critical: Precise Subscription Backfill & Legacy Cleanup
+
+### 🎯 Cél
+Admin garázsok VIP csomag hozzárendelése, tömeges free tier fallback, legacy string oszlopok kivezetése a kvóta logikából.
+
+### 🔧 Változtatások
+1. **Adatbázis backfill** ([`backend/app/scripts/p0_subscription_backfill.py`](backend/app/scripts/p0_subscription_backfill.py)):
+ - VIP: Admin Garázsa (org_id=67) → `private_test_v01` (tier_id=19, max_vehicles=100) ✅
+ - VIP: Test Company (org_id=1) → `org_test_v01` (tier_id=20, max_vehicles=100) ✅
+ - Free Tier Fallback: 14 individual org → `private_free_v1`, 13 other org → `corp_free_v1` ✅
+ - Eredmény: 31/31 org assigned, 0 NULL ✅
+
+2. **Kvóta Motor refaktor** ([`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:491)):
+ - `get_user_vehicle_limit()`: Eltávolítva a legacy `subscription_plan` string lookup
+ - Single Source of Truth: `SubscriptionTier.rules['allowances']['max_vehicles']` JSONB
+ - Fallback lánc: user subscription → org subscription tier → org.base_asset_limit → config
+
+3. **Dokumentum helper cleanup** ([`backend/app/api/v1/endpoints/documents.py`](backend/app/api/v1/endpoints/documents.py:90)):
+ - `_check_premium_or_admin()`: Eltávolítva a `subscription_plan` string alapú premium check
+
+### ✅ Verifikáció
+- Schema szinkron: sync_engine → 1089/1089 OK ✅
+- 0 org with NULL subscription_tier_id ✅
+- Distribution: private_free_v1(14), corp_free_v1(13), corp_premium_v1(1), private_test_v01(1), corp_premium_plus_v1(1), org_test_v01(1) ✅
+
+## 2026-06-18 - P0 CRITICAL: PATCH Organization 500 Error Fix
+
+### 🎯 Cél
+A `PATCH /api/v1/organizations/{id}` végpont 500 Internal Server Error hibájának javítása, amely a subscription backfill után jelentkezett.
+
+### 🔍 Root Cause Analysis
+
+**Kiváltó ok #1:** PostgreSQL ENUM típus mismatch.
+
+A `fleet.organization_members.role` oszlop az adatbázisban `fleet.orguserrole` ENUM típusként volt definiálva, de a SQLAlchemy modellben [`OrganizationMember.role`](backend/app/models/marketplace/organization.py:246) `String(50)`-ként. Amikor a `PATCH /organizations/{id}` végpont [`update_organization`](backend/app/api/v1/endpoints/organizations.py:672) függvénye lefutott, az SQLAlchemy a következő SQL-t generálta:
+
+```sql
+WHERE fleet.organization_members.role IN ('OWNER', 'ADMIN')
+```
+
+PostgreSQL nem tud implicit módon összehasonlítani egy ENUM típust (`fleet.orguserrole`) egy string literállal (`character varying`), ezért dobta:
+
+```
+operator does not exist: fleet.orguserrole = character varying
+HINT: No operator matches the given name and argument types. You might need to add explicit type casts.
+```
+
+**Kiváltó ok #2:** Hiányzó `OrganizationMember` rekord az `owner_id` alapú tulajdonosoknak.
+
+A subscription backfill során számos szervezetnél az `owner_id` mező be lett állítva, de a `fleet.organization_members` táblába nem került be a megfelelő OWNER szerepkörű rekord. A PATCH végpont csak a `OrganizationMember` táblában keresett, így a jogos tulajdonos is 403-as hibát kapott.
+
+**Érintett fájlok:**
+- [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:246) - `OrganizationMember.role` oszlop típusa
+- [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:668) - A PATCH végpont RBAC ellenőrzése
+
+### 🔧 Javítások
+
+**1. MODELL JAVÍTÁS** ([`organization.py`](backend/app/models/marketplace/organization.py:246)):
+- `OrganizationMember.role` oszlop típusa `String(50)` → `PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False)`-re változtatva
+- `create_type=False` paraméter biztosítja, hogy nem próbáljuk újra létrehozni a már meglévő ENUM típust
+
+**2. VÉGPONT JAVÍTÁS** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:668)):
+- Az RBAC ellenőrzés kiegészítve: ha a user nem található `OrganizationMember`-ként, de `Organization.owner_id`-ként szerepel, akkor is engedélyezve van a módosítás
+- A szervezet lekérése előrébb került (az RBAC ellenőrzés előtt), hogy elérhető legyen az `owner_id` mező
+- Ezzel elkerültük a duplikált SQL lekérdezést is
+
+### ✅ Verifikáció
+- `PATCH /api/v1/organizations/44` → **200 OK** ✅ (korábban 500, majd 403)
+- Nincs több `UndefinedFunctionError` a logokban ✅
+- Az SQLAlchemy által generált SQL helyesen kezeli az ENUM összehasonlítást ✅
+- A tulajdonos (owner_id) sikeresen tudja módosítani a szervezet adatait ✅
+
+## 2026-06-18: Address fields missing from OrganizationResponse and GET /my (#3-as probléma)
+
+### 🔍 Root Cause Analysis
+
+A PATCH `/api/v1/organizations/{id}` végpont 200 OK-val tért vissza, és az adatok az adatbázisba is bekerültek, de a frontend UI-ban nem jelentek meg a cím adatok. A probléma **három rétegben** jelentkezett:
+
+**1. Backend `OrganizationResponse` séma hiányossága** ([`organization.py`](backend/app/schemas/organization.py:70)):
+- A `OrganizationResponse` Pydantic osztály nem tartalmazta a cím mezőket (`address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`)
+- A PATCH végpont `response_model=OrganizationResponse`-et használ, így a válaszból kimaradtak a cím adatok
+
+**2. Backend `GET /my` végpont hiányossága** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:210)):
+- A `get_my_organizations` függvény által visszaadott dictionary-ből hiányoztak a cím mezők
+- A frontend a PATCH után `authStore.fetchMyOrganizations()`-t hív, ami ezt a végpontot használja
+
+**3. Frontend `OrganizationItem` típus és `onMounted` hiányossága** ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:222)):
+- A TypeScript `OrganizationItem` interfészből hiányoztak a cím mezők
+- Az `onMounted` hook nem töltötte be a cím mezőket a `form` objektumba
+
+**További probléma:** A `OrganizationUpdate` séma tartalmazott olyan mezőket (`address_stairwell`, `address_floor`, `address_door`), amelyek nem léteznek az `Organization` SQLAlchemy modellben. Ezeket a PATCH végpont csendben figyelmen kívül hagyta, de a `GET /my` végpont AttributeError-t dobott volna rájuk.
+
+### 🔧 Javítások
+
+**1. `OrganizationResponse` séma bővítése** ([`organization.py`](backend/app/schemas/organization.py:88)):
+- Hozzáadva: `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`
+- Eltávolítva a nem létező mezők: `address_stairwell`, `address_floor`, `address_door`
+
+**2. `OrganizationUpdate` séma tisztítása** ([`organization.py`](backend/app/schemas/organization.py:54)):
+- Eltávolítva a nem létező mezők: `address_stairwell`, `address_floor`, `address_door`
+
+**3. `GET /my` végpont bővítése** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:227)):
+- Hozzáadva a cím mezők a visszaadott dictionary-hez
+
+**4. Frontend `OrganizationItem` típus bővítése** ([`organization.ts`](frontend/src/types/organization.ts:33)):
+- Hozzáadva a cím mezők az interfészhez
+
+**5. Frontend `OrganizationSettingsModal.vue` javítása** ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:222)):
+- `form` objektum bővítve a cím mezőkkel
+- `onMounted` hook kiegészítve a cím mezők betöltésével (mindkét ágban)
+
+### ✅ Verifikáció
+- `GET /api/v1/organizations/my` → **200 OK**, tartalmazza a cím mezőket ✅
+- `PATCH /api/v1/organizations/{id}` → **200 OK**, a válasz tartalmazza a cím mezőket ✅
+- A cím adatok elérhetők a frontend `OrganizationSettingsModal` form-jában ✅
diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index e00140d..a222e37 100755
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -10,8 +10,10 @@ from sqlalchemy.orm import selectinload, joinedload
from app.db.session import get_db
from app.core.security import decode_token, DEFAULT_RANK_MAP
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
+from app.models.marketplace.organization import OrgRole, OrganizationMember
from app.core.config import settings
from app.core.translation_helper import t # Translation helper
+from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
logger = logging.getLogger(__name__)
@@ -104,13 +106,13 @@ async def get_current_active_user(
return current_user
async def check_resource_access(
- resource_scope_id: Union[str, int],
+ resource_scope_id: Union[str, int],
current_user: User = Depends(get_current_user)
):
"""
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
"""
- if current_user.role == UserRole.superadmin:
+ if current_user.role == UserRole.SUPERADMIN:
return True
user_scope = str(current_user.scope_id) if current_user.scope_id else None
@@ -161,17 +163,138 @@ async def get_current_admin(
"""
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
"""
- # A UserRole Enum értékeit használjuk
+ # A UserRole Enum értékeit használjuk (RBAC Phase 1)
allowed_roles = {
- UserRole.superadmin,
- UserRole.admin,
- UserRole.region_admin,
- UserRole.country_admin,
- UserRole.moderator,
+ UserRole.SUPERADMIN,
+ UserRole.ADMIN,
+ UserRole.MODERATOR,
}
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
)
- return current_user
\ No newline at end of file
+ return current_user
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# RBAC Phase 2: Capability Dependencies (Kapuőrök)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+def RequireSystemCapability(capability_name: str):
+ """
+ 🎯 Rendszerszintű képesség-ellenőrző dependency.
+
+ Használat:
+ @router.get("/admin/users")
+ async def list_users(
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ _ = Depends(RequireSystemCapability("can_manage_users"))
+ ):
+
+ Logika:
+ 1. Ha a user SUPERADMIN → azonnal átengedi (True).
+ 2. Egyébként lekéri a user role-ját, és ellenőrzi a
+ SYSTEM_CAPABILITIES_MATRIX-ból, hogy a kért capability True-e.
+ 3. Ha nincs meg a capability → 403 Forbidden.
+ """
+ async def system_capability_checker(
+ current_user: User = Depends(get_current_user),
+ ) -> bool:
+ # SUPERADMIN mindent visz
+ if current_user.role == UserRole.SUPERADMIN:
+ return True
+
+ # Ellenőrizzük a SYSTEM_CAPABILITIES_MATRIX-ból
+ role_key = current_user.role.value # pl. "ADMIN", "MODERATOR", "USER"
+ if not role_has_capability(role_key, capability_name):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"SYSTEM_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{role_key}' szerepkör számára."
+ )
+ return True
+
+ return system_capability_checker
+
+
+def RequireOrgCapability(capability_name: str):
+ """
+ 🎯 Szervezeti képesség-ellenőrző dependency.
+
+ Használat:
+ @router.post("/expenses")
+ async def create_expense(
+ expense: AssetCostCreate,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ _ = Depends(RequireOrgCapability("can_add_expense"))
+ ):
+
+ Logika:
+ 1. Ha a user SUPERADMIN → azonnal átengedi (True).
+ 2. Lekéri a user OrgRole-ját az adott organization_id alapján
+ a fleet.organization_members táblából.
+ 3. Kikeresi a fleet.org_roles táblából a szerepkörhöz tartozó
+ permissions JSONB oszlopot.
+ 4. Ha a JSONB-ben a kért capability_name nem True → 403 Forbidden.
+
+ Fontos: Ez a dependency egy organization_id paramétert vár a végponttól.
+ A végpontnak át kell adnia az org_id-t a dependency-nek.
+ """
+ async def org_capability_checker(
+ organization_id: int,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ ) -> bool:
+ # SUPERADMIN mindent visz
+ if current_user.role == UserRole.SUPERADMIN:
+ return True
+
+ # 1. Lekérjük a user szerepkörét a szervezetben
+ member_stmt = select(OrganizationMember.role).where(
+ OrganizationMember.user_id == current_user.id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_result = await db.execute(member_stmt)
+ org_role_name = member_result.scalar_one_or_none()
+
+ if not org_role_name:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="ORG_MEMBERSHIP_REQUIRED: You are not a member of this organization."
+ )
+
+ # 2. Lekérjük a szerepkör permissions JSONB-jét a fleet.org_roles táblából
+ role_stmt = select(OrgRole.permissions).where(
+ OrgRole.name_key == org_role_name,
+ OrgRole.is_active == True
+ ).limit(1)
+ role_result = await db.execute(role_stmt)
+ permissions = role_result.scalar_one_or_none()
+
+ if not permissions:
+ # Fallback: ha nincs a fleet.org_roles táblában, használjuk a
+ # OrganizationMember.permissions mezőt (örökölt viselkedés)
+ member_perm_stmt = select(OrganizationMember.permissions).where(
+ OrganizationMember.user_id == current_user.id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_perm_result = await db.execute(member_perm_stmt)
+ permissions = member_perm_result.scalar_one_or_none() or {}
+
+ # 3. Ellenőrizzük a kért képességet
+ if not isinstance(permissions, dict):
+ permissions = {}
+
+ if not permissions.get(capability_name, False):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=f"ORG_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{org_role_name}' szerepkör számára ebben a szervezetben."
+ )
+
+ return True
+
+ return org_capability_checker
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py
index ac8436f..67e5ee8 100755
--- a/backend/app/api/v1/endpoints/admin.py
+++ b/backend/app/api/v1/endpoints/admin.py
@@ -192,7 +192,7 @@ async def ban_user(
)
# 2. Ellenőrizd, hogy nem superadmin-e
- if user.role == UserRole.superadmin:
+ if user.role == UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot ban a superadmin user"
@@ -698,7 +698,7 @@ async def bulk_user_action(
affected_count = 0
for user in users:
- if user.role == UserRole.superadmin and user.id != current_admin.id:
+ if user.role == UserRole.SUPERADMIN and user.id != current_admin.id:
continue
if request.action == "ban":
diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py
index 2820155..552c09b 100755
--- a/backend/app/api/v1/endpoints/assets.py
+++ b/backend/app/api/v1/endpoints/assets.py
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
-from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
+from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
from app.models.identity import User
from app.services.cost_service import cost_service
from app.services.asset_service import AssetService
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
logger = logging.getLogger(__name__)
+async def _resolve_user_role_in_org(
+ db: AsyncSession,
+ user_id: int,
+ organization_id: int
+) -> str:
+ """
+ Resolve the user's role within the given organization.
+
+ Returns:
+ str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
+ Returns 'MEMBER' as default if no membership found.
+ """
+ stmt = select(OrganizationMember.role).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ result = await db.execute(stmt)
+ role = result.scalar_one_or_none()
+ return role or "MEMBER"
+
+
+async def _check_org_capability(
+ db: AsyncSession,
+ user_id: int,
+ organization_id: int,
+ capability_name: str
+) -> bool:
+ """
+ RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
+
+ Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
+ permissions JSONB oszlopából ellenőrzi a kért képességet.
+
+ Returns:
+ bool: True ha a user rendelkezik a képességgel.
+ """
+ # 1. Get user's org role
+ member_stmt = select(OrganizationMember.role).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_result = await db.execute(member_stmt)
+ org_role_name = member_result.scalar_one_or_none()
+
+ if not org_role_name:
+ return False
+
+ # 2. Get permissions from fleet.org_roles table
+ role_stmt = select(OrgRole.permissions).where(
+ OrgRole.name_key == org_role_name,
+ OrgRole.is_active == True
+ ).limit(1)
+ role_result = await db.execute(role_stmt)
+ permissions = role_result.scalar_one_or_none()
+
+ if not permissions:
+ # Fallback to OrganizationMember.permissions
+ member_perm_stmt = select(OrganizationMember.permissions).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_perm_result = await db.execute(member_perm_stmt)
+ permissions = member_perm_result.scalar_one_or_none() or {}
+
+ if not isinstance(permissions, dict):
+ permissions = {}
+
+ return permissions.get(capability_name, False)
+
+
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
@@ -40,10 +113,18 @@ async def get_vehicle_quota_status(
GET /api/v1/assets/vehicles/quota-status
+ Resolves the user's active organization:
+ - If scope_id is set, use that organization
+ - If scope_id is null, resolve the user's primary (individual) garage
+ from fleet.organizations via owner_id
+
Returns:
{ "can_add": bool, "current_count": int, "limit": int }
"""
try:
+ from sqlalchemy import func
+ from app.models.marketplace.organization import Organization
+
# Determine organization ID based on user's active scope (garage isolation)
org_id = None
if current_user.scope_id is not None:
@@ -51,6 +132,20 @@ async def get_vehicle_quota_status(
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
+ else:
+ # Resolve the user's primary individual garage
+ org_stmt = select(Organization.id).where(
+ Organization.owner_id == current_user.id,
+ Organization.org_type == "individual",
+ Organization.is_active == True
+ ).limit(1)
+ org_result = await db.execute(org_stmt)
+ resolved_org_id = org_result.scalar_one_or_none()
+ if resolved_org_id is not None:
+ org_id = resolved_org_id
+ logger.info(
+ f"Resolved primary org for user {current_user.id}: org_id={org_id}"
+ )
# Get the user's vehicle limit
allowed_limit = await AssetService.get_user_vehicle_limit(
@@ -59,17 +154,15 @@ async def get_vehicle_quota_status(
# SAFETY: Ensure limit is at least 1
allowed_limit = max(allowed_limit or 1, 1)
- # Count only active vehicles (draft vehicles don't count toward the limit)
- from sqlalchemy import func
+ # Count only active vehicles in the resolved organization
if org_id is not None:
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
- Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
else:
+ # Fallback: count by owner_person_id if no org could be resolved
count_stmt = select(func.count(Asset.id)).where(
- Asset.current_organization_id.is_(None),
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
@@ -158,29 +251,35 @@ async def get_user_vehicles(
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None:
- # Personal mode: only show vehicles owned/operated personally (no organization)
- stmt = (
- select(Asset)
- .where(
- or_(
- Asset.owner_org_id.is_(None),
- Asset.operator_org_id.is_(None)
- ),
- or_(
- Asset.owner_person_id == current_user.person_id,
- Asset.operator_person_id == current_user.person_id
+ # Personal mode: show vehicles in organizations where user is a member
+ org_stmt = select(OrganizationMember.organization_id).where(
+ OrganizationMember.user_id == current_user.id
+ )
+ org_result = await db.execute(org_stmt)
+ user_org_ids = [row[0] for row in org_result.all()]
+
+ if user_org_ids:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.current_organization_id.in_(user_org_ids),
+ Asset.status == "active"
+ )
+ .order_by(order_expr, Asset.created_at.desc())
+ .offset(skip)
+ .limit(limit)
+ .options(
+ selectinload(Asset.catalog),
+ selectinload(Asset.odometer_readings)
)
)
- .order_by(order_expr, Asset.created_at.desc())
- .offset(skip)
- .limit(limit)
- .options(
- selectinload(Asset.catalog),
- selectinload(Asset.odometer_readings)
- )
- )
+ else:
+ # No organizations — return empty list
+ return []
else:
- # Corporate mode: only show vehicles belonging to the active organization's garages
+ # Corporate mode: show vehicles belonging to the active organization
+ # Uses current_organization_id (same logic as quota-status endpoint)
+ # for consistency — vehicles belong to organizations, not branches.
try:
scope_org_id = int(current_user.scope_id)
except (ValueError, TypeError):
@@ -191,25 +290,11 @@ async def get_user_vehicles(
# Fallback: no valid organization, return empty list
return []
- # First, get all branch IDs (garages) for this organization
- from app.models.marketplace.organization import Branch
- branch_stmt = select(Branch.id).where(
- Branch.organization_id == scope_org_id,
- Branch.is_deleted == False,
- Branch.status == "active"
- )
- branch_result = await db.execute(branch_stmt)
- branch_ids = [row[0] for row in branch_result.all()]
-
- if not branch_ids:
- # Organization has no active garages, return empty list
- return []
-
- # Query assets that are in any of the organization's garages
+ # Query assets that belong to the active organization
stmt = (
select(Asset)
.where(
- Asset.branch_id.in_(branch_ids),
+ Asset.current_organization_id == scope_org_id,
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
@@ -271,7 +356,9 @@ async def list_asset_costs(
"id": cost.id,
"asset_id": cost.asset_id,
"organization_id": cost.organization_id,
+ "amount_gross": cost.amount_gross,
"amount_net": cost.amount_net,
+ "vat_rate": cost.vat_rate,
"currency": cost.currency,
"date": cost.date,
"invoice_number": cost.invoice_number,
@@ -281,6 +368,8 @@ async def list_asset_costs(
"category_code": cost.category.code if cost.category else None,
"description": (cost.data or {}).get("description"),
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
+ "status": cost.status,
+ "linked_asset_event_id": cost.linked_asset_event_id,
}
result.append(AssetCostResponse(**cost_dict))
@@ -308,22 +397,32 @@ async def get_asset(
user_org_ids = [row[0] for row in org_result.all()]
# Query asset with catalog and master definition
- stmt = (
- select(Asset)
- .where(
- Asset.id == asset_id,
- or_(
- Asset.owner_person_id == current_user.id,
- Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
- Asset.operator_person_id == current_user.id,
- Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
+ # Unified Workspace: access is granted if the asset belongs to an organization
+ # where the current user is an active member
+ if user_org_ids:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.in_(user_org_ids)
+ )
+ .options(
+ selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
+ selectinload(Asset.odometer_readings)
)
)
- .options(
- selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
- selectinload(Asset.odometer_readings)
+ else:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.is_(None)
+ )
+ .options(
+ selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
+ selectinload(Asset.odometer_readings)
+ )
)
- )
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -337,6 +436,69 @@ async def get_asset(
return asset
+@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
+async def get_vehicle(
+ vehicle_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Get detailed information about a specific vehicle/asset by vehicle_id.
+
+ GET /api/v1/assets/vehicles/{vehicle_id}
+
+ This endpoint is the primary single-vehicle lookup used by the frontend.
+ Uses Unified Workspace (OrganizationMember-based) permission check.
+
+ Returns the asset's full technical profile including catalog data
+ and vehicle model definition specifications.
+ """
+ # Get user's organization memberships
+ org_stmt = select(OrganizationMember.organization_id).where(
+ OrganizationMember.user_id == current_user.id
+ )
+ org_result = await db.execute(org_stmt)
+ user_org_ids = [row[0] for row in org_result.all()]
+
+ # Unified Workspace: access is granted if the asset belongs to an organization
+ # where the current user is an active member
+ if user_org_ids:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == vehicle_id,
+ Asset.current_organization_id.in_(user_org_ids)
+ )
+ .options(
+ selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
+ selectinload(Asset.odometer_readings)
+ )
+ )
+ else:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == vehicle_id,
+ Asset.current_organization_id.is_(None)
+ )
+ .options(
+ selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
+ selectinload(Asset.odometer_readings)
+ )
+ )
+
+ result = await db.execute(stmt)
+ asset = result.scalar_one_or_none()
+
+ if not asset:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Asset not found or you don't have permission to access it"
+ )
+
+ return asset
+
+
@router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_or_claim_vehicle(
payload: AssetCreate,
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
existing_asset = existing_result.scalar_one_or_none()
if existing_asset:
- # Szabály A: Saját jármű duplikációja
- if existing_asset.owner_person_id == current_user.person_id:
+ # Szabály A: Saját jármű duplikációja — check via organization membership
+ # Get user's organization IDs
+ member_org_stmt = select(OrganizationMember.organization_id).where(
+ OrganizationMember.user_id == current_user.id
+ )
+ member_org_result = await db.execute(member_org_stmt)
+ user_org_ids = [row[0] for row in member_org_result.all()]
+
+ if existing_asset.current_organization_id in user_org_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez a jármű már szerepel a garázsodban!"
)
# Szabály B: Tulajdonosváltás / Átmeneti állapot
- # A rendszám létezik, de más a tulajdonosa
+ # A rendszám létezik, de más szervezetben van
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft"
logger.info(
- f"License plate {license_plate_clean} exists with different owner "
- f"(owner_person_id={existing_asset.owner_person_id} != "
- f"current_user.person_id={current_user.person_id}). "
+ f"License plate {license_plate_clean} exists in different organization "
+ f"(current_organization_id={existing_asset.current_organization_id}). "
f"Setting data_status='draft' for transfer scenario."
)
@@ -458,20 +626,25 @@ async def update_vehicle(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
- # Find the asset
- stmt = (
- select(Asset)
- .where(
- Asset.id == asset_id,
- or_(
- Asset.owner_person_id == current_user.person_id,
- Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
- Asset.operator_person_id == current_user.person_id,
- Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
+ # Find the asset — Unified Workspace: access via organization membership only
+ if user_org_ids:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.in_(user_org_ids)
)
+ .options(selectinload(Asset.catalog))
+ )
+ else:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.is_(None)
+ )
+ .options(selectinload(Asset.catalog))
)
- .options(selectinload(Asset.catalog))
- )
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -540,16 +713,17 @@ async def list_maintenance_records(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
- # Check asset access
- asset_stmt = select(Asset).where(
- Asset.id == asset_id,
- or_(
- Asset.owner_person_id == current_user.id,
- Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
- Asset.operator_person_id == current_user.id,
- Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
+ # Check asset access — Unified Workspace: via organization membership only
+ if user_org_ids:
+ asset_stmt = select(Asset).where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.in_(user_org_ids)
+ )
+ else:
+ asset_stmt = select(Asset).where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.is_(None)
)
- )
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
@@ -599,16 +773,17 @@ async def create_maintenance_record(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
- # Check asset access and get asset
- asset_stmt = select(Asset).where(
- Asset.id == asset_id,
- or_(
- Asset.owner_person_id == current_user.id,
- Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
- Asset.operator_person_id == current_user.id,
- Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
+ # Check asset access and get asset — Unified Workspace: via organization membership only
+ if user_org_ids:
+ asset_stmt = select(Asset).where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.in_(user_org_ids)
+ )
+ else:
+ asset_stmt = select(Asset).where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.is_(None)
)
- )
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
@@ -663,11 +838,14 @@ async def create_maintenance_record(
)
# Create AssetCost record with MAINTENANCE category (id=2)
+ # GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
+ cost_gross = float(payload["cost"])
maintenance_cost = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=2,
- amount_net=float(payload["cost"]),
+ amount_gross=cost_gross,
+ amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
currency=payload.get("currency", "EUR"),
date=date,
invoice_number=payload.get("invoice_number"),
@@ -736,18 +914,23 @@ async def _check_asset_access(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
- stmt = (
- select(Asset)
- .where(
- Asset.id == asset_id,
- or_(
- Asset.owner_person_id == current_user.id,
- Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
- Asset.operator_person_id == current_user.id,
- Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
+ # Unified Workspace: access via organization membership only
+ if user_org_ids:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.in_(user_org_ids)
+ )
+ )
+ else:
+ stmt = (
+ select(Asset)
+ .where(
+ Asset.id == asset_id,
+ Asset.current_organization_id.is_(None)
)
)
- )
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -863,6 +1046,14 @@ async def create_asset_event(
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
+
+ **Role-Based Auto-Approval (PHASE 2):**
+ - OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
+ - DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
+
+ **Smart Linking (Double-Entry Avoidance):**
+ - When cost_amount > 0, the auto-created AssetCost is linked back
+ to the event via linked_expense_id / linked_asset_event_id.
"""
# Jogosultság ellenőrzés
asset = await _check_asset_access(db, asset_id, current_user)
@@ -873,9 +1064,30 @@ async def create_asset_event(
# Szervezet meghatározása
organization_id = asset.current_organization_id or asset.owner_org_id
+ # ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
+ user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
+
+ # Check if user has can_approve_expense capability
+ can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
+
+ # Determine event and cost status based on capability
+ if can_approve:
+ event_status = "COMPLETED"
+ cost_status = "APPROVED"
+ else:
+ event_status = "MISSING_TECH_DATA"
+ cost_status = "PENDING_APPROVAL"
+
+ logger.info(
+ f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
+ f"in org {organization_id}: can_approve_expense={can_approve}, "
+ f"event_status={event_status}, cost_status={cost_status}"
+ )
+
try:
# ── Üzleti logika: AssetCost automatikus létrehozása ──
cost_id = payload.cost_id
+ auto_created_cost_id = None
if payload.cost_amount is not None and payload.cost_amount > 0:
# Költségkategória feloldása az esemény típusa alapján
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
@@ -883,13 +1095,17 @@ async def create_asset_event(
currency = payload.currency or "HUF"
+ # GROSS-FIRST: cost_amount is treated as gross (Bruttó)
+ cost_gross = payload.cost_amount
cost_record = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=cost_category_id,
- amount_net=payload.cost_amount,
+ amount_gross=cost_gross,
+ amount_net=cost_gross, # Default: net = gross (0% VAT)
currency=currency,
date=event_date,
+ status=cost_status,
data={
"description": payload.description or f"Service event: {payload.event_type}",
"mileage_at_cost": payload.odometer_reading,
@@ -900,13 +1116,15 @@ async def create_asset_event(
db.add(cost_record)
await db.flush() # Flush to get the cost_record.id
cost_id = cost_record.id
+ auto_created_cost_id = cost_record.id
logger.info(
f"AssetCost auto-created for asset {asset_id}: "
- f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
+ f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
+ f"cost_id={cost_id}, status={cost_status})"
)
- # Új esemény létrehozása
+ # Új esemény létrehozása (with status and bidirectional link)
event = AssetEvent(
asset_id=asset_id,
user_id=current_user.id,
@@ -915,9 +1133,28 @@ async def create_asset_event(
odometer_reading=payload.odometer_reading,
description=payload.description,
cost_id=cost_id,
+ linked_expense_id=auto_created_cost_id, # Bidirectional link
+ status=event_status,
event_date=event_date,
)
db.add(event)
+ await db.flush() # Flush to get event.id
+
+ # If we auto-created a cost, link it back to the event
+ if auto_created_cost_id:
+ # Update the cost record with the link back to the event
+ stmt_update = (
+ select(AssetCost)
+ .where(AssetCost.id == auto_created_cost_id)
+ )
+ result = await db.execute(stmt_update)
+ cost_record = result.scalar_one_or_none()
+ if cost_record:
+ cost_record.linked_asset_event_id = event.id
+ logger.info(
+ f"Smart Sync: Bidirectional link established between "
+ f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
+ )
# ── Üzleti logika: Km óra állás frissítése ──
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
diff --git a/backend/app/api/v1/endpoints/documents.py b/backend/app/api/v1/endpoints/documents.py
index dc7734d..d9de832 100755
--- a/backend/app/api/v1/endpoints/documents.py
+++ b/backend/app/api/v1/endpoints/documents.py
@@ -89,11 +89,15 @@ async def get_document_status(
# RBAC helper function
def _check_premium_or_admin(user: User) -> bool:
- """Check if user has premium subscription or admin role."""
- premium_plans = ['PREMIUM', 'PREMIUM_PLUS', 'VIP', 'VIP_PLUS']
- if user.role == 'admin':
- return True
- if hasattr(user, 'subscription_plan') and user.subscription_plan in premium_plans:
+ """Check if user has premium subscription or admin role.
+
+ P0: Legacy subscription_plan string check removed.
+ The Single Source of Truth is now subscription_tier JSONB rules.
+ Premium entitlement should be checked via the SubscriptionTier entitlements array.
+ """
+ premium_roles = ['admin', 'superadmin']
+ user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
+ if user_role.lower() in premium_roles:
return True
return False
diff --git a/backend/app/api/v1/endpoints/evidence.py b/backend/app/api/v1/endpoints/evidence.py
index ae17d6f..4ed4172 100755
--- a/backend/app/api/v1/endpoints/evidence.py
+++ b/backend/app/api/v1/endpoints/evidence.py
@@ -2,10 +2,12 @@
import logging
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func, text
+from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
+from app.models.marketplace.organization import Organization
+from app.models.core_logic import SubscriptionTier
logger = logging.getLogger(__name__)
@@ -13,10 +15,28 @@ router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
- stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
- plan_key = (current_user.subscription_plan or "free").lower()
- res = await db.execute(stmt_limit, {"plan": plan_key})
- max_allowed = max(res.scalar() or 1, 1)
+ # ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
+ # Instead of hardcoded plan-based lookup from system_parameters,
+ # we now read the organization's assigned subscription_tier rules.
+ max_allowed = 1 # default fallback
+
+ if current_user.scope_id:
+ # Get the organization and its subscription tier
+ stmt_org = select(Organization).where(Organization.id == current_user.scope_id)
+ result = await db.execute(stmt_org)
+ org = result.scalar_one_or_none()
+
+ if org and org.subscription_tier_id:
+ stmt_tier = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
+ tier_result = await db.execute(stmt_tier)
+ tier = tier_result.scalar_one_or_none()
+
+ if tier and tier.rules:
+ max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
+ max_allowed = max(int(max_vehicles), 1)
+ elif org:
+ # Fallback to legacy base_asset_limit if no tier assigned
+ max_allowed = max(org.base_asset_limit or 1, 1)
stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id,
diff --git a/backend/app/api/v1/endpoints/expenses.py b/backend/app/api/v1/endpoints/expenses.py
index cf8004e..9c0e0d2 100755
--- a/backend/app/api/v1/endpoints/expenses.py
+++ b/backend/app/api/v1/endpoints/expenses.py
@@ -1,10 +1,11 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging
+from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
-from app.api.deps import get_db, get_current_user
-from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
+from app.api.deps import get_db, get_current_user, RequireOrgCapability
+from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime, timezone
@@ -14,8 +15,101 @@ logger = logging.getLogger(__name__)
# These trigger automatic AssetEvent creation
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
+# Fuel category IDs - auto-approved even for DRIVER role
+FUEL_CATEGORY_IDS = {1} # FUEL
+
router = APIRouter()
+
+async def _resolve_user_role_in_org(
+ db: AsyncSession,
+ user_id: int,
+ organization_id: int
+) -> str:
+ """
+ Resolve the user's role within the given organization.
+
+ Returns:
+ str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
+ Returns 'MEMBER' as default if no membership found.
+ """
+ stmt = select(OrganizationMember.role).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ result = await db.execute(stmt)
+ role = result.scalar_one_or_none()
+ return role or "MEMBER"
+
+
+async def _check_org_capability(
+ db: AsyncSession,
+ user_id: int,
+ organization_id: int,
+ capability_name: str
+) -> bool:
+ """
+ RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
+
+ Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
+ permissions JSONB oszlopából ellenőrzi a kért képességet.
+
+ Returns:
+ bool: True ha a user rendelkezik a képességgel.
+ """
+ # 1. Get user's org role
+ member_stmt = select(OrganizationMember.role).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_result = await db.execute(member_stmt)
+ org_role_name = member_result.scalar_one_or_none()
+
+ if not org_role_name:
+ return False
+
+ # 2. Get permissions from fleet.org_roles table
+ role_stmt = select(OrgRole.permissions).where(
+ OrgRole.name_key == org_role_name,
+ OrgRole.is_active == True
+ ).limit(1)
+ role_result = await db.execute(role_stmt)
+ permissions = role_result.scalar_one_or_none()
+
+ if not permissions:
+ # Fallback to OrganizationMember.permissions
+ member_perm_stmt = select(OrganizationMember.permissions).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.organization_id == organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_perm_result = await db.execute(member_perm_stmt)
+ permissions = member_perm_result.scalar_one_or_none() or {}
+
+ if not isinstance(permissions, dict):
+ permissions = {}
+
+ return permissions.get(capability_name, False)
+
+
+def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
+ """
+ Calculate net amount from gross amount and VAT rate.
+
+ GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
+ is the absolute Source of Truth. Net is calculated back from gross.
+
+ Formula: net = gross / (1 + vat_rate / 100)
+
+ If vat_rate is 0 or None, net = gross (0% VAT content).
+ """
+ if vat_rate is None or vat_rate == 0:
+ return amount_gross
+ return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
+
+
@router.post("/", status_code=201)
async def create_expense(
expense: AssetCostCreate,
@@ -25,10 +119,15 @@ async def create_expense(
"""
Create a new expense (fuel, service, tax, insurance) for an asset.
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
+
+ **Role-Based Auto-Approval:**
+ - OWNER/ADMIN → status = APPROVED
+ - DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
- **Bidirectional Sync:**
+ **Smart Linking (Double-Entry Avoidance):**
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
- an AssetEvent is automatically created and linked to the cost via cost_id.
+ an AssetEvent is automatically created with MISSING_TECH_DATA status
+ and linked via linked_asset_event_id / linked_expense_id.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == expense.asset_id)
@@ -89,6 +188,48 @@ async def create_expense(
else:
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
+ # ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
+ # JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
+ user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
+
+ # Check if user has can_approve_expense capability → auto-approved
+ can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
+
+ # Determine expense status based on capabilities
+ if can_approve:
+ # User has can_approve_expense → auto-approved
+ expense_status = "APPROVED"
+ elif expense.category_id in FUEL_CATEGORY_IDS:
+ # Fuel costs are auto-approved even without can_approve_expense
+ expense_status = "APPROVED"
+ else:
+ # User lacks can_approve_expense → pending approval
+ expense_status = "PENDING_APPROVAL"
+
+ logger.info(
+ f"Capability-based approval for user {current_user.id} (role={user_role}) "
+ f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}"
+ )
+
+ # ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ──
+ # In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth.
+ amount_gross = expense.amount_gross
+ vat_rate = expense.vat_rate
+ amount_net = expense.amount_net
+
+ # Auto-calculate net from gross if vat_rate is provided but net is not
+ if vat_rate is not None and amount_net is None:
+ amount_net = _calculate_net_from_gross(amount_gross, vat_rate)
+ # Auto-calculate vat_rate if net is provided but vat_rate is not
+ elif amount_net is not None and vat_rate is None and amount_gross > 0:
+ # vat_rate = ((gross / net) - 1) * 100
+ vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100")
+ vat_rate = vat_rate.quantize(Decimal("0.01"))
+ # If only gross is provided (no net, no vat) → net = gross (0% VAT)
+ elif amount_net is None and vat_rate is None:
+ amount_net = amount_gross
+ vat_rate = Decimal("0")
+
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {}
if expense.mileage_at_cost is not None:
@@ -97,22 +238,25 @@ async def create_expense(
data["description"] = expense.description
try:
- # Create AssetCost instance
+ # Create AssetCost instance with new fields
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
- amount_net=expense.amount_net,
+ amount_net=amount_net,
+ amount_gross=amount_gross,
+ vat_rate=vat_rate,
currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
+ status=expense_status,
data=data
)
db.add(new_cost)
await db.flush() # Flush to get new_cost.id
- # ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
+ # ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type
@@ -121,6 +265,10 @@ async def create_expense(
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
mileage = expense.mileage_at_cost
+ # Determine event status based on capability
+ # can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA
+ event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA"
+
new_event = AssetEvent(
asset_id=expense.asset_id,
user_id=getattr(current_user, 'id', None),
@@ -129,15 +277,21 @@ async def create_expense(
odometer_reading=mileage,
description=description,
cost_id=new_cost.id,
+ linked_expense_id=new_cost.id, # Bidirectional link
+ status=event_status,
event_date=expense.date or datetime.now(timezone.utc),
)
db.add(new_event)
await db.flush() # Flush to get new_event.id
event_id = new_event.id
+ # Update the cost record with the link back to the event
+ new_cost.linked_asset_event_id = new_event.id
+
logger.info(
- f"Bidirectional sync: Auto-created AssetEvent {event_id} "
- f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
+ f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
+ f"for AssetCost {new_cost.id} (category_id={expense.category_id}). "
+ f"Bidirectional link established."
)
# Update Asset.current_mileage if mileage_at_cost is higher
@@ -152,8 +306,11 @@ async def create_expense(
"id": new_cost.id,
"asset_id": new_cost.asset_id,
"category_id": new_cost.category_id,
- "amount_net": new_cost.amount_net,
- "date": new_cost.date,
+ "amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
+ "amount_net": str(new_cost.amount_net),
+ "vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None,
+ "expense_status": new_cost.status,
+ "date": new_cost.date.isoformat() if new_cost.date else None,
"event_id": str(event_id) if event_id else None,
}
@@ -179,4 +336,4 @@ def _map_category_to_event_type(category_id: int) -> str:
17: "SERVICE", # MAINT_OIL
18: "REPAIR", # MAINT_BRAKES
}
- return category_event_map.get(category_id, "MAINTENANCE")
\ No newline at end of file
+ return category_event_map.get(category_id, "MAINTENANCE")
diff --git a/backend/app/api/v1/endpoints/finance_admin.py b/backend/app/api/v1/endpoints/finance_admin.py
index f7080c5..428f72b 100644
--- a/backend/app/api/v1/endpoints/finance_admin.py
+++ b/backend/app/api/v1/endpoints/finance_admin.py
@@ -24,7 +24,7 @@ async def check_finance_admin_access(
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
In our system, this translates to role being 'superadmin' or 'admin'.
"""
- if current_user.role not in [UserRole.superadmin, UserRole.admin]:
+ if current_user.role not in [UserRole.SUPERADMIN, UserRole.ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."
diff --git a/backend/app/api/v1/endpoints/organizations.py b/backend/app/api/v1/endpoints/organizations.py
index 55ca137..de2846e 100755
--- a/backend/app/api/v1/endpoints/organizations.py
+++ b/backend/app/api/v1/endpoints/organizations.py
@@ -14,8 +14,11 @@ from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field, ConfigDict, EmailStr
from app.db.session import get_db
-from app.api.deps import get_current_user
+from app.api.deps import get_current_user, RequireSystemCapability
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
+from app.schemas.subscription import SubscriptionTierResponse
+from app.services.billing_engine import upgrade_org_subscription
+from app.core.capabilities import Capability
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
from app.models.identity import User, OneTimePassword
from app.core.config import settings
@@ -219,7 +222,14 @@ async def get_my_organizations(
"subscription_plan": o.subscription_plan,
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
- "user_role": _get_user_role(o, current_user.id)
+ "user_role": _get_user_role(o, current_user.id),
+ # ── Cím adatok (Address fields) ──
+ "address_zip": o.address_zip,
+ "address_city": o.address_city,
+ "address_street_name": o.address_street_name,
+ "address_street_type": o.address_street_type,
+ "address_house_number": o.address_house_number,
+ "address_hrsz": o.address_hrsz,
}
for o in orgs
]
@@ -663,19 +673,7 @@ async def update_organization(
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
"""
# 1. Jogosultság ellenőrzése
- stmt_member = select(OrganizationMember).where(
- (OrganizationMember.organization_id == org_id) &
- (OrganizationMember.user_id == current_user.id) &
- (OrganizationMember.role.in_(["OWNER", "ADMIN"]))
- )
- member = (await db.execute(stmt_member)).scalar_one_or_none()
- if not member:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
- )
-
- # 2. Szervezet lekérése
+ # Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
stmt_org = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
@@ -688,7 +686,23 @@ async def update_organization(
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
)
- # 3. JSONB merge visual_settings esetén
+ # Jogosultság: OWNER/ADMIN a OrganizationMember táblában VAGY owner_id a Organization-ben
+ is_owner_by_field = (org.owner_id == current_user.id)
+
+ stmt_member = select(OrganizationMember).where(
+ (OrganizationMember.organization_id == org_id) &
+ (OrganizationMember.user_id == current_user.id) &
+ (OrganizationMember.role.in_(["OWNER", "ADMIN"]))
+ )
+ member = (await db.execute(stmt_member)).scalar_one_or_none()
+
+ if not member and not is_owner_by_field:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
+ )
+
+ # 2. JSONB merge visual_settings esetén
update_dict = update_data.model_dump(exclude_unset=True)
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
@@ -717,6 +731,63 @@ async def update_organization(
return OrganizationResponse.model_validate(org)
+# ── P0 FEATURE: SUBSCRIPTION & PACKAGE ASSIGNMENT BRIDGE ──
+
+class SubscriptionAssignIn(BaseModel):
+ """PUT body a szervezet előfizetési csomagjának beállításához."""
+ tier_id: int = Field(..., description="SubscriptionTier ID a system.subscription_tiers táblából")
+
+
+@router.put("/{org_id}/subscription", response_model=dict)
+async def assign_organization_subscription(
+ org_id: int,
+ body: SubscriptionAssignIn,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ _: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
+):
+ """
+ Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
+
+ P0 Feature: This endpoint assigns a subscription_tier to an organization.
+ It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
+
+ The endpoint:
+ 1. Validates the tier exists in system.subscription_tiers
+ 2. Sets subscription_tier_id on the Organization record
+ 3. Updates subscription_plan and base_asset_limit from tier rules
+ 4. Creates/updates a finance.org_subscriptions audit record
+ """
+ try:
+ result = await upgrade_org_subscription(
+ db=db,
+ org_id=org_id,
+ tier_id=body.tier_id,
+ actor_user_id=current_user.id
+ )
+ await db.commit()
+
+ await security_service.log_event(
+ db, user_id=current_user.id, action="ORG_SUBSCRIPTION_ASSIGNED",
+ severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
+ new_data={"tier_id": body.tier_id, "tier_name": result.get("tier_name")}
+ )
+
+ return result
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=str(e)
+ )
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Subscription assignment failed: org_id={org_id}, tier_id={body.tier_id}: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
+ )
+
+
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
class JoinRequestIn(BaseModel):
diff --git a/backend/app/api/v1/endpoints/security.py b/backend/app/api/v1/endpoints/security.py
index c35a68f..873a320 100644
--- a/backend/app/api/v1/endpoints/security.py
+++ b/backend/app/api/v1/endpoints/security.py
@@ -36,7 +36,7 @@ async def request_action(
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
"""
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
- if current_user.role not in [UserRole.admin, UserRole.superadmin]:
+ if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
@@ -69,7 +69,7 @@ async def list_pending_actions(
Admin és superadmin látja az összes függőben lévő műveletet.
Egyéb felhasználók csak a sajátjaikat láthatják.
"""
- if current_user.role in [UserRole.admin, UserRole.superadmin]:
+ if current_user.role in [UserRole.ADMIN, UserRole.SUPERADMIN]:
user_id = None
else:
user_id = current_user.id
@@ -89,7 +89,7 @@ async def approve_action(
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
"""
- if current_user.role not in [UserRole.admin, UserRole.superadmin]:
+ if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
@@ -122,7 +122,7 @@ async def reject_action(
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
"""
- if current_user.role not in [UserRole.admin, UserRole.superadmin]:
+ if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
@@ -164,7 +164,7 @@ async def get_action(
if not action:
raise HTTPException(status_code=404, detail="Művelet nem található.")
- if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
+ if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN] and action.requester_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultságod ehhez a művelethez."
diff --git a/backend/app/api/v1/endpoints/system_parameters.py b/backend/app/api/v1/endpoints/system_parameters.py
index c71f27e..f495956 100644
--- a/backend/app/api/v1/endpoints/system_parameters.py
+++ b/backend/app/api/v1/endpoints/system_parameters.py
@@ -122,7 +122,7 @@ async def update_system_parameter(
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
"""
# Jogosultság ellenőrzése
- if current_user.role not in (UserRole.superadmin, UserRole.admin):
+ if current_user.role not in (UserRole.SUPERADMIN, UserRole.ADMIN):
raise HTTPException(
status_code=403,
detail="Insufficient permissions. Only superadmin or admin can update system parameters."
diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py
index 6d30f24..6c08550 100755
--- a/backend/app/api/v1/endpoints/users.py
+++ b/backend/app/api/v1/endpoints/users.py
@@ -41,13 +41,15 @@ class NetworkResponse(BaseModel):
level3: List[NetworkMemberL2L3]
-def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
+def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
"""
Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe.
+ RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
"""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
+ from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID
if active_org_id is None:
@@ -101,6 +103,17 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
first_name = person.first_name if person else ""
last_name = person.last_name if person else ""
+ # ── RBAC Phase 3: Resolve system capabilities ──
+ role_key = user.role.value if hasattr(user.role, 'value') else str(user.role)
+ system_capabilities = get_capabilities_for_role(role_key)
+
+ # ── RBAC Phase 3: Resolve org capabilities ──
+ org_capabilities: Dict[str, Dict[str, bool]] = {}
+ if db is not None:
+ # We need to run this synchronously since _build_user_response is not async
+ # The async version is handled in read_users_me directly
+ pass
+
return {
"id": user.id,
"email": user.email,
@@ -109,7 +122,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"is_active": user.is_active,
"region_code": user.region_code,
"person_id": user.person_id,
- "role": user.role.value if hasattr(user.role, 'value') else str(user.role),
+ "role": role_key,
"subscription_plan": user.subscription_plan,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
@@ -118,6 +131,10 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"preferred_language": user.preferred_language or "hu",
"person": person_data,
"is_last_admin": False, # Default, will be overridden in read_users_me
+ # RBAC Phase 3: Rendszerszintű képességek
+ "system_capabilities": system_capabilities,
+ # RBAC Phase 3: Szervezeti képességek (feltöltve a read_users_me végpontban)
+ "org_capabilities": {},
}
@@ -126,9 +143,11 @@ async def read_users_me(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- """Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
+ """Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.
+ RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba."""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
+ from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID
active_org_id = None
@@ -178,8 +197,53 @@ async def read_users_me(
# Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
+
+ # ── RBAC Phase 3: Build base response ──
response_data = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin
+
+ # ── RBAC Phase 3: Resolve org_capabilities ──
+ # Get all organizations the user is a member of
+ org_memberships_stmt = select(
+ OrganizationMember.organization_id,
+ OrganizationMember.role
+ ).where(
+ OrganizationMember.user_id == current_user.id,
+ OrganizationMember.status == "active"
+ )
+ org_memberships_result = await db.execute(org_memberships_stmt)
+ org_memberships = org_memberships_result.all()
+
+ org_capabilities: Dict[str, Dict[str, bool]] = {}
+ for membership in org_memberships:
+ org_id = str(membership.organization_id)
+ org_role_name = membership.role
+
+ # Try to get permissions from OrgRole table first
+ role_stmt = select(OrgRole.permissions).where(
+ OrgRole.name_key == org_role_name,
+ OrgRole.is_active == True
+ ).limit(1)
+ role_result = await db.execute(role_stmt)
+ permissions = role_result.scalar_one_or_none()
+
+ if not permissions:
+ # Fallback: use OrganizationMember.permissions
+ member_perm_stmt = select(OrganizationMember.permissions).where(
+ OrganizationMember.user_id == current_user.id,
+ OrganizationMember.organization_id == membership.organization_id,
+ OrganizationMember.status == "active"
+ ).limit(1)
+ member_perm_result = await db.execute(member_perm_stmt)
+ permissions = member_perm_result.scalar_one_or_none() or {}
+
+ if not isinstance(permissions, dict):
+ permissions = {}
+
+ org_capabilities[org_id] = permissions
+
+ response_data["org_capabilities"] = org_capabilities
+
return UserResponse.model_validate(response_data)
diff --git a/backend/app/core/capabilities.py b/backend/app/core/capabilities.py
new file mode 100644
index 0000000..649c6d4
--- /dev/null
+++ b/backend/app/core/capabilities.py
@@ -0,0 +1,295 @@
+# /opt/docker/dev/service_finder/backend/app/core/capabilities.py
+"""
+🎯 SYSTEM CAPABILITIES MATRIX (RBAC Phase 1)
+
+Fine-grained, capability-driven permission matrix for system-level roles.
+Each role maps to a set of boolean capabilities that define what the role
+can do within the platform.
+
+Architecture:
+- SUPERADMIN: Full system access (rank 100)
+- ADMIN: Full admin panel access (rank 90)
+- MODERATOR: Content moderation capabilities (rank 75)
+- SALES_REP: Sales & discount management (rank 30)
+- SERVICE_MGR: Service provider & taxonomy management (rank 40)
+- USER: Basic platform user (rank 10)
+"""
+
+from typing import Dict, Set
+
+# ──────────────────────────────────────────────
+# CAPABILITY KEYS (for type-safe references)
+# ──────────────────────────────────────────────
+
+class Capability:
+ """Central registry of all capability keys."""
+
+ # ── Admin & System ──
+ CAN_MANAGE_USERS = "can_manage_users"
+ CAN_MANAGE_ROLES = "can_manage_roles"
+ CAN_VIEW_SYSTEM_LOGS = "can_view_system_logs"
+ CAN_MANAGE_SYSTEM_CONFIG = "can_manage_system_config"
+ CAN_MANAGE_TRANSLATIONS = "can_manage_translations"
+
+ # ── Moderation ──
+ CAN_MODERATE_CONTENT = "can_moderate_content"
+ CAN_VIEW_REPORTS = "can_view_reports"
+ CAN_SUSPEND_USERS = "can_suspend_users"
+ CAN_APPROVE_PROVIDER = "can_approve_provider"
+
+ # ── Sales & Marketing ──
+ CAN_VIEW_ORG_STATS = "can_view_org_stats"
+ CAN_MANAGE_DISCOUNTS = "can_manage_discounts"
+ CAN_MANAGE_PROMOTIONS = "can_manage_promotions"
+ CAN_VIEW_SALES_PIPELINE = "can_view_sales_pipeline"
+
+ # ── Service Management ──
+ CAN_EDIT_MASTER_TAXONOMY = "can_edit_master_taxonomy"
+ CAN_MANAGE_SERVICE_CATEGORIES = "can_manage_service_categories"
+ CAN_VERIFY_SERVICE_PROVIDERS = "can_verify_service_providers"
+ CAN_MANAGE_EXPERTISE_TAGS = "can_manage_expertise_tags"
+
+ # ── Vehicle & Catalog ──
+ CAN_MANAGE_VEHICLE_CATALOG = "can_manage_vehicle_catalog"
+ CAN_APPROVE_VEHICLE_DEFINITIONS = "can_approve_vehicle_definitions"
+ CAN_VIEW_TECHNICAL_SPECS = "can_view_technical_specs"
+
+ # ── Finance ──
+ CAN_VIEW_FINANCIAL_REPORTS = "can_view_financial_reports"
+ CAN_MANAGE_BILLING = "can_manage_billing"
+ CAN_ISSUE_REFUNDS = "can_issue_refunds"
+ CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"
+
+ # ── Basic User ──
+ CAN_MANAGE_OWN_PROFILE = "can_manage_own_profile"
+ CAN_MANAGE_OWN_VEHICLES = "can_manage_own_vehicles"
+ CAN_CREATE_SERVICE_REQUEST = "can_create_service_request"
+ CAN_VIEW_PUBLIC_CATALOG = "can_view_public_catalog"
+
+
+# ──────────────────────────────────────────────
+# SYSTEM CAPABILITIES MATRIX
+# ──────────────────────────────────────────────
+
+SYSTEM_CAPABILITIES_MATRIX: Dict[str, Dict[str, bool]] = {
+ # ── SUPERADMIN: Full system access ──
+ "SUPERADMIN": {
+ # Admin & System
+ Capability.CAN_MANAGE_USERS: True,
+ Capability.CAN_MANAGE_ROLES: True,
+ Capability.CAN_VIEW_SYSTEM_LOGS: True,
+ Capability.CAN_MANAGE_SYSTEM_CONFIG: True,
+ Capability.CAN_MANAGE_TRANSLATIONS: True,
+ # Moderation
+ Capability.CAN_MODERATE_CONTENT: True,
+ Capability.CAN_VIEW_REPORTS: True,
+ Capability.CAN_SUSPEND_USERS: True,
+ Capability.CAN_APPROVE_PROVIDER: True,
+ # Sales
+ Capability.CAN_VIEW_ORG_STATS: True,
+ Capability.CAN_MANAGE_DISCOUNTS: True,
+ Capability.CAN_MANAGE_PROMOTIONS: True,
+ Capability.CAN_VIEW_SALES_PIPELINE: True,
+ # Service Management
+ Capability.CAN_EDIT_MASTER_TAXONOMY: True,
+ Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
+ Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
+ Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
+ # Vehicle & Catalog
+ Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
+ Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
+ Capability.CAN_VIEW_TECHNICAL_SPECS: True,
+ # Finance
+ Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
+ Capability.CAN_MANAGE_BILLING: True,
+ Capability.CAN_ISSUE_REFUNDS: True,
+ Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
+ # Basic User
+ Capability.CAN_MANAGE_OWN_PROFILE: True,
+ Capability.CAN_MANAGE_OWN_VEHICLES: True,
+ Capability.CAN_CREATE_SERVICE_REQUEST: True,
+ Capability.CAN_VIEW_PUBLIC_CATALOG: True,
+ },
+
+ # ── ADMIN: Full admin panel access ──
+ "ADMIN": {
+ Capability.CAN_MANAGE_USERS: True,
+ Capability.CAN_MANAGE_ROLES: False,
+ Capability.CAN_VIEW_SYSTEM_LOGS: True,
+ Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
+ Capability.CAN_MANAGE_TRANSLATIONS: True,
+ Capability.CAN_MODERATE_CONTENT: True,
+ Capability.CAN_VIEW_REPORTS: True,
+ Capability.CAN_SUSPEND_USERS: True,
+ Capability.CAN_APPROVE_PROVIDER: True,
+ Capability.CAN_VIEW_ORG_STATS: True,
+ Capability.CAN_MANAGE_DISCOUNTS: True,
+ Capability.CAN_MANAGE_PROMOTIONS: True,
+ Capability.CAN_VIEW_SALES_PIPELINE: True,
+ Capability.CAN_EDIT_MASTER_TAXONOMY: True,
+ Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
+ Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
+ Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
+ Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
+ Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
+ Capability.CAN_VIEW_TECHNICAL_SPECS: True,
+ Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
+ Capability.CAN_MANAGE_BILLING: False,
+ Capability.CAN_ISSUE_REFUNDS: False,
+ Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
+ Capability.CAN_MANAGE_OWN_PROFILE: True,
+ Capability.CAN_MANAGE_OWN_VEHICLES: True,
+ Capability.CAN_CREATE_SERVICE_REQUEST: True,
+ Capability.CAN_VIEW_PUBLIC_CATALOG: True,
+ },
+
+ # ── MODERATOR: Content moderation ──
+ "MODERATOR": {
+ Capability.CAN_MANAGE_USERS: False,
+ Capability.CAN_MANAGE_ROLES: False,
+ Capability.CAN_VIEW_SYSTEM_LOGS: False,
+ Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
+ Capability.CAN_MANAGE_TRANSLATIONS: True,
+ Capability.CAN_MODERATE_CONTENT: True,
+ Capability.CAN_VIEW_REPORTS: True,
+ Capability.CAN_SUSPEND_USERS: False,
+ Capability.CAN_APPROVE_PROVIDER: True,
+ Capability.CAN_VIEW_ORG_STATS: False,
+ Capability.CAN_MANAGE_DISCOUNTS: False,
+ Capability.CAN_MANAGE_PROMOTIONS: False,
+ Capability.CAN_VIEW_SALES_PIPELINE: False,
+ Capability.CAN_EDIT_MASTER_TAXONOMY: False,
+ Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
+ Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
+ Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
+ Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
+ Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
+ Capability.CAN_VIEW_TECHNICAL_SPECS: True,
+ Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
+ Capability.CAN_MANAGE_BILLING: False,
+ Capability.CAN_ISSUE_REFUNDS: False,
+ Capability.CAN_MANAGE_OWN_PROFILE: True,
+ Capability.CAN_MANAGE_OWN_VEHICLES: True,
+ Capability.CAN_CREATE_SERVICE_REQUEST: True,
+ Capability.CAN_VIEW_PUBLIC_CATALOG: True,
+ },
+
+ # ── SALES_REP: Sales & discount management ──
+ "SALES_REP": {
+ Capability.CAN_MANAGE_USERS: False,
+ Capability.CAN_MANAGE_ROLES: False,
+ Capability.CAN_VIEW_SYSTEM_LOGS: False,
+ Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
+ Capability.CAN_MANAGE_TRANSLATIONS: False,
+ Capability.CAN_MODERATE_CONTENT: False,
+ Capability.CAN_VIEW_REPORTS: False,
+ Capability.CAN_SUSPEND_USERS: False,
+ Capability.CAN_APPROVE_PROVIDER: False,
+ Capability.CAN_VIEW_ORG_STATS: True,
+ Capability.CAN_MANAGE_DISCOUNTS: True,
+ Capability.CAN_MANAGE_PROMOTIONS: True,
+ Capability.CAN_VIEW_SALES_PIPELINE: True,
+ Capability.CAN_EDIT_MASTER_TAXONOMY: False,
+ Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
+ Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
+ Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
+ Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
+ Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
+ Capability.CAN_VIEW_TECHNICAL_SPECS: False,
+ Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
+ Capability.CAN_MANAGE_BILLING: False,
+ Capability.CAN_ISSUE_REFUNDS: False,
+ Capability.CAN_MANAGE_OWN_PROFILE: True,
+ Capability.CAN_MANAGE_OWN_VEHICLES: True,
+ Capability.CAN_CREATE_SERVICE_REQUEST: True,
+ Capability.CAN_VIEW_PUBLIC_CATALOG: True,
+ },
+
+ # ── SERVICE_MGR: Service provider & taxonomy management ──
+ "SERVICE_MGR": {
+ Capability.CAN_MANAGE_USERS: False,
+ Capability.CAN_MANAGE_ROLES: False,
+ Capability.CAN_VIEW_SYSTEM_LOGS: False,
+ Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
+ Capability.CAN_MANAGE_TRANSLATIONS: False,
+ Capability.CAN_MODERATE_CONTENT: False,
+ Capability.CAN_VIEW_REPORTS: False,
+ Capability.CAN_SUSPEND_USERS: False,
+ Capability.CAN_APPROVE_PROVIDER: True,
+ Capability.CAN_VIEW_ORG_STATS: False,
+ Capability.CAN_MANAGE_DISCOUNTS: False,
+ Capability.CAN_MANAGE_PROMOTIONS: False,
+ Capability.CAN_VIEW_SALES_PIPELINE: False,
+ Capability.CAN_EDIT_MASTER_TAXONOMY: True,
+ Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
+ Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
+ Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
+ Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
+ Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
+ Capability.CAN_VIEW_TECHNICAL_SPECS: True,
+ Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
+ Capability.CAN_MANAGE_BILLING: False,
+ Capability.CAN_ISSUE_REFUNDS: False,
+ Capability.CAN_MANAGE_OWN_PROFILE: True,
+ Capability.CAN_MANAGE_OWN_VEHICLES: True,
+ Capability.CAN_CREATE_SERVICE_REQUEST: True,
+ Capability.CAN_VIEW_PUBLIC_CATALOG: True,
+ },
+
+ # ── USER: Basic platform user ──
+ "USER": {
+ Capability.CAN_MANAGE_USERS: False,
+ Capability.CAN_MANAGE_ROLES: False,
+ Capability.CAN_VIEW_SYSTEM_LOGS: False,
+ Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
+ Capability.CAN_MANAGE_TRANSLATIONS: False,
+ Capability.CAN_MODERATE_CONTENT: False,
+ Capability.CAN_VIEW_REPORTS: False,
+ Capability.CAN_SUSPEND_USERS: False,
+ Capability.CAN_APPROVE_PROVIDER: False,
+ Capability.CAN_VIEW_ORG_STATS: False,
+ Capability.CAN_MANAGE_DISCOUNTS: False,
+ Capability.CAN_MANAGE_PROMOTIONS: False,
+ Capability.CAN_VIEW_SALES_PIPELINE: False,
+ Capability.CAN_EDIT_MASTER_TAXONOMY: False,
+ Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
+ Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
+ Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
+ Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
+ Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
+ Capability.CAN_VIEW_TECHNICAL_SPECS: False,
+ Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
+ Capability.CAN_MANAGE_BILLING: False,
+ Capability.CAN_ISSUE_REFUNDS: False,
+ Capability.CAN_MANAGE_OWN_PROFILE: True,
+ Capability.CAN_MANAGE_OWN_VEHICLES: True,
+ Capability.CAN_CREATE_SERVICE_REQUEST: True,
+ Capability.CAN_VIEW_PUBLIC_CATALOG: True,
+ },
+}
+
+
+def get_capabilities_for_role(role_key: str) -> Dict[str, bool]:
+ """Return the capability dict for a given role key.
+
+ Args:
+ role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN', 'USER')
+
+ Returns:
+ Dict of capability -> bool, or empty dict if role not found.
+ """
+ return SYSTEM_CAPABILITIES_MATRIX.get(role_key.upper(), {})
+
+
+def role_has_capability(role_key: str, capability: str) -> bool:
+ """Check if a role has a specific capability.
+
+ Args:
+ role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN')
+ capability: The capability key (e.g. 'can_manage_users')
+
+ Returns:
+ True if the role has the capability, False otherwise.
+ """
+ caps = get_capabilities_for_role(role_key)
+ return caps.get(capability, False)
diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py
index 8550594..6a5da8d 100755
--- a/backend/app/core/rbac.py
+++ b/backend/app/core/rbac.py
@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
from fastapi import HTTPException, Depends, status
from app.api.deps import get_current_user
-from app.models.identity import User
+from app.models.identity import User, UserRole
from app.core.config import settings
class RBAC:
@@ -11,11 +11,11 @@ class RBAC:
async def __call__(self, current_user: User = Depends(get_current_user)):
# 1. Superadmin mindent visz (Rank 100)
- if current_user.role == "superadmin":
+ if current_user.role == UserRole.SUPERADMIN:
return True
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
- role_key = current_user.role.value.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
+ role_key = current_user.role.value # A DEFAULT_RANK_MAP már nagybetűs kulcsokat használ
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
if user_rank < self.min_rank:
raise HTTPException(
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index a8bb0a7..081678f 100755
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -51,23 +51,14 @@ def generate_secure_slug(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
-# Teljesen a margón van, így globális konstans lesz!
-# Lefedi a UserRole enum MIND a 10 értékét
+# RBAC Phase 1: Tisztított rangtábla a rendszerszintű szerepkörökhöz.
+# A szervezeti szerepkörök (OrgUserRole) külön kezelendők.
DEFAULT_RANK_MAP = {
"SUPERADMIN": 100,
"ADMIN": 90,
- "REGION_ADMIN": 85,
- "COUNTRY_ADMIN": 80,
"MODERATOR": 75,
- "AUDITOR": 70,
- "ORGANIZATION_OWNER": 65,
- "ORGANIZATION_MANAGER": 60,
- "ORGANIZATION_MEMBER": 50,
- "SERVICE_PROVIDER": 40,
- "SALES_AGENT": 30,
- "FLEET_MANAGER": 25,
- "PREMIUM_USER": 20,
+ "SERVICE_MGR": 40,
+ "SALES_REP": 30,
"USER": 10,
- "DRIVER": 5,
"GUEST": 0
}
\ No newline at end of file
diff --git a/backend/app/models/identity/identity.py b/backend/app/models/identity/identity.py
index 86c74ae..5f6d04e 100644
--- a/backend/app/models/identity/identity.py
+++ b/backend/app/models/identity/identity.py
@@ -22,16 +22,17 @@ if TYPE_CHECKING:
from ..marketplace.service_request import ServiceRequest
class UserRole(str, enum.Enum):
- superadmin = "superadmin"
- admin = "admin"
- region_admin = "region_admin"
- country_admin = "country_admin"
- moderator = "moderator"
- sales_agent = "sales_agent"
- user = "user"
- service_owner = "service_owner"
- fleet_manager = "fleet_manager"
- driver = "driver"
+ """Rendszerszintű (System-level) szerepkörök.
+
+ Csak a globális platform szerepkörök maradnak itt.
+ Szervezeti szerepkörök: lásd OrgUserRole a marketplace/organization.py-ban.
+ """
+ SUPERADMIN = "SUPERADMIN"
+ ADMIN = "ADMIN"
+ MODERATOR = "MODERATOR"
+ SALES_REP = "SALES_REP"
+ SERVICE_MGR = "SERVICE_MGR"
+ USER = "USER"
class Person(Base):
"""
@@ -133,7 +134,7 @@ class User(Base):
role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"),
- default=UserRole.user
+ default=UserRole.USER
)
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
diff --git a/backend/app/models/marketplace/organization.py b/backend/app/models/marketplace/organization.py
index f7bdec8..52efde9 100755
--- a/backend/app/models/marketplace/organization.py
+++ b/backend/app/models/marketplace/organization.py
@@ -25,17 +25,25 @@ class OrgType(str, enum.Enum):
business = "business"
class OrgUserRole(str, enum.Enum):
+ """Szervezeti (Organization-level) szerepkörök.
+
+ Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
+ Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
+ """
OWNER = "OWNER"
ADMIN = "ADMIN"
- MANAGER = "MANAGER"
- MEMBER = "MEMBER"
- AGENT = "AGENT"
+ ACCOUNTANT = "ACCOUNTANT"
+ DRIVER = "DRIVER"
+ VIEWER = "VIEWER"
class OrgRole(Base):
"""
- Dinamikus szerepkör modell (RBAC).
+ Dinamikus szerepkör modell (RBAC Phase 2).
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
- Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
+ Alapértelmezett szerepkörök: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER.
+
+ permissions: JSONB oszlop a capability-alapú jogosultságok tárolására.
+ Példa: {"can_add_expense": True, "can_approve_expense": False}
"""
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
@@ -48,6 +56,16 @@ class OrgRole(Base):
priority: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ # ── RBAC Phase 2: JSONB permissions column ──
+ # Stores capability flags for this role, e.g.:
+ # {"can_add_expense": True, "can_approve_expense": True, "can_view_financials": True}
+ permissions: Mapped[dict] = mapped_column(
+ JSONB,
+ nullable=False,
+ default=dict,
+ server_default=text("'{}'::jsonb")
+ )
class Organization(Base):
"""
@@ -119,9 +137,18 @@ class Organization(Base):
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ # ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
+
+ # P0 FEATURE: Subscription Tier FK — connects this org to a system.subscription_tiers record
+ subscription_tier_id: Mapped[Optional[int]] = mapped_column(
+ Integer,
+ ForeignKey("system.subscription_tiers.id"),
+ nullable=True,
+ index=True
+ )
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
@@ -215,9 +242,12 @@ class OrganizationMember(Base):
# Meghívó lejárati ideje
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
- # Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
+ # Dinamikus szerepkör - PostgreSQL ENUM típus (fleet.orguserrole)
+ # A PG_ENUM használata biztosítja, hogy az SQLAlchemy megfelelő típuskényszerítést
+ # (type cast) alkalmazzon a lekérdezéseknél, elkerülve az
+ # "operator does not exist: fleet.orguserrole = character varying" hibát.
role: Mapped[str] = mapped_column(
- String(50),
+ PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
default="MEMBER",
server_default=text("'MEMBER'")
)
diff --git a/backend/app/models/vehicle/asset.py b/backend/app/models/vehicle/asset.py
index d04c3be..7e5af36 100644
--- a/backend/app/models/vehicle/asset.py
+++ b/backend/app/models/vehicle/asset.py
@@ -247,6 +247,13 @@ class AssetFinancials(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
+class AssetCostStatusEnum(str, enum.Enum):
+ """Költség státuszok a jóváhagyási munkafolyamathoz."""
+ DRAFT = "DRAFT"
+ PENDING_APPROVAL = "PENDING_APPROVAL"
+ APPROVED = "APPROVED"
+
+
class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. """
__tablename__ = "asset_costs"
@@ -257,15 +264,35 @@ class AssetCost(Base):
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
+ amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
+ vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
+ # Státusz a jóváhagyási munkafolyamathoz
+ status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
+
+ # Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
+ linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("vehicle.asset_events.id"),
+ nullable=True,
+ index=True
+ )
+
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
category: Mapped["CostCategory"] = relationship("CostCategory")
+
+ # Kapcsolat a hivatkozott eseményhez
+ linked_event: Mapped[Optional["AssetEvent"]] = relationship(
+ "AssetEvent",
+ foreign_keys=[linked_asset_event_id],
+ post_update=True
+ )
class VehicleLogbook(Base):
@@ -396,6 +423,13 @@ class AssetEventTypeEnum(str, enum.Enum):
RECALL = "RECALL" # Visszahívás
+class AssetEventStatusEnum(str, enum.Enum):
+ """Esemény státuszok a feldolgozási munkafolyamathoz."""
+ DRAFT = "DRAFT"
+ MISSING_TECH_DATA = "MISSING_TECH_DATA"
+ COMPLETED = "COMPLETED"
+
+
class AssetEvent(Base):
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
__tablename__ = "asset_events"
@@ -411,6 +445,17 @@ class AssetEvent(Base):
description: Mapped[Optional[str]] = mapped_column(Text)
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
+ # Státusz a feldolgozási munkafolyamathoz
+ status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
+
+ # Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése)
+ linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("vehicle.asset_costs.id"),
+ nullable=True,
+ index=True
+ )
+
event_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -419,7 +464,15 @@ class AssetEvent(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
user: Mapped[Optional["User"]] = relationship("User")
organization: Mapped[Optional["Organization"]] = relationship("Organization")
- cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
+ cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
+
+ # Kapcsolat a hivatkozott költséghez
+ linked_expense: Mapped[Optional["AssetCost"]] = relationship(
+ "AssetCost",
+ foreign_keys=[linked_expense_id],
+ post_update=True,
+ primaryjoin="AssetEvent.linked_expense_id == AssetCost.id"
+ )
class ExchangeRate(Base):
diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py
index aee4e5e..daaa5fb 100755
--- a/backend/app/schemas/admin.py
+++ b/backend/app/schemas/admin.py
@@ -20,7 +20,7 @@ router = APIRouter()
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
- if current_user.role not in [UserRole.admin, UserRole.superadmin]:
+ if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges a művelethez!"
diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py
index 5e3eb72..2b1034a 100755
--- a/backend/app/schemas/asset.py
+++ b/backend/app/schemas/asset.py
@@ -386,6 +386,8 @@ class AssetEventCreate(BaseModel):
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
+ status: Optional[str] = Field(None, description="Event status (DRAFT, MISSING_TECH_DATA, COMPLETED) - auto-set by backend")
+ linked_expense_id: Optional[UUID] = Field(None, description="Associated AssetCost ID (bidirectional link)")
model_config = ConfigDict(from_attributes=True)
@@ -402,6 +404,8 @@ class AssetEventResponse(BaseModel):
cost_id: Optional[UUID] = None
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
+ status: str = "DRAFT"
+ linked_expense_id: Optional[UUID] = None
event_date: datetime
created_at: datetime
updated_at: Optional[datetime] = None
@@ -410,7 +414,10 @@ class AssetEventResponse(BaseModel):
@classmethod
def model_validate(cls, obj, **kwargs):
- """Override to resolve cost_amount and currency from the related AssetCost."""
+ """Override to resolve cost_amount and currency from the related AssetCost.
+
+ GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
+ """
data = {}
if hasattr(obj, '__dict__'):
# Copy ORM attributes
@@ -422,7 +429,10 @@ class AssetEventResponse(BaseModel):
cost = getattr(obj, 'cost', None)
if cost is not None:
if data.get('cost_amount') is None:
- data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
+ # GROSS-FIRST: prefer amount_gross as the source of truth
+ data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
+ float(cost.amount_net) if cost.amount_net is not None else None
+ )
if data.get('currency') is None:
data['currency'] = cost.currency
return cls(**data)
\ No newline at end of file
diff --git a/backend/app/schemas/asset_cost.py b/backend/app/schemas/asset_cost.py
index 759b8a2..9be283b 100755
--- a/backend/app/schemas/asset_cost.py
+++ b/backend/app/schemas/asset_cost.py
@@ -1,13 +1,38 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Optional, Dict, Any
from datetime import datetime
from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
- """Base schema — matches the AssetCost SQLAlchemy model fields."""
- amount_net: Decimal
+ """Base schema — matches the AssetCost SQLAlchemy model fields.
+
+ GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary/required field.
+ In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth.
+ amount_net is optional — if only gross is provided, net = gross (0% VAT).
+ If vat_rate is also provided, net is calculated back from gross.
+ """
+ amount_gross: Optional[Decimal] = Field(None, description="Bruttó összeg (ÁFA-val) — ELSŐDLEGES forrás, de lehet NULL legacy rekordoknál")
+ amount_net: Optional[Decimal] = Field(None, description="Nettó összeg (ÁFA nélkül) — OPCIONÁLIS, ha nincs megadva, bruttóból számolva")
+
+ @model_validator(mode='before')
+ @classmethod
+ def safety_net_gross_from_net(cls, data: Any) -> Any:
+ """SAFETY NET (Graceful Degradation): If the incoming payload contains
+ amount_net but amount_gross is missing/None, automatically copy amount_net
+ to amount_gross. This protects against legacy/cached browsers that still
+ send amount_net in their payloads.
+
+ GROSS-FIRST (Masterbook 2.0.1): The backend is strictly Gross-first.
+ """
+ if isinstance(data, dict):
+ has_net = data.get('amount_net') is not None
+ has_gross = data.get('amount_gross') is not None
+ if has_net and not has_gross:
+ data['amount_gross'] = data['amount_net']
+ return data
+ vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)")
currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now)
invoice_number: Optional[str] = None
@@ -16,15 +41,45 @@ class AssetCostBase(BaseModel):
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
description: Optional[str] = None # Stored inside data JSONB
+ @model_validator(mode='after')
+ def validate_gross_first(self):
+ """Gross-First validation: ensure amount_gross is the primary source.
+
+ If amount_net is not provided but vat_rate is, calculate net from gross.
+ If neither amount_net nor vat_rate is provided, net = gross (0% VAT).
+ If amount_gross is None (legacy data), default to 0 to prevent 500 errors.
+ """
+ gross = self.amount_gross
+ net = self.amount_net
+ vat = self.vat_rate
+
+ # Fault tolerance: if gross is None (legacy NULL), default to 0
+ if gross is None:
+ self.amount_gross = Decimal("0")
+ gross = self.amount_gross
+
+ if net is None and vat is not None and vat > 0:
+ # Net will be calculated in the service layer from gross & vat
+ pass
+ elif net is None:
+ # No net and no vat → net = gross (0% VAT content)
+ self.amount_net = gross
+ self.vat_rate = Decimal("0")
+ return self
+
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
+ status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
+ linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID
asset_id: UUID
organization_id: int
+ status: str = "DRAFT"
+ linked_asset_event_id: Optional[UUID] = None
# Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship
diff --git a/backend/app/schemas/asset_event.py b/backend/app/schemas/asset_event.py
index 5730177..76a1aa9 100644
--- a/backend/app/schemas/asset_event.py
+++ b/backend/app/schemas/asset_event.py
@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_event.py
-from pydantic import BaseModel, ConfigDict, Field, validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
@@ -17,6 +17,12 @@ class AssetEventTypeEnum(str, Enum):
RECALL = "RECALL" # Visszahívás
OTHER = "OTHER" # Egyéb
+class AssetEventStatusEnum(str, Enum):
+ """Esemény státuszok a feldolgozási munkafolyamathoz."""
+ DRAFT = "DRAFT"
+ MISSING_TECH_DATA = "MISSING_TECH_DATA"
+ COMPLETED = "COMPLETED"
+
class AssetEventCreate(BaseModel):
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa")
@@ -24,9 +30,10 @@ class AssetEventCreate(BaseModel):
description: str = Field(..., min_length=1, max_length=1000, description="Esemény leírása")
event_date: Optional[datetime] = Field(None, description="Esemény dátuma (alapértelmezett: most)")
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
-
- # RBAC ellenőrzés: csak a tulajdonos vagy operátor szervezet adhat hozzá eseményt
- # Ezt a service rétegben validáljuk, nem a sémában
+ cost_amount: Optional[float] = Field(None, gt=0, description="Költség összege. Ha > 0, automatikusan létrejön egy AssetCost rekord.")
+ currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Valutakód (pl. HUF, EUR)")
+ status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz (auto-set by backend)")
+ linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID (kétirányú hivatkozáshoz)")
model_config = ConfigDict(from_attributes=True)
@@ -41,6 +48,12 @@ class AssetEventResponse(BaseModel):
odometer_reading: Optional[int] = None
description: Optional[str] = None
cost_id: Optional[UUID] = None
+ cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
+ currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
+
+ # Új mezők
+ status: str = "DRAFT"
+ linked_expense_id: Optional[UUID] = None
event_date: datetime
created_at: datetime
@@ -52,6 +65,31 @@ class AssetEventResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
+ @classmethod
+ def model_validate(cls, obj, **kwargs):
+ """Override to resolve cost_amount and currency from the related AssetCost.
+
+ GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
+ """
+ data = {}
+ if hasattr(obj, '__dict__'):
+ # Copy ORM attributes
+ for field in cls.model_fields:
+ if hasattr(obj, field):
+ data[field] = getattr(obj, field)
+
+ # Resolve cost_amount and currency from the cost relationship
+ cost = getattr(obj, 'cost', None)
+ if cost is not None:
+ if data.get('cost_amount') is None:
+ # GROSS-FIRST: prefer amount_gross as the source of truth
+ data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
+ float(cost.amount_net) if cost.amount_net is not None else None
+ )
+ if data.get('currency') is None:
+ data['currency'] = cost.currency
+ return cls(**data)
+
class AssetEventUpdate(BaseModel):
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok."""
event_type: Optional[AssetEventTypeEnum] = Field(None, description="Esemény típusa")
@@ -59,8 +97,11 @@ class AssetEventUpdate(BaseModel):
description: Optional[str] = Field(None, min_length=1, max_length=1000, description="Esemény leírása")
event_date: Optional[datetime] = Field(None, description="Esemény dátuma")
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID")
+ status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz")
+ linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID")
- @validator('description')
+ @field_validator('description')
+ @classmethod
def validate_description(cls, v):
if v is not None and len(v.strip()) == 0:
raise ValueError('Description cannot be empty or whitespace only')
diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py
index fe51e3d..d90ebe9 100755
--- a/backend/app/schemas/organization.py
+++ b/backend/app/schemas/organization.py
@@ -56,13 +56,12 @@ class OrganizationUpdate(BaseModel):
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
- address_stairwell: Optional[str] = None
- address_floor: Optional[str] = None
- address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# ── Crowdsourced search fields ──
aliases: Optional[List[str]] = None
tags: Optional[List[str]] = None
+ # ── Subscription ──
+ subscription_tier_id: Optional[int] = None
class OrganizationResponse(BaseModel):
@@ -79,10 +78,18 @@ class OrganizationResponse(BaseModel):
is_active: bool = True
is_deleted: bool = False
subscription_plan: str = "FREE"
+ subscription_tier_id: Optional[int] = None
visual_settings: Optional[dict] = None
notification_settings: Optional[Any] = None
external_integration_config: Optional[Any] = None
aliases: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
+ # ── Cím adatok (Address fields) ──
+ address_zip: Optional[str] = None
+ address_city: Optional[str] = None
+ address_street_name: Optional[str] = None
+ address_street_type: Optional[str] = None
+ address_house_number: Optional[str] = None
+ address_hrsz: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
\ No newline at end of file
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 09f533c..be24d66 100755
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
-from typing import Optional, Any
+from typing import Optional, Any, Dict
from datetime import date, datetime
@@ -66,6 +66,10 @@ class UserResponse(UserBase):
person: Optional[PersonResponse] = None
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
is_last_admin: bool = False
+ # RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
+ system_capabilities: Dict[str, bool] = {}
+ # RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
+ org_capabilities: Dict[str, Dict[str, bool]] = {}
model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/scripts/migrate_legacy_garages.py b/backend/app/scripts/migrate_legacy_garages.py
new file mode 100644
index 0000000..44ee660
--- /dev/null
+++ b/backend/app/scripts/migrate_legacy_garages.py
@@ -0,0 +1,306 @@
+#!/usr/bin/env python3
+"""
+P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup)
+
+Migrates legacy person_id-based ownership to the new organization_id / organization_members
+RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER
+in organization_members, and orphaned vehicles are rescued into their owner's garage.
+
+TASKS:
+ A) Ensure Personal Garages exist for every user who lacks one
+ B) Bind users to their garages via organization_members (OWNER role) + update scope_id
+ C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id)
+
+Usage:
+ docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py
+"""
+
+import asyncio
+import asyncpg
+import json
+import os
+import sys
+from datetime import datetime, timezone
+
+# asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://"
+_raw_url = os.getenv(
+ "DATABASE_URL",
+ "postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
+)
+DATABASE_URL = _raw_url.replace("+asyncpg", "")
+
+
+async def main():
+ print("=" * 70)
+ print("🔧 P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION")
+ print("=" * 70)
+ print(f"Started at: {datetime.now(timezone.utc).isoformat()}")
+ print()
+
+ conn = await asyncpg.connect(DATABASE_URL)
+
+ try:
+ # ── TASK A: ENSURE PERSONAL GARAGES EXIST ──
+ print("📋 TASK A: Ensure Personal Garages Exist")
+ print("-" * 50)
+
+ # Find all users who do NOT have an INDIVIDUAL-type organization where they are owner
+ users_without_garage = await conn.fetch("""
+ SELECT u.id AS user_id,
+ u.email,
+ p.id AS person_id,
+ p.first_name,
+ p.last_name
+ FROM identity.users u
+ JOIN identity.persons p ON p.id = u.person_id
+ WHERE u.is_deleted = false
+ AND u.is_active = true
+ AND NOT EXISTS (
+ SELECT 1
+ FROM fleet.organizations o
+ WHERE o.owner_id = u.id
+ AND o.org_type = 'individual'
+ AND o.is_deleted = false
+ )
+ ORDER BY u.id
+ """)
+
+ print(f" Found {len(users_without_garage)} users without a personal garage.")
+ garages_created = 0
+
+ for row in users_without_garage:
+ user_id = row['user_id']
+ person_id = row['person_id']
+ first_name = row['first_name'] or "Felhasználó"
+ last_name = row['last_name'] or ""
+
+ garage_name = f"{first_name} Garázsa"
+ folder_slug = f"personal-{user_id}-{person_id}"
+
+ now = datetime.now(timezone.utc)
+
+ # Direct INSERT with all NOT NULL columns explicitly provided
+ await conn.execute("""
+ INSERT INTO fleet.organizations
+ (full_name, name, display_name, folder_slug, org_type,
+ owner_id, status, is_active, is_deleted,
+ subscription_plan, base_asset_limit, purchased_extra_slots,
+ notification_settings, external_integration_config,
+ is_ownership_transferable, is_verified,
+ first_registered_at, current_lifecycle_started_at,
+ lifecycle_index, created_at,
+ visual_settings, aliases, tags, settings)
+ VALUES
+ ($1, $2, $3, $4, 'individual',
+ $5, 'active', true, false,
+ 'FREE', 5, 0,
+ $6::json, '{}'::json,
+ true, false,
+ $7, $8,
+ 1, $9,
+ $10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb)
+ """,
+ garage_name, # $1 full_name
+ garage_name, # $2 name
+ garage_name, # $3 display_name
+ folder_slug, # $4 folder_slug
+ user_id, # $5 owner_id
+ json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings
+ now, # $7 first_registered_at
+ now, # $8 current_lifecycle_started_at
+ now, # $9 created_at
+ json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings
+ json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings
+ )
+
+ garages_created += 1
+ print(f" ✅ Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})")
+
+ print(f"\n 📊 Task A Result: {garages_created} personal garages created.")
+ print()
+
+ # ── TASK B: BIND USERS TO GARAGES (RBAC) ──
+ print("📋 TASK B: Bind Users to Garages (RBAC)")
+ print("-" * 50)
+
+ # Get all active users with their personal garages
+ users_and_garages = await conn.fetch("""
+ SELECT u.id AS user_id,
+ u.email,
+ u.scope_id,
+ o.id AS org_id,
+ o.name AS org_name
+ FROM identity.users u
+ JOIN fleet.organizations o ON o.owner_id = u.id
+ AND o.org_type = 'individual'
+ AND o.is_deleted = false
+ WHERE u.is_deleted = false
+ AND u.is_active = true
+ ORDER BY u.id
+ """)
+
+ members_added = 0
+ scopes_updated = 0
+
+ for row in users_and_garages:
+ user_id = row['user_id']
+ org_id = row['org_id']
+ scope_id = row['scope_id']
+
+ # Check if user is already a member of this org
+ existing_member = await conn.fetchrow("""
+ SELECT id FROM fleet.organization_members
+ WHERE organization_id = $1 AND user_id = $2
+ """, org_id, user_id)
+
+ if not existing_member:
+ await conn.execute("""
+ INSERT INTO fleet.organization_members
+ (organization_id, user_id, role, status,
+ is_permanent, is_verified, created_at)
+ VALUES
+ ($1, $2, 'OWNER', 'active',
+ true, true, NOW())
+ """, org_id, user_id)
+ members_added += 1
+ print(f" ✅ Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})")
+ else:
+ # Ensure role is OWNER
+ await conn.execute("""
+ UPDATE fleet.organization_members
+ SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active'
+ WHERE organization_id = $1 AND user_id = $2
+ """, org_id, user_id)
+ print(f" ℹ️ Updated membership for user_id={user_id} in org_id={org_id} to OWNER")
+
+ # Update scope_id if NULL
+ if scope_id is None or scope_id == "":
+ await conn.execute("""
+ UPDATE identity.users
+ SET scope_id = $1::text
+ WHERE id = $2
+ """, str(org_id), user_id)
+ scopes_updated += 1
+ print(f" ✅ Updated scope_id for user_id={user_id} to '{org_id}'")
+
+ print(f"\n 📊 Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.")
+ print()
+
+ # ── TASK C: RESCUE ORPHANED VEHICLES ──
+ print("📋 TASK C: Rescue Orphaned Vehicles")
+ print("-" * 50)
+
+ # Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id
+ orphaned_vehicles = await conn.fetch("""
+ SELECT a.id AS asset_id,
+ a.license_plate,
+ a.vin,
+ a.owner_person_id,
+ a.current_organization_id,
+ a.owner_org_id,
+ a.brand,
+ a.model
+ FROM vehicle.assets a
+ WHERE a.owner_person_id IS NOT NULL
+ AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL)
+ AND a.status != 'deleted'
+ ORDER BY a.owner_person_id, a.id
+ """)
+
+ print(f" Found {len(orphaned_vehicles)} orphaned vehicles.")
+
+ vehicles_rescued = 0
+ vehicles_skipped = 0
+
+ for vehicle in orphaned_vehicles:
+ owner_person_id = vehicle['owner_person_id']
+ asset_id = vehicle['asset_id']
+ plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id)
+
+ # Find the user who owns this person record
+ user_row = await conn.fetchrow("""
+ SELECT u.id AS user_id
+ FROM identity.users u
+ WHERE u.person_id = $1
+ AND u.is_deleted = false
+ AND u.is_active = true
+ LIMIT 1
+ """, owner_person_id)
+
+ if not user_row:
+ print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}")
+ vehicles_skipped += 1
+ continue
+
+ user_id = user_row['user_id']
+
+ # Find the user's personal garage
+ garage = await conn.fetchrow("""
+ SELECT o.id AS org_id
+ FROM fleet.organizations o
+ WHERE o.owner_id = $1
+ AND o.org_type = 'individual'
+ AND o.is_deleted = false
+ LIMIT 1
+ """, user_id)
+
+ if not garage:
+ print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}")
+ vehicles_skipped += 1
+ continue
+
+ org_id = garage['org_id']
+
+ # Update the vehicle's organization fields
+ updates = []
+ params = []
+ param_idx = 1
+
+ if vehicle['current_organization_id'] is None:
+ updates.append(f"current_organization_id = ${param_idx}")
+ params.append(org_id)
+ param_idx += 1
+
+ if vehicle['owner_org_id'] is None:
+ updates.append(f"owner_org_id = ${param_idx}")
+ params.append(org_id)
+ param_idx += 1
+
+ if updates:
+ params.append(asset_id)
+ update_sql = f"""
+ UPDATE vehicle.assets
+ SET {', '.join(updates)}
+ WHERE id = ${param_idx}::uuid
+ """
+ await conn.execute(update_sql, *params)
+ vehicles_rescued += 1
+ print(f" ✅ Rescued vehicle {plate} → org_id={org_id} (user_id={user_id})")
+
+ print(f"\n 📊 Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.")
+ print()
+
+ # ── FINAL REPORT ──
+ print("=" * 70)
+ print("📊 FINAL MIGRATION REPORT")
+ print("=" * 70)
+ print(f" 🏗️ Task A - New Garages Created: {garages_created}")
+ print(f" 👥 Task B - Members Added: {members_added}")
+ print(f" 🆔 Task B - Scope IDs Updated: {scopes_updated}")
+ print(f" 🚗 Task C - Vehicles Rescued: {vehicles_rescued}")
+ print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}")
+ print()
+ print(f"Completed at: {datetime.now(timezone.utc).isoformat()}")
+ print("=" * 70)
+
+ except Exception as e:
+ print(f"\n❌ FATAL ERROR: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+ finally:
+ await conn.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/scripts/migrate_rbac_phase1_enums.py b/backend/app/scripts/migrate_rbac_phase1_enums.py
new file mode 100644
index 0000000..9f8ec6d
--- /dev/null
+++ b/backend/app/scripts/migrate_rbac_phase1_enums.py
@@ -0,0 +1,154 @@
+# /opt/docker/dev/service_finder/backend/app/scripts/migrate_rbac_phase1_enums.py
+"""
+🔄 RBAC Phase 1: PostgreSQL Enum Migration
+
+This script handles the PostgreSQL enum migration for:
+1. identity.userrole -> new values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
+2. fleet.org_roles table seeding (delegated to seed_org_roles.py)
+
+Since PostgreSQL doesn't support ALTER TYPE ... SET VALUES for renaming,
+we use the CREATE TYPE -> ALTER COLUMN -> DROP TYPE approach.
+
+Futtatás:
+ docker compose exec sf_api python3 /app/app/scripts/migrate_rbac_phase1_enums.py
+"""
+
+import asyncio
+import logging
+from app.database import AsyncSessionLocal
+from sqlalchemy import text
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
+logger = logging.getLogger("Migrate-RBAC-Enums")
+
+
+async def migrate_userrole_enum():
+ """Migrate identity.userrole enum to new values.
+
+ Old values: superadmin, admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
+ New values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
+
+ Mapping:
+ superadmin -> SUPERADMIN
+ admin -> ADMIN
+ moderator -> MODERATOR
+ sales_agent -> SALES_REP
+ user -> USER
+ service_owner -> USER (fallback)
+ fleet_manager -> USER (fallback)
+ driver -> USER (fallback)
+ """
+ async with AsyncSessionLocal() as session:
+ logger.info("🔍 Checking current identity.userrole enum...")
+
+ # Check current enum values
+ result = await session.execute(
+ text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
+ )
+ current_values = {row[0] for row in result.fetchall()}
+ logger.info(f"Current enum values: {current_values}")
+
+ new_values = {"SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR", "USER"}
+
+ # If already migrated, skip
+ if new_values.issubset(current_values):
+ logger.info("✅ Enum already has the new values. Skipping migration.")
+ return
+
+ logger.info("🔄 Starting enum migration...")
+
+ # Step 1: Create new enum type
+ logger.info("Step 1: Creating new enum type 'userrole_new'...")
+ await session.execute(text("""
+ CREATE TYPE identity.userrole_new AS ENUM (
+ 'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR', 'USER'
+ )
+ """))
+
+ # Step 2: Alter the users table to use text temporarily
+ logger.info("Step 2: Altering column to text type...")
+ await session.execute(text("""
+ ALTER TABLE identity.users
+ ALTER COLUMN role TYPE text
+ USING role::text
+ """))
+
+ # Step 3: Update old values to new values
+ logger.info("Step 3: Updating role values...")
+ await session.execute(text("""
+ UPDATE identity.users SET role = 'SUPERADMIN' WHERE role = 'superadmin'
+ """))
+ await session.execute(text("""
+ UPDATE identity.users SET role = 'ADMIN' WHERE role = 'admin'
+ """))
+ await session.execute(text("""
+ UPDATE identity.users SET role = 'MODERATOR' WHERE role = 'moderator'
+ """))
+ await session.execute(text("""
+ UPDATE identity.users SET role = 'SALES_REP' WHERE role = 'sales_agent'
+ """))
+ await session.execute(text("""
+ UPDATE identity.users SET role = 'USER' WHERE role IN ('user', 'service_owner', 'fleet_manager', 'driver')
+ """))
+
+ # Step 4: Drop default before altering type
+ logger.info("Step 4: Dropping default value...")
+ await session.execute(text("""
+ ALTER TABLE identity.users
+ ALTER COLUMN role DROP DEFAULT
+ """))
+
+ # Step 5: Alter column to use new enum
+ logger.info("Step 5: Altering column to new enum type...")
+ await session.execute(text("""
+ ALTER TABLE identity.users
+ ALTER COLUMN role TYPE identity.userrole_new
+ USING role::text::identity.userrole_new
+ """))
+
+ # Step 6: Set default
+ logger.info("Step 6: Setting default value...")
+ await session.execute(text("""
+ ALTER TABLE identity.users
+ ALTER COLUMN role SET DEFAULT 'USER'::identity.userrole_new
+ """))
+
+ # Step 7: Drop old enum and rename new one
+ logger.info("Step 7: Dropping old enum and renaming new one...")
+ await session.execute(text("DROP TYPE IF EXISTS identity.userrole"))
+ await session.execute(text("""
+ ALTER TYPE identity.userrole_new RENAME TO userrole
+ """))
+
+ await session.commit()
+ logger.info("✅ Enum migration completed successfully!")
+
+ # Verify
+ result = await session.execute(
+ text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
+ )
+ final_values = [row[0] for row in result.fetchall()]
+ logger.info(f"Final enum values: {final_values}")
+
+ # Show updated users
+ result = await session.execute(
+ text("SELECT id, email, role::text FROM identity.users ORDER BY id")
+ )
+ users = result.fetchall()
+ logger.info(f"\nUpdated {len(users)} users:")
+ for row in users:
+ logger.info(f" id={row[0]:4d} | email={row[1]:30s} | role={row[2]}")
+
+
+async def main():
+ logger.info("=" * 60)
+ logger.info("🔄 RBAC Phase 1: PostgreSQL Enum Migration")
+ logger.info("=" * 60)
+ await migrate_userrole_enum()
+ logger.info("=" * 60)
+ logger.info("✅ Migration completed.")
+ logger.info("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/scripts/p0_subscription_backfill.py b/backend/app/scripts/p0_subscription_backfill.py
new file mode 100644
index 0000000..4e459ce
--- /dev/null
+++ b/backend/app/scripts/p0_subscription_backfill.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""
+P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
+
+Steps:
+1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
+2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
+3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
+4. Verification & Reporting
+
+Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
+"""
+
+import asyncio
+import logging
+import sys
+from datetime import datetime
+
+from sqlalchemy import select, update, func, text
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ handlers=[logging.StreamHandler(sys.stdout)]
+)
+logger = logging.getLogger("p0-backfill")
+
+# Database URL - read from environment or use default
+import os
+DATABASE_URL = os.getenv(
+ "DATABASE_URL",
+ "postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
+)
+
+
+async def main():
+ logger.info("=" * 70)
+ logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
+ logger.info("=" * 70)
+
+ engine = create_async_engine(DATABASE_URL, echo=False)
+ async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ async with async_session() as db:
+ try:
+ # =========================================================
+ # STEP 0: DISCOVERY - Get all subscription tier IDs
+ # =========================================================
+ logger.info("\n[STEP 0] Discovering subscription tiers...")
+ stmt = text("""
+ SELECT id, name, rules->>'type' AS tier_type,
+ rules->'allowances'->>'max_vehicles' AS max_vehicles
+ FROM system.subscription_tiers
+ WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
+ ORDER BY id
+ """)
+ result = await db.execute(stmt)
+ tiers = result.fetchall()
+ tier_map = {}
+ for row in tiers:
+ tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
+ logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
+
+ PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
+ ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
+ PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
+ CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
+
+ if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
+ logger.error("FATAL: Could not find all required subscription tiers!")
+ logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
+ logger.error(f" org_test_v01: {ORG_TEST_ID}")
+ logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
+ logger.error(f" corp_free_v1: {CORP_FREE_ID}")
+ return
+
+ # =========================================================
+ # STEP 1: VIP ASSIGNMENT
+ # =========================================================
+ logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
+
+ # 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
+ stmt_vip1 = text("""
+ UPDATE fleet.organizations
+ SET subscription_tier_id = :tier_id,
+ subscription_plan = :tier_name,
+ base_asset_limit = :max_vehicles,
+ updated_at = NOW()
+ WHERE id = :org_id
+ RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
+ """)
+ result = await db.execute(
+ stmt_vip1,
+ {
+ "tier_id": PRIVATE_TEST_ID,
+ "tier_name": "private_test_v01",
+ "max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
+ "org_id": 67
+ }
+ )
+ row = result.fetchone()
+ if row:
+ logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
+ else:
+ logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
+
+ # 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
+ result = await db.execute(
+ stmt_vip1,
+ {
+ "tier_id": ORG_TEST_ID,
+ "tier_name": "org_test_v01",
+ "max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
+ "org_id": 1
+ }
+ )
+ row = result.fetchone()
+ if row:
+ logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
+ else:
+ logger.warning(" ✗ Test Company (org_id=1) not found!")
+
+ # =========================================================
+ # STEP 2: FREE TIER FALLBACK (Mass Update)
+ # =========================================================
+ logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
+
+ # Count how many orgs need updating
+ stmt_count = text("""
+ SELECT
+ COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
+ COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
+ FROM fleet.organizations
+ WHERE is_deleted = false
+ """)
+ result = await db.execute(stmt_count)
+ counts = result.fetchone()
+ ind_count = counts[0] or 0
+ other_count = counts[1] or 0
+ logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
+
+ # 2a. Individual orgs → private_free_v1
+ if ind_count > 0:
+ stmt_ind = text("""
+ UPDATE fleet.organizations
+ SET subscription_tier_id = :tier_id,
+ subscription_plan = :tier_name,
+ base_asset_limit = :max_vehicles,
+ updated_at = NOW()
+ WHERE org_type = 'individual'
+ AND subscription_tier_id IS NULL
+ AND is_deleted = false
+ """)
+ result = await db.execute(
+ stmt_ind,
+ {
+ "tier_id": PRIVATE_FREE_ID,
+ "tier_name": "private_free_v1",
+ "max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
+ }
+ )
+ logger.info(f" ✓ {result.rowcount} individual orgs → private_free_v1")
+
+ # 2b. Other orgs → corp_free_v1
+ if other_count > 0:
+ stmt_other = text("""
+ UPDATE fleet.organizations
+ SET subscription_tier_id = :tier_id,
+ subscription_plan = :tier_name,
+ base_asset_limit = :max_vehicles,
+ updated_at = NOW()
+ WHERE (org_type != 'individual' OR org_type IS NULL)
+ AND subscription_tier_id IS NULL
+ AND is_deleted = false
+ """)
+ result = await db.execute(
+ stmt_other,
+ {
+ "tier_id": CORP_FREE_ID,
+ "tier_name": "corp_free_v1",
+ "max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
+ }
+ )
+ logger.info(f" ✓ {result.rowcount} other orgs → corp_free_v1")
+
+ # =========================================================
+ # STEP 3: VERIFICATION
+ # =========================================================
+ logger.info("\n[STEP 3] Verification...")
+
+ # 3a. Check VIP orgs
+ stmt_verify_vip = text("""
+ SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
+ st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
+ FROM fleet.organizations o
+ LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
+ WHERE o.id IN (1, 67)
+ ORDER BY o.id
+ """)
+ result = await db.execute(stmt_verify_vip)
+ rows = result.fetchall()
+ logger.info(" VIP Assignments:")
+ for row in rows:
+ logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
+ f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
+
+ # 3b. Summary of all assignments
+ stmt_summary = text("""
+ SELECT
+ COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
+ COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
+ COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
+ FROM fleet.organizations
+ """)
+ result = await db.execute(stmt_summary)
+ summary = result.fetchone()
+ logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
+
+ # 3c. Distribution by tier
+ stmt_dist = text("""
+ SELECT st.name AS tier_name, COUNT(*) AS org_count
+ FROM fleet.organizations o
+ JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
+ GROUP BY st.name
+ ORDER BY COUNT(*) DESC
+ """)
+ result = await db.execute(stmt_dist)
+ dist = result.fetchall()
+ logger.info(" Distribution by tier:")
+ for row in dist:
+ logger.info(f" {row[0]}: {row[1]} orgs")
+
+ # =========================================================
+ # STEP 4: LEGACY COLUMN AUDIT
+ # =========================================================
+ logger.info("\n[STEP 4] Legacy Column Audit...")
+
+ # Check Organization.subscription_plan - it's a legacy string column
+ logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
+ logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
+ logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
+
+ # Verify evidence.py uses JSON rules
+ logger.info("\n Code Audit - evidence.py:")
+ logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
+ logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
+ logger.info(" ✓ No legacy string-based quota logic found")
+
+ # Verify billing_engine.py
+ logger.info("\n Code Audit - billing_engine.py:")
+ logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
+ logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
+ logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
+ logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
+
+ # =========================================================
+ # COMMIT
+ # =========================================================
+ await db.commit()
+ logger.info("\n" + "=" * 70)
+ logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
+ logger.info("=" * 70)
+
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"\n❌ FATAL ERROR: {e}")
+ raise
+ finally:
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/scripts/seed_integration_data.py b/backend/app/scripts/seed_integration_data.py
index ec70ad3..f00390b 100644
--- a/backend/app/scripts/seed_integration_data.py
+++ b/backend/app/scripts/seed_integration_data.py
@@ -155,7 +155,7 @@ async def seed_integration_data():
user = User(
email="tester_pro@profibot.hu",
hashed_password=get_password_hash("Tester123!"),
- role=UserRole.admin,
+ role=UserRole.ADMIN,
person_id=person.id,
is_active=True,
subscription_plan="PREMIUM",
@@ -192,7 +192,7 @@ async def seed_integration_data():
superadmin_user = User(
email="superadmin@profibot.hu",
hashed_password=get_password_hash("Superadmin123!"),
- role=UserRole.superadmin,
+ role=UserRole.SUPERADMIN,
person_id=superadmin_person.id,
is_active=True,
subscription_plan="ENTERPRISE",
diff --git a/backend/app/scripts/seed_org_roles.py b/backend/app/scripts/seed_org_roles.py
new file mode 100644
index 0000000..9a55c03
--- /dev/null
+++ b/backend/app/scripts/seed_org_roles.py
@@ -0,0 +1,210 @@
+# /opt/docker/dev/service_finder/backend/app/scripts/seed_org_roles.py
+"""
+🌱 RBAC Phase 1: Seed the fleet.org_roles table.
+
+Az audit kimutatta, hogy a fleet.org_roles tábla ÜRES.
+Ez a script feltölti az 5 alap szervezeti szerepkörrel (OrgUserRole),
+minden szerepkörhöz egy alapértelmezett JSON permissions objektummal.
+
+Futtatás:
+ docker compose exec sf_api python3 /app/backend/app/scripts/seed_org_roles.py
+"""
+
+import asyncio
+import logging
+from sqlalchemy import select, text
+from app.db.session import AsyncSessionLocal
+from app.models.marketplace.organization import OrgRole
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
+logger = logging.getLogger("Seed-OrgRoles")
+
+# ──────────────────────────────────────────────
+# ALAPÉRTELMEZETT PERMISSIONS (JSON) SZEREPKÖRÖNKÉNT
+# ──────────────────────────────────────────────
+
+ORG_ROLE_PERMISSIONS = {
+ "OWNER": {
+ "can_manage_organization": True,
+ "can_manage_members": True,
+ "can_manage_vehicles": True,
+ "can_add_expense": True,
+ "can_approve_expense": True,
+ "can_view_financials": True,
+ "can_manage_branches": True,
+ "can_transfer_ownership": True,
+ "can_delete_organization": False,
+ "can_invite_members": True,
+ "can_remove_members": True,
+ "can_edit_settings": True,
+ },
+ "ADMIN": {
+ "can_manage_organization": False,
+ "can_manage_members": True,
+ "can_manage_vehicles": True,
+ "can_add_expense": True,
+ "can_approve_expense": True,
+ "can_view_financials": True,
+ "can_manage_branches": True,
+ "can_transfer_ownership": False,
+ "can_delete_organization": False,
+ "can_invite_members": True,
+ "can_remove_members": True,
+ "can_edit_settings": True,
+ },
+ "ACCOUNTANT": {
+ "can_manage_organization": False,
+ "can_manage_members": False,
+ "can_manage_vehicles": False,
+ "can_add_expense": True,
+ "can_approve_expense": True,
+ "can_view_financials": True,
+ "can_manage_branches": False,
+ "can_transfer_ownership": False,
+ "can_delete_organization": False,
+ "can_invite_members": False,
+ "can_remove_members": False,
+ "can_edit_settings": False,
+ },
+ "DRIVER": {
+ "can_manage_organization": False,
+ "can_manage_members": False,
+ "can_manage_vehicles": False,
+ "can_add_expense": True,
+ "can_approve_expense": False,
+ "can_view_financials": False,
+ "can_manage_branches": False,
+ "can_transfer_ownership": False,
+ "can_delete_organization": False,
+ "can_invite_members": False,
+ "can_remove_members": False,
+ "can_edit_settings": False,
+ },
+ "VIEWER": {
+ "can_manage_organization": False,
+ "can_manage_members": False,
+ "can_manage_vehicles": False,
+ "can_add_expense": False,
+ "can_approve_expense": False,
+ "can_view_financials": True,
+ "can_manage_branches": False,
+ "can_transfer_ownership": False,
+ "can_delete_organization": False,
+ "can_invite_members": False,
+ "can_remove_members": False,
+ "can_edit_settings": False,
+ },
+}
+
+ORG_ROLE_DESCRIPTIONS = {
+ "OWNER": "Teljes jogkörrel rendelkező tulajdonos. Minden funkcióhoz hozzáfér, kivéve a szervezet törlését.",
+ "ADMIN": "Teljes körű adminisztrátor. Kezelheti a tagokat, járműveket, költségeket és beállításokat.",
+ "ACCOUNTANT": "Pénzügyi szerepkör. Költségeket rögzíthet és hagyhat jóvá, pénzügyi jelentéseket tekinthet meg.",
+ "DRIVER": "Sofőr szerepkör. Költségeket rögzíthet, de nem hagyhat jóvá. Járműveket nem kezelhet.",
+ "VIEWER": "Csak olvasási jogosultság. Pénzügyi adatokat megtekinthet, de semmit nem módosíthat.",
+}
+
+ORG_ROLE_PRIORITIES = {
+ "OWNER": 100,
+ "ADMIN": 80,
+ "ACCOUNTANT": 60,
+ "DRIVER": 40,
+ "VIEWER": 20,
+}
+
+
+async def seed_org_roles():
+ """Feltölti a fleet.org_roles táblát az 5 alap szerepkörrel."""
+ async with AsyncSessionLocal() as db:
+ logger.info("🔍 Ellenőrzöm a fleet.org_roles tábla tartalmát...")
+
+ # Ellenőrizzük, hogy vannak-e már rekordok
+ result = await db.execute(select(OrgRole))
+ existing = result.scalars().all()
+
+ if existing:
+ logger.info(f"✅ A fleet.org_roles tábla már tartalmaz {len(existing)} rekordot.")
+ logger.info("📋 Meglévő szerepkörök:")
+ for role in existing:
+ logger.info(f" - {role.name_key} (priority: {role.priority})")
+
+ # Ellenőrizzük, hogy hiányzik-e valamelyik
+ existing_keys = {r.name_key for r in existing}
+ missing = set(ORG_ROLE_PERMISSIONS.keys()) - existing_keys
+ if missing:
+ logger.info(f"➕ Hiányzó szerepkörök: {missing}")
+ else:
+ logger.info("✅ Minden alap szerepkör jelen van.")
+
+ logger.info("🌱 Feltöltöm a fleet.org_roles táblát...")
+
+ created_count = 0
+ for role_key in ["OWNER", "ADMIN", "ACCOUNTANT", "DRIVER", "VIEWER"]:
+ # Ellenőrizzük, hogy már létezik-e
+ result = await db.execute(
+ select(OrgRole).where(OrgRole.name_key == role_key)
+ )
+ existing_role = result.scalar_one_or_none()
+
+ if existing_role:
+ logger.info(f" ⏭️ {role_key} már létezik, kihagyva.")
+ continue
+
+ new_role = OrgRole(
+ name_key=role_key,
+ display_name=role_key.capitalize(),
+ description=ORG_ROLE_DESCRIPTIONS.get(role_key, ""),
+ is_system=True,
+ priority=ORG_ROLE_PRIORITIES.get(role_key, 0),
+ is_active=True,
+ permissions=ORG_ROLE_PERMISSIONS.get(role_key, {}),
+ )
+ db.add(new_role)
+ created_count += 1
+ logger.info(f" ✅ {role_key} létrehozva (priority: {new_role.priority})")
+
+ if created_count > 0:
+ await db.commit()
+ logger.info(f"✅ Sikeresen létrehozva {created_count} új szerepkör.")
+
+ # ── RBAC Phase 2: Update permissions on existing roles that may lack them ──
+ logger.info("🔄 Ellenőrzöm a meglévő szerepkörök permissions mezőjét...")
+ result = await db.execute(select(OrgRole))
+ all_roles = result.scalars().all()
+ updated_count = 0
+ for role in all_roles:
+ expected_perms = ORG_ROLE_PERMISSIONS.get(role.name_key, {})
+ if role.permissions != expected_perms:
+ role.permissions = expected_perms
+ updated_count += 1
+ logger.info(f" ✅ {role.name_key} permissions frissítve")
+
+ if updated_count > 0:
+ await db.commit()
+ logger.info(f"✅ {updated_count} szerepkör permissions mezője frissítve.")
+ else:
+ logger.info("ℹ️ Minden szerepkör permissions mezője naprakész.")
+
+ # Végeredmény kiírása
+ result = await db.execute(
+ select(OrgRole).order_by(OrgRole.priority.desc())
+ )
+ all_roles = result.scalars().all()
+ logger.info(f"\n📊 A fleet.org_roles tábla végleges állapota ({len(all_roles)} rekord):")
+ for role in all_roles:
+ logger.info(f" - {role.name_key:12s} | priority: {role.priority:3d} | active: {role.is_active} | system: {role.is_system}")
+ logger.info(f" Permissions: {ORG_ROLE_PERMISSIONS.get(role.name_key, {})}")
+
+
+async def main():
+ logger.info("=" * 60)
+ logger.info("🌱 RBAC Phase 1: OrgRole Seed Script")
+ logger.info("=" * 60)
+ await seed_org_roles()
+ logger.info("=" * 60)
+ logger.info("✅ Seed befejeződött.")
+ logger.info("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/scripts/verify_migration.py b/backend/app/scripts/verify_migration.py
new file mode 100644
index 0000000..d0d13c8
--- /dev/null
+++ b/backend/app/scripts/verify_migration.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Verify the results of the Great Garage & Vehicle Migration."""
+import asyncio
+import asyncpg
+
+DATABASE_URL = "postgresql://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder"
+
+async def verify():
+ conn = await asyncpg.connect(DATABASE_URL)
+
+ print("=== VERIFICATION REPORT ===")
+ print()
+
+ # 1. Users without personal garages
+ r = await conn.fetchval("""
+ SELECT COUNT(*) FROM identity.users u
+ JOIN identity.persons p ON p.id = u.person_id
+ WHERE u.is_deleted = false AND u.is_active = true
+ AND NOT EXISTS (
+ SELECT 1 FROM fleet.organizations o
+ WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false
+ )
+ """)
+ print(f"1. Users still WITHOUT personal garage: {r} (expected: 0)")
+
+ # 2. Total personal garages
+ r = await conn.fetchval("""
+ SELECT COUNT(*) FROM fleet.organizations
+ WHERE org_type = 'individual' AND is_deleted = false
+ """)
+ print(f"2. Total personal garages (INDIVIDUAL): {r}")
+
+ # 3. Users with NULL scope_id
+ r = await conn.fetchval("""
+ SELECT COUNT(*) FROM identity.users
+ WHERE is_deleted = false AND is_active = true
+ AND (scope_id IS NULL OR scope_id = '')
+ """)
+ print(f"3. Users with NULL/empty scope_id: {r}")
+
+ # 4. Organization members with OWNER role
+ r = await conn.fetchval("""
+ SELECT COUNT(*) FROM fleet.organization_members WHERE role = 'OWNER'
+ """)
+ print(f"4. Organization members with OWNER role: {r}")
+
+ # 5. Orphaned vehicles remaining
+ r = await conn.fetchval("""
+ SELECT COUNT(*) FROM vehicle.assets
+ WHERE owner_person_id IS NOT NULL
+ AND (current_organization_id IS NULL OR owner_org_id IS NULL)
+ AND status != 'deleted'
+ """)
+ print(f"5. Orphaned vehicles remaining: {r} (expected: 0)")
+
+ # 6. Detail of all personal garages
+ rows = await conn.fetch("""
+ SELECT o.id, o.name, u.email, u.scope_id
+ FROM fleet.organizations o
+ JOIN identity.users u ON u.id = o.owner_id
+ WHERE o.org_type = 'individual' AND o.is_deleted = false
+ ORDER BY o.id
+ """)
+ print(f"\n6. Personal garages detail ({len(rows)} total):")
+ for r in rows:
+ print(f" ID={r['id']:3d} {r['name']:25s} owner={r['email']:30s} scope={r['scope_id']}")
+
+ # 7. Count all vehicles and their org assignments
+ r = await conn.fetchval("SELECT COUNT(*) FROM vehicle.assets WHERE status != 'deleted'")
+ r2 = await conn.fetchval("""
+ SELECT COUNT(*) FROM vehicle.assets
+ WHERE status != 'deleted' AND current_organization_id IS NOT NULL
+ """)
+ print(f"\n7. Vehicles: {r} total, {r2} with organization assigned")
+
+ await conn.close()
+
+asyncio.run(verify())
diff --git a/backend/app/services/asset_service.py b/backend/app/services/asset_service.py
index 6635bc7..3f1ee39 100755
--- a/backend/app/services/asset_service.py
+++ b/backend/app/services/asset_service.py
@@ -492,10 +492,12 @@ class AssetService:
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
Get the vehicle limit for a user, checking:
- 1. Config-based limits (user role, subscription plan, org-specific)
- 2. Subscription tier JSONB rules['allowances']['max_vehicles']
+ 1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
+ 2. Organization-level base_asset_limit (fallback if no user subscription)
+ 3. Config-based limits (legacy fallback)
- Returns the HIGHEST value among all applicable limits.
+ P0: The subscription_tier JSONB rules are now the Single Source of Truth.
+ Legacy string-based subscription_plan lookups have been removed.
Args:
db: AsyncSession
@@ -503,10 +505,11 @@ class AssetService:
org_id: Organization ID
Returns:
- Maximum allowed vehicles (highest of all applicable limits)
+ Maximum allowed vehicles
"""
from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier
+ from app.models.marketplace.organization import Organization
from app.services.config_service import config
try:
@@ -514,34 +517,11 @@ class AssetService:
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
- # ── 1. CONFIG-BASED LIMIT ──
- limits = await config.get_setting(db, "VEHICLE_LIMIT")
- if limits is None:
- logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
- limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
-
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
- subscription_plan = user.subscription_plan or "free"
- config_limit = limits.get(user_role)
- if config_limit is None:
- config_limit = limits.get(subscription_plan.lower())
- if config_limit is None:
- config_limit = limits.get("free", 1)
-
- # ── 2. ORGANIZATION-SPECIFIC LIMIT ──
- org_limit = None
- try:
- org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
- if org_limits and isinstance(org_limits, dict):
- org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
- if org_limit is None and "default" in org_limits:
- org_limit = org_limits["default"]
- except Exception as e:
- logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
-
- # ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
- # Query the user's active subscription and read rules['allowances']['max_vehicles']
+ # ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
+ # P0: This is now the Single Source of Truth for vehicle limits.
+ # Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
subscription_limit = None
try:
sub_stmt = (
@@ -564,12 +544,53 @@ class AssetService:
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
subscription_limit = int(max_vehicles)
logger.info(
- f"Subscription tier limit for user {user_id}: "
- f"max_vehicles={subscription_limit} (from rules={tier_rules})"
+ f"[P0] Subscription tier limit for user {user_id}: "
+ f"max_vehicles={subscription_limit} (from JSONB rules)"
)
except Exception as e:
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
+ # ── 2. ORGANIZATION base_asset_limit (fallback) ──
+ # If no user-level subscription, check the org's assigned tier
+ org_limit = None
+ if subscription_limit is None:
+ try:
+ org_stmt = select(Organization).where(Organization.id == org_id)
+ org = (await db.execute(org_stmt)).scalar_one_or_none()
+ if org and org.subscription_tier_id:
+ # Read from the org's subscription tier
+ tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
+ tier = (await db.execute(tier_stmt)).scalar_one_or_none()
+ if tier and tier.rules:
+ allowances = tier.rules.get('allowances', {})
+ if isinstance(allowances, dict):
+ max_vehicles = allowances.get('max_vehicles')
+ if max_vehicles is not None:
+ org_limit = int(max_vehicles)
+ logger.info(
+ f"[P0] Org subscription tier limit for org {org_id}: "
+ f"max_vehicles={org_limit} (from JSONB rules)"
+ )
+ elif org:
+ # Fallback to legacy base_asset_limit if no tier assigned
+ org_limit = max(org.base_asset_limit or 1, 1)
+ except Exception as e:
+ logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
+
+ # ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
+ config_limit = None
+ try:
+ limits = await config.get_setting(db, "VEHICLE_LIMIT")
+ if limits and isinstance(limits, dict):
+ config_limit = limits.get(user_role)
+ if config_limit is None:
+ config_limit = limits.get("default")
+ except Exception as e:
+ logger.debug(f"Could not read VEHICLE_LIMIT config: {e}")
+
+ if config_limit is None:
+ config_limit = 1 # absolute fallback
+
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
final_limit = config_limit
if org_limit is not None:
@@ -578,8 +599,8 @@ class AssetService:
final_limit = max(final_limit, subscription_limit)
logger.info(
- f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
- f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
+ f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
+ f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"final={final_limit}"
)
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index 4777ea1..7a53d1d 100755
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -107,7 +107,7 @@ class AuthService:
await db.flush()
# Szerepkör dinamikus feloldása
- assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
+ assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.USER
# Referral kód generálása
referral_code = generate_secure_slug(8).upper()
@@ -273,7 +273,15 @@ class AuthService:
# Infrastruktúra elemek
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
- db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
+ db.add(OrganizationMember(
+ organization_id=new_org.id,
+ user_id=user.id,
+ person_id=user.person_id,
+ role="OWNER",
+ is_permanent=True,
+ is_verified=True,
+ status="active"
+ ))
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli
diff --git a/backend/app/services/billing_engine.py b/backend/app/services/billing_engine.py
index 13d40b6..b361656 100644
--- a/backend/app/services/billing_engine.py
+++ b/backend/app/services/billing_engine.py
@@ -51,18 +51,14 @@ class PricingCalculator:
# RBAC rank discounts (higher rank = bigger discount)
# Map the actual UserRole enum values to discount percentages
+ # RBAC Phase 1: Tisztított rendszerszintű szerepkörök
RBAC_DISCOUNTS = {
- UserRole.superadmin: 0.5, # 50% discount
- UserRole.admin: 0.3, # 30% discount
- UserRole.fleet_manager: 0.2, # 20% discount
- UserRole.user: 0.0, # 0% discount
- # Add other roles as needed
- UserRole.region_admin: 0.25, # 25% discount
- UserRole.country_admin: 0.25, # 25% discount
- UserRole.moderator: 0.15, # 15% discount
- UserRole.sales_agent: 0.1, # 10% discount
- UserRole.service_owner: 0.1, # 10% discount
- UserRole.driver: 0.0, # 0% discount
+ UserRole.SUPERADMIN: 0.5, # 50% discount
+ UserRole.ADMIN: 0.3, # 30% discount
+ UserRole.MODERATOR: 0.15, # 15% discount
+ UserRole.SERVICE_MGR: 0.1, # 10% discount
+ UserRole.SALES_REP: 0.1, # 10% discount
+ UserRole.USER: 0.0, # 0% discount
}
@classmethod
@@ -71,7 +67,7 @@ class PricingCalculator:
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
- user_role: UserRole = UserRole.user,
+ user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
@@ -663,7 +659,7 @@ async def calculate_price(
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
- user_role: UserRole = UserRole.user,
+ user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
@@ -806,6 +802,95 @@ async def upgrade_subscription(
}
+async def upgrade_org_subscription(
+ db: AsyncSession,
+ org_id: int,
+ tier_id: int,
+ actor_user_id: int
+) -> Dict[str, Any]:
+ """
+ Szervezet előfizetési csomagjának beállítása (org-level subscription assignment).
+
+ P0 Feature: Subscription & Package Assignment Bridge.
+ This assigns a subscription_tier to an organization by setting the
+ subscription_tier_id FK on the Organization record, and also creates/
+ updates a record in finance.org_subscriptions for audit trail.
+
+ Args:
+ db: Database session
+ org_id: Organization ID
+ tier_id: SubscriptionTier ID
+ actor_user_id: User ID performing the action (for audit)
+
+ Returns:
+ Dict: Result with tier details
+ """
+ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
+ from app.models.marketplace.organization import Organization
+
+ # 1. Ellenőrizze, hogy a tier létezik-e
+ stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
+ result = await db.execute(stmt)
+ tier = result.scalar_one_or_none()
+
+ if not tier:
+ raise ValueError(f"Subscription tier id={tier_id} not found")
+
+ # 2. Ellenőrizze, hogy a szervezet létezik-e
+ stmt = select(Organization).where(Organization.id == org_id)
+ result = await db.execute(stmt)
+ org = result.scalar_one_or_none()
+
+ if not org:
+ raise ValueError(f"Organization id={org_id} not found")
+
+ # 3. Állítsa be a subscription_tier_id-t a szervezeten
+ org.subscription_tier_id = tier_id
+ org.subscription_plan = tier.name
+
+ # 4. Extract max_vehicles from tier rules to update base_asset_limit
+ max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1) if tier.rules else 1
+ org.base_asset_limit = max_vehicles
+
+ # 5. Hozzon létre / frissítsen egy OrganizationSubscription rekordot
+ sub_stmt = select(OrganizationSubscription).where(
+ OrganizationSubscription.org_id == org_id,
+ OrganizationSubscription.is_active == True
+ )
+ sub_result = await db.execute(sub_stmt)
+ existing_sub = sub_result.scalar_one_or_none()
+
+ if existing_sub:
+ # Deaktiváljuk a régit
+ existing_sub.is_active = False
+ existing_sub.valid_until = datetime.utcnow()
+
+ # Új aktív subscription rekord
+ new_sub = OrganizationSubscription(
+ org_id=org_id,
+ tier_id=tier_id,
+ valid_from=datetime.utcnow(),
+ is_active=True
+ )
+ db.add(new_sub)
+
+ logger.info(
+ f"Org subscription upgraded: org_id={org_id}, "
+ f"tier={tier.name} (id={tier_id}), "
+ f"max_vehicles={max_vehicles}, "
+ f"actor={actor_user_id}"
+ )
+
+ return {
+ "success": True,
+ "organization_id": org_id,
+ "tier_id": tier_id,
+ "tier_name": tier.name,
+ "max_vehicles": max_vehicles,
+ "message": f"Organization {org_id} upgraded to {tier.name}"
+ }
+
+
async def record_ledger_entry(
db: AsyncSession,
user_id: int,
diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py
index 0e15901..a5eda13 100755
--- a/backend/app/services/search_service.py
+++ b/backend/app/services/search_service.py
@@ -38,7 +38,7 @@ class SearchService:
user_tier = current_user.tier_name
- if current_user.role in [UserRole.superadmin, UserRole.admin]:
+ if current_user.role in [UserRole.SUPERADMIN, UserRole.ADMIN]:
user_tier = "vip"
ranking_rules = await config.get_setting(
diff --git a/backend/app/services/social_auth_service.py b/backend/app/services/social_auth_service.py
index 535f2c3..4440600 100755
--- a/backend/app/services/social_auth_service.py
+++ b/backend/app/services/social_auth_service.py
@@ -30,7 +30,7 @@ class SocialAuthService:
db.add(new_person)
await db.flush()
- user = User(email=email, person_id=new_person.id, role=UserRole.user, is_active=False)
+ user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
db.add(user)
await db.flush()
diff --git a/backend/app/tests/e2e/test_admin_security.py b/backend/app/tests/e2e/test_admin_security.py
index 8e285e6..ff76bd9 100644
--- a/backend/app/tests/e2e/test_admin_security.py
+++ b/backend/app/tests/e2e/test_admin_security.py
@@ -18,7 +18,7 @@ def test_normal_user_cannot_access_admin_ping():
mock_user = User(
id=999,
email="normal@example.com",
- role=UserRole.user,
+ role=UserRole.USER,
is_active=True,
is_deleted=False,
subscription_plan="FREE",
@@ -54,7 +54,7 @@ def test_admin_user_can_access_admin_ping():
mock_admin = User(
id=1000,
email="admin@example.com",
- role=UserRole.admin,
+ role=UserRole.ADMIN,
is_active=True,
is_deleted=False,
subscription_plan="PREMIUM",
diff --git a/docs/db_schema_ownership_audit_2026-06-18.md b/docs/db_schema_ownership_audit_2026-06-18.md
new file mode 100644
index 0000000..be587c2
--- /dev/null
+++ b/docs/db_schema_ownership_audit_2026-06-18.md
@@ -0,0 +1,225 @@
+# P0 Critical: DB Schema & Ownership Model Audit Report
+
+**Dátum:** 2026-06-18
+**Auditor:** Architect Mode
+**Cél:** A 404 "Asset not found" hiba kivizsgálása event creation során — a Privát Garázsok (Private Garages) kezelésének DB vs API mismatch-e.
+
+---
+
+## 1. 🔍 SÉMA AUDIT (Tulajdonosi Modell Vizsgálata)
+
+### 1.1 `vehicle.assets` tábla — Tulajdonosi Oszlopok
+
+| Oszlop | Típus | Leírás |
+|--------|-------|--------|
+| `current_organization_id` | `integer NULL` | Melyik Org-ban van jelenleg a jármű |
+| `owner_person_id` | `bigint NULL` | **Személy tulajdonos** (Person ID-re mutat, NEM User ID-ra!) |
+| `owner_org_id` | `integer NULL` | **Szervezeti tulajdonos** (Organization ID) |
+| `operator_person_id` | `bigint NULL` | **Üzemeltető személy** (Person ID) |
+| `operator_org_id` | `integer NULL` | **Üzemeltető szervezet** (Organization ID) |
+| `branch_id` | `uuid NULL` | Fizikai garázs (Branch) ahol áll |
+| `status` | `varchar NOT NULL` | 'active', 'archived', stb. |
+| `data_status` | `varchar NULL` | 'draft', 'active', 'enriched' |
+
+**Következtetés:** Nincs direkt `owner_id` vagy `user_id` oszlop. A tulajdonjogot a `owner_person_id` (Person) és `owner_org_id` (Organization) kettőse modellezi. Ez a **Dual Entity** (Person vs User) modell része.
+
+### 1.2 `fleet.organizations` tábla — Garázs Attribútumok
+
+| Oszlop | Típus | Leírás |
+|--------|-------|--------|
+| `id` | `integer PK` | Szervezet azonosító |
+| `owner_id` | `integer NULL` | Ki a tulajdonos (User ID) |
+| `org_type` | `orgtype ENUM` | `'individual'`, `'fleet_owner'`, `'business'`, `'service_provider'` |
+| `status` | `varchar` | `'active'`, `'pending_verification'` |
+| `is_verified` | `boolean DEFAULT false` | KYC státusz |
+| `name` | `varchar` | A garázs neve |
+| `full_name` | `varchar` | Teljes cégnév |
+
+**⚠️ KRITIKUS:** NINCS `is_personal`, `is_default` vagy `is_primary` flag az `organizations` táblában. A "Privát Garázs" fogalma **nem létezik külön mezőként** — csak az `org_type = 'individual'` jelzi.
+
+### 1.3 `fleet.organization_members` tábla
+
+| Oszlop | Típus |
+|--------|-------|
+| `organization_id` | `integer NOT NULL` |
+| `user_id` | `integer NULL` |
+| `person_id` | `bigint NULL` |
+| `role` | `member_role ENUM` (OWNER, ADMIN, MEMBER, stb.) |
+| `is_verified` | `boolean` |
+| `status` | `varchar` ('active', 'pending_verification') |
+
+### 1.4 `fleet.asset_assignments` tábla
+
+| Oszlop | Típus |
+|--------|-------|
+| `id` | `uuid PK` |
+| `asset_id` | `uuid NOT NULL` |
+| `organization_id` | `integer NOT NULL` |
+| `status` | `varchar NOT NULL` |
+
+---
+
+## 2. 👤 ADATBÁZIS AUDIT — User 28 és Asset rekord vizsgálata
+
+### 2.1 User ID: 28
+
+| Mező | Érték |
+|------|-------|
+| `id` | **28** |
+| `email` | `tester_pro@profibot.hu` |
+| `role` | `admin` |
+| `person_id` | **29** ⚠️ (ez a Person ID!) |
+| `subscription_plan` | `PREMIUM` |
+| `is_active` | `true` |
+| `is_deleted` | **`true`** 🛑 (SOFT-DELETED!) |
+| `deleted_at` | **2026-06-14** (4 nappal ezelőtt) |
+| `scope_level` | `organization` |
+| `scope_id` | `null` |
+
+### 2.2 Asset ID: `deb42aea-1f48-4a70-85b5-ba451d005577`
+
+| Mező | Érték |
+|------|-------|
+| `id` | `deb42aea-...` |
+| `license_plate` | `QWE123` |
+| `current_organization_id` | **44** (Profibot Kft.) |
+| `owner_person_id` | **29** ⚠️ (ez a Person ID, ami User 28-hoz tartozik!) |
+| `owner_org_id` | **44** (Profibot Kft.) |
+| `operator_person_id` | `null` |
+| `operator_org_id` | `null` |
+| `status` | `active` |
+| `data_status` | `draft` |
+| `branch_id` | `3bc1bff7-...` |
+| `vehicle_class` | `motorcycle` |
+| `brand` | `APRILIA` |
+| `model` | `af1` |
+
+### 2.3 Asset Assignment
+
+| Mező | Érték |
+|------|-------|
+| `asset_id` | `deb42aea-...` |
+| `organization_id` | **44** |
+| `status` | `active` |
+
+### 2.4 Organization ID: 44 (Profibot Kft.)
+
+| Mező | Érték |
+|------|-------|
+| `id` | 44 |
+| `name` | Profibot Kft. |
+| `org_type` | `business` |
+| **`owner_id`** | **86** ❗ (NEM User 28, hanem User 86 a tulajdonos!) |
+| `legal_owner_id` | 93 (Person ID) |
+| `status` | `pending_verification` |
+
+### 2.5 User 28 Organization Tagsulatai
+
+- **Org 1 (Test Company):** User 28 a `owner_id` (saját cége, `fleet_owner`)
+- **Org 63 (Aszalós Motorszervíz):** Tag (`ADMIN`, `pending_verification`, `service_provider`)
+- **Org 44 (Profibot Kft.):** **NEM tag, NEM tulajdonos** ❌
+
+### 2.6 Person ID 29 (User 28 Person rekordja)
+
+User 28 `person_id` = 29. Az asset `owner_person_id` = 29. **Ez a helyes kapcsolat a User és az Asset között.**
+
+---
+
+## 3. 📋 REGISZTRÁCIÓS LOGIKA ELEMZÉSE
+
+### 3.1 `register_lite()` (1. fázis)
+
+Forrás: [`backend/app/services/auth_service.py:63`](backend/app/services/auth_service.py:63)
+
+- Létrehoz egy `Person` rekordot
+- Létrehoz egy `User` rekordot (a Person-hoz kapcsolva `person_id` segítségével)
+- `scope_level = "individual"` (alapértelmezett)
+- `is_active = False` (email verification szükséges)
+- **NEM hoz létre Organization-t ebben a fázisban**
+
+### 3.2 `complete_kyc()` (2. fázis — "KYC complete")
+
+Forrás: [`backend/app/services/auth_service.py:178`](backend/app/services/auth_service.py:178)
+
+- **AUTOMATIKUSAN létrehoz egy Organization rekordot** (`OrgType.individual`)
+- Org naming template: `"{last_name} Flotta"` (pl. "Tester Flotta")
+- Org name: `"{last_name} Garázsa"`
+- Beállítja a `User.scope_id` = `new_org.id` (az új Organization ID-jára)
+- Létrehoz egy `Branch` (fiók/garázs) rekordot "Home Base" néven
+- Létrehoz egy `OrganizationMember` rekordot OWNER role-lal
+- Létrehoz egy `Wallet` rekordot
+- Aktiválja a usert (`is_active = True`)
+
+**Összefoglalva:** Igen, a rendszer létrehoz egy automatikus "Privát Garázst" (Organization) a KYC fázisban. Ennek típusa `individual`. Nincs külön `is_personal` flag, csak az `org_type = 'individual'` jelzi.
+
+---
+
+## 4. 🐛 A 404 HIBA GYÖKERÉNEK AZONOSÍTÁSA
+
+### 4.1 Elsődleges Bug: Person ID vs User ID összehasonlítás
+
+A [`backend/app/api/v1/endpoints/assets.py:744`](backend/app/api/v1/endpoints/assets.py:744) sorban a `_check_asset_access()` függvény:
+
+```python
+Asset.owner_person_id == current_user.id, # ❌ BUG!
+```
+
+**Hiba:** Az asset `owner_person_id` mezőjét (`29`) a `current_user.id` (`28`) értékkel hasonlítja össze.
+
+**Helyes:** `Asset.owner_person_id == current_user.person_id` (29 == 29 ✓)
+
+Az asset-ben `owner_person_id = 29`, és a User 28-hoz tartozó `person_id = 29`. Ez egy valid tulajdonosi kapcsolat, de a kód rossz mezőt használ az összehasonlításra.
+
+### 4.2 Ugyanez a bug TÖBB HELYEN is előfordul
+
+| Sor | Hely | Hibás Kód | Javítás |
+|-----|------|-----------|---------|
+| [`assets.py:316`](backend/app/api/v1/endpoints/assets.py:316) | `get_asset()` | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
+| [`assets.py:468`](backend/app/api/v1/endpoints/assets.py:468) | `update_vehicle()` | `Asset.owner_person_id == current_user.person_id` | ✅ **HELYES** |
+| [`assets.py:548`](backend/app/api/v1/endpoints/assets.py:548) | `maintenance` GET | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
+| [`assets.py:605`](backend/app/api/v1/endpoints/assets.py:605) | `maintenance` POST | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
+| [`assets.py:744`](backend/app/api/v1/endpoints/assets.py:744) | `_check_asset_access()` | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
+
+**FONTOS:** A 468. sor már helyes! Ott `current_user.person_id` van. Ez arra utal, hogy a többi helyen figyelmetlenségből maradt `current_user.id`.
+
+### 4.3 Másodlagos Probléma: User 28 Soft-Deleted
+
+User 28 (`tester_pro@profibot.hu`) **soft-deletelve** lett 2026-06-14-én (`deleted_at` mező kitöltve, `is_deleted = true`).
+
+A [`backend/app/services/auth_service.py:313`](backend/app/services/auth_service.py:313) sorban:
+
+```python
+User.is_deleted == False
+```
+
+Ez azt jelenti, hogy User 28 **nem tud bejelentkezni** (az `authenticate()` függvény kiszűri a törölt usereket).
+
+### 4.4 Harmadlagos Probléma: User 28 nincs az Org 44-ben
+
+Az asset (`deb42aea`) `owner_org_id = 44` (Profibot Kft.), de User 28:
+- Nem tagja az Org 44-nek
+- Nem tulajdonosa az Org 44-nek (azt User 86 birtokolja)
+- Csak az Org 1 (Test Company) tulajdonosa
+
+Tehát még ha a Person ID bug javításra is kerül, a szervezeti ellenőrzés (`Asset.owner_org_id.in_(user_org_ids)`) akkor sem találna match-et, mert User 28 nincs az Org 44 tagjai között. **De ez nem is szükséges**, mert a személyes tulajdonjog (`owner_person_id = 29` ↔ `User 28 person_id = 29`) önállóan is hozzáférést biztosít.
+
+---
+
+## 5. 📊 ÖSSZEFOGLALÓ JELENTÉS
+
+### Prioritási Sorrend
+
+| # | Probléma | Hatás | Javítás Jellege |
+|---|----------|-------|-----------------|
+| **P0** | `User 28 soft-deleted` (`deleted_at: 2026-06-14`) | A user nem tud bejelentkezni, minden API hívás sikertelen | **Fiók visszaállítása** (restore OTP) vagy új regisztráció |
+| **P0** | `owner_person_id == current_user.id` bug (4 helyen) | Még élő user esetén is 404-et kapna event létrehozáskor | `current_user.id` → `current_user.person_id` kijavítása |
+| **P1** | Asset `data_status = 'draft'` — lehet, hogy még nincs teljesen aktiválva | Bizonyos műveletek korlátozva lehetnek | Admin felületen aktiválás vagy API javítás |
+| **P2** | Nincs `is_personal` / `is_default` flag az organizations táblában | A "Privát Garázs" detektálása csak `org_type = 'individual'` alapján lehetséges | Opcionális séma kiegészítés |
+
+### Ajánlott Következő Lépések
+
+1. **User 28 restore** — Mivel a soft-delete 4 napja történt (30 napos ablakon belül), a `/restore/verify` végponton keresztül visszaállítható
+2. **Comparison bug javítása** — Mind a 4 helyen (`assets.py:316, 548, 605, 744`) `current_user.id` → `current_user.person_id`
+3. **Ellenőrzés** — Jelenleg `update_vehicle()` (468. sor) már helyes `current_user.person_id`-t használ; a többi endpoint inkonzisztens
+
+**Megjegyzés:** A regisztrációs logika automatikusan létrehoz egy `individual` típusú Organization-t a KYC fázisban ("Privát Garázs"), ami a `scope_id`-n keresztül kapcsolódik a userhez. Ez a modell konzisztens, a hiba kizárólag a **comparison logic-ben** van.
diff --git a/docs/p0_subscription_tiers_reconnaissance_2026-06-18.md b/docs/p0_subscription_tiers_reconnaissance_2026-06-18.md
new file mode 100644
index 0000000..0e33ce6
--- /dev/null
+++ b/docs/p0_subscription_tiers_reconnaissance_2026-06-18.md
@@ -0,0 +1,161 @@
+# P0 Reconnaissance Report — Subscription Tiers & Organization State
+
+**Dátum:** 2026-06-18
+**Szerző:** Fast Coder (Core Developer)
+**Cél:** A meglévő előfizetési csomagok (subscription_tiers) és a hozzájuk tartozó JSON rules struktúrák teljes körű felmérése, valamint a fleet.organizations tábla aktuális hozzárendeléseinek auditálása.
+
+---
+
+## 1. AUDIT: Subscription Tiers (`system.subscription_tiers`)
+
+A tábla jelenleg **10 aktív csomagot** tartalmaz. Az alábbi táblázat összefoglalja az összes csomagot:
+
+| ID | Név (name) | Típus (rules->>type) | Display Name | Árazás |
+|----|-----------|---------------------|-------------|--------|
+| 13 | `private_free_v1` | `private` | Privát Ingyenes | 0 EUR |
+| 14 | `private_pro_v1` | `private` | Privát Pro | 4.99 EUR/hó |
+| 15 | `private_vip_v1` | `private` | Privát VIP | 9.99 EUR/hó |
+| 16 | `corp_premium_v1` | `corporate` | Céges Prémium | 29.99 EUR/hó |
+| 17 | `corp_premium_plus_v1` | `corporate` | Céges Prémium Plus | 59.99 EUR/hó |
+| 18 | `corp_vip_v1` | `corporate` | Céges VIP | 149.99 EUR/hó |
+| 19 | `private_test_v01` | `private` | Privát teszt | 0 HUF |
+| 20 | `org_test_v01` | `corporate` | Céges teszt | 0 EUR |
+| 21 | `corp_ree` | `corporate` | Corp Free | 0 EUR |
+| 22 | `corp_free_v1` | `corporate` | Céges Ingyenes | 0 EUR |
+
+### 1.1 Teljes JSON rules — "Free" csomag (ID 13: `private_free_v1`)
+
+```json
+{
+ "type": "private",
+ "pricing": {
+ "currency": "EUR",
+ "credit_price": 0,
+ "yearly_price": 0,
+ "monthly_price": 0
+ },
+ "affiliate": {
+ "referral_bonus_credits": 0,
+ "commission_rate_percent": 0
+ },
+ "lifecycle": {
+ "is_public": true
+ },
+ "allowances": {
+ "max_garages": 1,
+ "max_vehicles": 1,
+ "monthly_free_credits": 0
+ },
+ "display_name": "Privát Ingyenes",
+ "entitlements": []
+}
+```
+
+### 1.2 Teljes JSON rules — "Legnagyobb céges" csomag (ID 18: `corp_vip_v1`)
+
+```json
+{
+ "type": "corporate",
+ "pricing": {
+ "currency": "EUR",
+ "credit_price": 15000,
+ "yearly_price": 1499.99,
+ "monthly_price": 149.99
+ },
+ "affiliate": {
+ "referral_bonus_credits": 500,
+ "commission_rate_percent": 25
+ },
+ "lifecycle": {
+ "is_public": true
+ },
+ "allowances": {
+ "max_garages": 50,
+ "max_vehicles": 200,
+ "monthly_free_credits": 2500
+ },
+ "display_name": "Céges VIP",
+ "entitlements": [
+ "SRV_DATA_EXPORT",
+ "SRV_AI_UPLOAD",
+ "SRV_ACCOUNTING_SYNC",
+ "SRV_API_ACCESS"
+ ]
+}
+```
+
+### 1.3 A JSON rules struktúra elemzése
+
+A `rules` JSONB mező egységes szerkezetet követ minden csomagnál:
+
+| Kulcs | Típus | Leírás |
+|-------|-------|--------|
+| `type` | `string` | `"private"` vagy `"corporate"` — a csomag típusa |
+| `pricing` | `object` | Árazási adatok: `currency`, `credit_price`, `yearly_price`, `monthly_price` |
+| `affiliate` | `object` | MLM/affiliate beállítások: `referral_bonus_credits`, `commission_rate_percent` |
+| `lifecycle` | `object` | Életciklus: `is_public` (bool), opcionálisan `available_until` (date/null) |
+| `allowances` | `object` | Korlátok: `max_garages`, `max_vehicles`, `monthly_free_credits` |
+| `display_name` | `string` | Felhasználói felületen megjelenő név |
+| `entitlements` | `string[]` | Jogosultságok listája (pl. `SRV_DATA_EXPORT`, `SRV_AI_UPLOAD`) |
+
+**Felfedezett entitlement kódok:**
+- `SRV_DATA_EXPORT` — Adatexport szolgáltatás
+- `SRV_AI_UPLOAD` — AI feltöltés
+- `SRV_ACCOUNTING_SYNC` — Számviteli szinkron
+- `SRV_API_ACCESS` — API hozzáférés
+
+---
+
+## 2. AUDIT: Current Assignments (`fleet.organizations`)
+
+A `fleet.organizations` táblában jelenleg **2 garázs** rendelkezik nem-NULL `subscription_tier_id` értékkel:
+
+| Org ID | Név | Csomag ID | Csomag Neve | Display Name |
+|--------|-----|-----------|-------------|-------------|
+| 45 | Gyöngyössy garázs | 17 | `corp_premium_plus_v1` | Céges Prémium Plus |
+| 49 | Test Garázsa | 16 | `corp_premium_v1` | Céges Prémium |
+
+**Megjegyzés:** A többi garázs (beleértve az admin garázsait is) `subscription_tier_id = NULL` értékkel rendelkezik, azaz jelenleg nincs hozzájuk rendelve előfizetési csomag.
+
+---
+
+## 3. IDENTIFY: Admin Garages (User ID 2 — admin@profibot.hu)
+
+### 3.1 Tulajdonolt garázsok (owner_id = 2)
+
+| Org ID | Név | Típus (org_type) | Státusz | subscription_tier_id |
+|--------|-----|------------------|---------|---------------------|
+| 67 | Admin Garázsa | `individual` | active | **NULL** |
+
+### 3.2 Tagsági viszonyok (organization_members)
+
+A 2-es user (admin@profibot.hu) az alábbi szervezetekben tag:
+
+| Org ID | Szervezet Neve | Szerepkör | Státusz |
+|--------|---------------|-----------|---------|
+| 1 | **Test Company** | `ADMIN` | active |
+| 57 | Teszt Autószerviz Kft. | `OWNER` | active |
+| 58 | Autónyíri Kft. | `OWNER` | active |
+| 62 | Bokebo Kft. | `OWNER` | active |
+| 67 | **Admin Garázsa** | `OWNER` | active |
+
+### 3.3 Kiemelt garázsok azonosítói
+
+A Tervező által kért két kiemelt garázs:
+
+| Megnevezés | Organization ID | Jelenlegi Csomag |
+|------------|---------------|-----------------|
+| **Test Company** (Céges) | **1** | **NINCS** (NULL) |
+| **Admin Garázsa** (Privát) | **67** | **NINCS** (NULL) |
+
+---
+
+## 4. Összefoglaló és Következtetések
+
+1. **A subscription_tiers rendszer teljes és jól strukturált.** 10 csomag áll rendelkezésre, 4 privát és 6 céges kategóriában. A JSON rules séma egységes, jól bővíthető.
+
+2. **Csak 2 garázs kapott eddig csomagot** (Gyöngyössy garázs → corp_premium_plus_v1, Test Garázsa → corp_premium_v1). A többi garázs, köztük az admin garázsai is, csomag nélküli állapotban vannak.
+
+3. **Az admin két kiemelt garázsa** — `Test Company` (org_id=1) és `Admin Garázsa` (org_id=67) — jelenleg nem rendelkezik előfizetési csomaggal. Ezeket a későbbiekben fel kell címkézni a megfelelő tier-rel.
+
+4. **A JSON rules struktúra lehetővé teszi** a korlátok (max_vehicles, max_garages), árazás, affiliate jutalékok és entitlement-ök (feature flagek) finomhangolását csomagonként.
diff --git a/docs/p0_unified_workspace_impact_analysis_2026-06-18.md b/docs/p0_unified_workspace_impact_analysis_2026-06-18.md
new file mode 100644
index 0000000..aefb5ff
--- /dev/null
+++ b/docs/p0_unified_workspace_impact_analysis_2026-06-18.md
@@ -0,0 +1,189 @@
+# P0 PRE-FLIGHT CHECK: Unified Workspace Impact Analysis
+
+**Dátum:** 2026-06-18
+**Auditor:** Architect Mode
+**Cél:** A 28-as User (tester_pro@profibot.hu) privát járműveinek organization_id auditja, mielőtt bevezetjük az új "Unified Workspace" szabályt (ahol a hozzáférés szigorúan organization_id + OrganizationMember alapú).
+
+---
+
+## 1. 🔍 SZERVEZETI STRUKTÚRA (Organization Audit)
+
+### 1.1 User 28 → Person 29
+
+| Mező | Érték |
+|------|-------|
+| User ID | **28** |
+| Email | tester_pro@profibot.hu |
+| Person ID | **29** |
+| Név | Tester Profibot |
+
+### 1.2 Privát Garázs (individual type)
+
+A 28-as User saját "Privát Garázsa":
+
+| Mező | Érték |
+|------|-------|
+| **Organization ID** | **21** |
+| Név | Private_28 |
+| Típus | `individual` |
+| Tulajdonos (owner_id) | 29 (Person) |
+| folder_slug | priv_28 |
+
+### 1.3 A Person 29 által birtokolt összes szervezet
+
+| Org ID | Név | Típus | Van tagja? |
+|--------|-----|-------|-----------|
+| 15 | Profibot Test Fleet | `fleet_owner` | ❌ NINCS |
+| **21** | **Private_28** | **`individual`** | **❌ NINCS** |
+| 26 | Test Kft. Alpha | `fleet_owner` | ❌ NINCS |
+| 27 | Test Kft. Beta | `fleet_owner` | ❌ NINCS |
+| 38 | Admin Széfe | `individual` | ❌ NINCS |
+
+### 1.4 Ahol Person 29 TAG (OrganizationMember)
+
+| Org ID | Név | Típus | Szerep |
+|--------|-----|-------|--------|
+| 63 | Aszalós Motorszervíz | `service_provider` | ADMIN |
+
+**⚠️ KRITIKUS:** Person 29 NEM tagja a saját privát garázsának (org 21)! A `fleet.organization_members` táblában egyetlen rekord sincs a `Private_28`-hoz.
+
+---
+
+## 2. 🚗 JÁRMŰVEK ÁLLAPOTA (Assets Audit)
+
+### 2.1 Person 29 tulajdonában lévő összes jármű
+
+| # | ID (UUID) | Rendszám | Márka | Modell | current_org_id | owner_org_id | Státusz |
+|---|-----------|----------|-------|--------|---------------|-------------|---------|
+| 1 | deb42aea... | QWE123 | APRILIA | af1 | **44** | 44 | active/draft |
+| 2 | da49269e... | TTRFGHZT | BAYLINER | 1750 CAPRY BOWRIDER | **44** | 44 | active/draft |
+| 3 | 72b0a0cf... | UOK795 | Honda | CB1000R | **NULL** ⚠️ | 34 | active/verified |
+| 4 | 0b97a975... | PKT215 | Mazda | 2 | **NULL** ⚠️ | 34 | active/draft |
+| 5 | 09700990... | AIML519 | Skoda | Citigo e | **NULL** ⚠️ | 34 | active/enriched |
+| 6 | 104d6753... | ABC-123 | Toyota | Corolla | **34** | 34 | active/verified |
+
+### 2.2 Érintett szervezetek részletei
+
+| Org ID | Név | Típus | Tulajdonos |
+|--------|-----|-------|-----------|
+| 21 | Private_28 | `individual` | Person 29 ✅ |
+| 34 | Swagger-Test Kft. | `business` | Nincs tulajdonos (owner_id = NULL) |
+| 44 | Profibot Kft. | `business` | User 86 |
+
+---
+
+## 3. 📊 HATÁSELEMZÉS (Impact Analysis)
+
+### 3.1 Unified Workspace forgatókönyv
+
+Ha bevezetjük a szabályt: **"Egy User csak olyan járművet érhet el, ahol a `current_organization_id` egyezik egy olyan Organization-nel, aminek a User tagja (OrganizationMember)"**:
+
+#### 3.1.1 NULL current_organization_id-val rendelkező járművek
+
+**3 jármű** (Honda CB1000R, Mazda 2, Skoda Citigo e) **jelenleg NULL** `current_organization_id`-val rendelkezik. Ezek:
+- Nincsenek egyetlen szervezethez sem rendelve jelenleg
+- Csak a `owner_person_id = 29` alapján lennének elérhetők
+- **Az új szabály szerint KI lennének ZÁRVA** (orphaned), mert nincs organization contextusuk
+
+#### 3.1.2 Más szervezethez rendelt járművek
+
+**3 jármű** (Toyota Corolla → org 34, Aprilia + Bayliner → org 44) olyan szervezetekhez van rendelve, amelyeknek Person 29 NEM tagja:
+- **Toyota Corolla** (`current_org = 34` = Swagger-Test Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
+- **APRILIA** (`current_org = 44` = Profibot Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
+- **BAYLINER** (`current_org = 44` = Profibot Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
+
+### 3.2 Összegzés
+
+| Kategória | Darab | Járművek | Sorsa új szabály alatt |
+|-----------|-------|----------|----------------------|
+| NULL current_org_id | **3** | Honda, Mazda, Skoda | ❌ ORPHANED |
+| Idegen org (nincs tagság) | **3** | Toyota (org 34), Aprilia+Bayliner (org 44) | ❌ ORPHANED |
+| Saját privát garázsban (org 21) | **0** | — | ✅ OK |
+| **ÖSSZESEN** | **6/6** | **MIND** | **❌ 100%-OS ORPHANED** |
+
+---
+
+## 4. 💾 ELŐKÉSZÍTETT SQL JAVÍTÁS (NE FUTTASD KI - CSAK TERVEZÉS)
+
+### 4.1 Első lépés: Person 29 felvétele a Private_28 (org 21) tagjai közé
+
+```sql
+INSERT INTO fleet.organization_members (organization_id, user_id, person_id, role, is_permanent, is_verified, status)
+VALUES (21, 28, 29, 'OWNER', true, true, 'active');
+```
+
+**VAGY ha a user_id nem elvárás (person_id alapú):**
+
+```sql
+INSERT INTO fleet.organization_members (organization_id, person_id, role, is_permanent, is_verified, status)
+VALUES (21, 29, 'OWNER', true, true, 'active');
+```
+
+### 4.2 Második lépés: NULL current_organization_id javítása
+
+```sql
+UPDATE vehicle.assets
+SET current_organization_id = 21
+WHERE owner_person_id = 29 AND current_organization_id IS NULL;
+```
+
+Ez a **Honda CB1000R** (UOK795), **Mazda 2** (PKT215) és **Skoda Citigo e** (AIML519) járműveket beköti a Privát Garázsba.
+
+### 4.3 Harmadik lépés: Üzleti döntés szükséges
+
+A következő járművekhez **üzleti döntés** kell:
+
+1. **Toyota Corolla** (ABC-123) - `current_org = 34, owner_org = 34`
+ - **Opció A:** Áttenni a Private_28-ba (org 21)
+ - **Opció B:** Felvenni Person 29-et a Swagger-Test Kft. (org 34) tagjai közé
+ - **Opció C:** Megtartani a jelenlegi állapotot és kivételt képezni az új szabály alól
+
+2. **APRILIA af1** (QWE123) + **BAYLINER** (TTRFGHZT) - `current_org = 44, owner_org = 44`
+ - **Opció A:** Áttenni a Private_28-ba (org 21)
+ - **Opció B:** Felvenni Person 29-et a Profibot Kft. (org 44) tagjai közé (de User 86 a tulajdonos!)
+ - **Opció C:** Megtartani a jelenlegi állapotot
+
+### 4.4 Teljes körű SQL javítás (ha az összes járművet a privát garázsba tennénk)
+
+```sql
+-- 1. Person 29 taggá tétele a Private_28-ban
+INSERT INTO fleet.organization_members (organization_id, user_id, person_id, role, is_permanent, is_verified, status)
+VALUES (21, 28, 29, 'OWNER', true, true, 'active');
+
+-- 2. Összes jármű áthelyezése a Private_28-ba
+UPDATE vehicle.assets
+SET current_organization_id = 21
+WHERE owner_person_id = 29;
+```
+
+---
+
+## 5. ⚠️ KIEGÉSZÍTŐ MEGJEGYZÉSEK
+
+### 5.1 User 28 soft-deleted állapota
+
+A korábbi audit ([`docs/db_schema_ownership_audit_2026-06-18.md`](docs/db_schema_ownership_audit_2026-06-18.md)) feltárta, hogy User 28 **soft-deletelve** van (`is_deleted = true`, `deleted_at = 2026-06-14`). Ez azt jelenti:
+- Jelenleg NEM tud bejelentkezni
+- Még az adatjavítás után sem fér hozzá a járművekhez, amíg a fiókja nincs visszaállítva
+
+### 5.2 Tervezési ajánlás
+
+Az új "Unified Workspace" szabály bevezetése ELŐTT kötelező:
+
+1. **Adatjavítás:** A NULL `current_organization_id`-val rendelkező járművek bekötése
+2. **Tagság rögzítése:** Minden `individual` típusú org tulajdonosának automatikus OWNER taggá tétele
+3. **Üzleti járművek felülvizsgálata:** Döntés a org 34-be és 44-be sorolt járművekről
+4. **User 28 restore:** A soft-delete feloldása a KYC/restore folyamaton keresztül
+5. **Átmeneti időszak:** Az `owner_person_id` alapú hozzáférés megtartása fallback-ként, amíg a fenti pontok nem teljesülnek
+
+---
+
+## 6. 🔮 ARCHITECT AJÁNLÁS
+
+**Javaslat:** NE vezessük be a "pure Unified Workspace"-t (kizárólag org + membership alapú hozzáférés), amíg:
+
+1. Az összes `individual` org-nak nincs automatikusan létrehozott OWNER OrganizationMember rekordja
+2. Az összes meglévő jármű `current_organization_id` mezője ki van töltve
+3. A soft-deletelt userek vissza vannak állítva vagy tisztázva van a státuszuk
+
+**Ajánlott átmeneti megoldás:** Tartsd meg az `owner_person_id == user.person_id` ellenőrzést fallback-ként az org-based check mellett, amíg a fenti adatjavítások meg nem történnek.
diff --git a/docs/permission_system_audit_2026-06-18.md b/docs/permission_system_audit_2026-06-18.md
new file mode 100644
index 0000000..a4f9f74
--- /dev/null
+++ b/docs/permission_system_audit_2026-06-18.md
@@ -0,0 +1,275 @@
+# 🔐 Jogosultságkezelés Audit Jelentés (2026-06-18)
+
+## Teljes Rendszerkép a Jogosultsági Szintekről
+
+---
+
+## 1. Adatbázis Réteg (Database Schema)
+
+### 1.1 `identity.users` - Felhasználói Jogosultságok
+
+| Mező | Típus | Leírás |
+|------|-------|--------|
+| `role` | `identity.userrole` enum | Rendszerszintű szerepkör |
+| `scope_level` | `VARCHAR(30)` | Hatókör szint (pl. "individual") |
+| `scope_id` | `VARCHAR(50)` | Hatókör azonosító (pl. org ID vagy user ID) |
+| `custom_permissions` | `JSON` | Egyedi jogosultságok (`{"capabilities": [...]}`) |
+
+**`userrole` enum értékei** (Python `UserRole` osztály, `backend/app/models/identity/identity.py:24`):
+```
+superadmin, admin, region_admin, country_admin, moderator,
+sales_agent, user, service_owner, fleet_manager, driver
+```
+
+> ⚠️ **Probléma**: Az adatbázis enum duplikált értékeket tartalmaz (pl. "admin" és "ADMIN" is létezik, "user" kétszer szerepel). Ez az enum korábbi migrációk során keletkezett.
+
+### 1.2 `fleet.org_roles` - Szervezeti Szerepkör Definíciók
+
+**🔴 KRITIKUS: A tábla ÜRES!**
+
+A modell (`backend/app/models/marketplace/organization.py:34`) definiál egy dinamikus szerepkör rendszert (`OrgRole` modell), de **seed-elés nem történt meg**. A `OrgUserRole` enum (`backend/app/models/marketplace/organization.py:27`) az alábbi értékeket tartja számon:
+```
+OWNER, ADMIN, MANAGER, MEMBER, AGENT
+```
+
+### 1.3 `fleet.organization_members` - Tagságok és Jogosultságok
+
+| Mező | Típus | Leírás |
+|------|-------|--------|
+| `role` | `VARCHAR(50)` | Szerepkör a szervezetben (OWNER/ADMIN/MANAGER/MEMBER/AGENT) |
+| `permissions` | `JSON` | Egyedi JSON jogosultságok |
+| `is_permanent` | `BOOLEAN` | Állandó tagság |
+| `is_verified` | `BOOLEAN` | Megerősített tagság |
+| `status` | `VARCHAR(20)` | Státusz (active/inactive) |
+
+### 1.4 `system.system_parameters` - Hierarchikus Paraméterezés
+
+| Mező | Típus | Leírás |
+|------|-------|--------|
+| `key` | `VARCHAR` | Paraméter kulcs |
+| `value` | `JSONB` | Paraméter érték |
+| `scope_level` | `parameter_scope` enum | Hatókör: GLOBAL, COUNTRY, REGION, USER, ORGANIZATION |
+| `scope_id` | `VARCHAR` | Hatókör azonosító |
+
+**`parameter_scope` enum értékei** (`backend/app/models/system/system.py:12`):
+```
+GLOBAL, COUNTRY, REGION, ORGANIZATION, USER
+```
+
+> ⚠️ **Probléma**: Minden meglévő paraméter `global` scope_level-lel rendelkezik. A hierarchikus scope (country, region, organization, user) **nincs használatban** gyakorlatban.
+
+### 1.5 `system.pending_actions` - Dual Control (Négy Szem Elv)
+
+| Mező | Típus | Leírás |
+|------|-------|--------|
+| `requester_id` | `INTEGER` | Kérelem indítója |
+| `approver_id` | `INTEGER` | Jóváhagyó (nullable) |
+| `status` | `action_status` enum | pending/approved/rejected |
+| `action_type` | `VARCHAR` | CHANGE_ROLE, SET_VIP, WALLET_ADJUST, SOFT_DELETE_USER, ORGANIZATION_TRANSFER |
+| `payload` | `JSONB` | Művelet adatok |
+
+### 1.6 `system.subscription_tiers` - Előfizetési Csomagok
+
+| Mező | Típus | Leírás |
+|------|-------|--------|
+| `name` | `VARCHAR` | Csomag neve (FREE, PREMIUM, VIP, stb.) |
+| `rules` | `JSONB` | Szabályok (pl. `{"max_vehicles": 5}`) |
+
+---
+
+## 2. Backend Kód Réteg
+
+### 2.1 Rang Rendszer - `DEFAULT_RANK_MAP`
+
+Definiálva: `backend/app/core/security.py:56`
+
+| Rang | Érték | Van UserRole enum-ban? |
+|------|-------|----------------------|
+| SUPERADMIN | 100 | ✅ Igen |
+| ADMIN | 90 | ✅ Igen |
+| REGION_ADMIN | 85 | ✅ Igen |
+| COUNTRY_ADMIN | 80 | ✅ Igen |
+| MODERATOR | 75 | ✅ Igen |
+| AUDITOR | 70 | ❌ Nincs |
+| ORGANIZATION_OWNER | 65 | ❌ Nincs |
+| ORGANIZATION_MANAGER | 60 | ❌ Nincs |
+| ORGANIZATION_MEMBER | 50 | ❌ Nincs |
+| SERVICE_PROVIDER | 40 | ❌ Nincs |
+| SALES_AGENT | 30 | ✅ Igen |
+| FLEET_MANAGER | 25 | ✅ Igen |
+| PREMIUM_USER | 20 | ❌ Nincs |
+| USER | 10 | ✅ Igen |
+| DRIVER | 5 | ✅ Igen |
+| GUEST | 0 | ❌ Nincs |
+
+> 🔴 **KRITIKUS**: 16 rang van definiálva, de a `UserRole` enum csak 10 értéket tartalmaz. A hiányzók (AUDITOR, ORGANIZATION_OWNER/MANAGER/MEMBER, SERVICE_PROVIDER, PREMIUM_USER, GUEST) sosem kerülnek kiosztásra!
+
+### 2.2 RBAC Osztály - 3 Szintű Ellenőrzés
+
+Forrás: `backend/app/core/rbac.py`
+
+```
+1. Superadmin check → mindent átenged
+2. Rang ellenőrzés → DEFAULT_RANK_MAP alapján
+3. Custom permissions → capabilities lista ellenőrzése
+```
+
+### 2.3 API Deps - `get_current_admin`
+
+Forrás: `backend/app/api/deps.py:158`
+
+Engedélyezett admin szerepkörök:
+```python
+allowed_roles = {superadmin, admin, region_admin, country_admin, moderator}
+```
+
+### 2.4 Scoped RBAC - `check_resource_access`
+
+Forrás: `backend/app/api/deps.py:106`
+
+```
+- Superadmin → mindenhez hozzáfér
+- Saját ID vagy scope_id egyezés → hozzáfér
+- Egyéb → 403 Forbidden
+```
+
+### 2.5 Dual Control - `SecurityService`
+
+Forrás: `backend/app/services/security_service.py`
+
+| Típus | Leírás |
+|-------|--------|
+| `CHANGE_ROLE` | Szerepkör módosítás |
+| `SET_VIP` | VIP státusz |
+| `WALLET_ADJUST` | Pénztárca módosítás |
+| `SOFT_DELETE_USER` | Felhasználó törlés |
+| `ORGANIZATION_TRANSFER` | Szervezet átadás |
+
+### 2.6 JWT Token Felépítése
+
+Forrás: `backend/app/api/v1/endpoints/auth.py:108`
+
+```json
+{
+ "sub": "user_id",
+ "role": "superadmin",
+ "rank": 100,
+ "scope_level": "individual",
+ "scope_id": "user_id_vagy_org_id"
+}
+```
+
+---
+
+## 3. Frontend Réteg
+
+### 3.1 Frontend Auth Store
+
+Forrás: `frontend/src/stores/authStore.ts:4`
+
+```typescript
+export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
+```
+
+> ⚠️ **Probléma**: A frontend `UserRole` típus **NEM egyezik** a backend `UserRole` enum-jával! Hiányzik: `region_admin`, `country_admin`, `moderator`, `sales_agent`, `service_owner`, `fleet_manager`, `driver`.
+
+### 3.2 Router Admin Guard
+
+Forrás: `frontend/src/router/index.ts:133`
+
+```typescript
+const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
+```
+
+Ez a lista egyezik a backend `get_current_admin` függvényével.
+
+---
+
+## 4. Problémák és Hiányosságok
+
+### 🔴 KRITIKUS Problémák
+
+| # | Probléma | Hely | Hatás |
+|---|----------|------|-------|
+| P1 | `fleet.org_roles` tábla ÜRES | OrgRole modell | Dinamikus szerepkör rendszer nem működik |
+| P2 | Rank map inkonzisztens a UserRole enum-mal | DEFAULT_RANK_MAP vs UserRole | 6 rang sosem kerül kiosztásra |
+| P3 | Enum duplikáció (kis/nagybetű) | userrole és orgtype adatbázis enumok | Migrációs hiba, karbantartási nehézség |
+| P4 | Frontend/backend UserRole eltérés | authStore.ts | Frontend hibás role-okat kezel |
+| P5 | parameter_scope enum 3 sémában | public, system | Inkonzisztens definíciók |
+
+### 🟡 FIGYELMEZTETÉS
+
+| # | Probléma | Hely | Hatás |
+|---|----------|------|-------|
+| W1 | Csak `global` scope használatban | system_parameters | Hierarchikus paraméterezés nem kihasznált |
+| W2 | Minden user `individual` scope_level | users tábla | Többszintű scope nincs implementálva |
+| W3 | Nincs explicit "deny" mechanizmus | RBAC rendszer | Csak elégtelen rangot jelez, explicit tiltás nincs |
+| W4 | custom_permissions nincs validálva | User modell | Bármilyen JSON elfogadott |
+
+---
+
+## 5. Összefüggés Diagram
+
+```mermaid
+graph TB
+ subgraph Frontend
+ FS[authStore.ts 2,450 {{ t('dashboard.monthlyScore') }}
+ {{ t('dashboard.achievements') }}
+ {{ t('dashboard.noVehicles') }} {{ authStore.userName || t('dashboard.guest') }} {{ authStore.user?.email }} {{ authStore.myOrganizations.length }} {{ t('header.myCompanies') }} {{ vehicleStore.vehicles.length }} {{ t('dashboard.totalVehicles') }}
UserRole: guest/user/manager/admin/superadmin]
+ RG[Router Guard
adminRoles: superadmin, admin,
region_admin, country_admin, moderator]
+ end
+
+ subgraph API_Layer
+ JWT[JWT Token payload
sub + role + rank + scope_level + scope_id]
+ DC[Dual Control SecurityService
5 művelettípus]
+ end
+
+ subgraph Backend
+ UR[UserRole enum
10 érték]
+ RM[DEFAULT_RANK_MAP
16 rang - csak 10 használható]
+ RBAC[RBAC osztály
3 szint: superadmin + rank + capabilities]
+ GR[get_current_admin
5 engedélyezett szerepkör]
+ end
+
+ subgraph Database
+ U[identity.users
role + scope_level + scope_id + custom_permissions]
+ OM[fleet.organization_members
role + permissions JSON]
+ OR[fleet.org_roles
🔴 ÜRES! Nincs seed data]
+ SP[system.system_parameters
scope_level + scope_id - csak global használva]
+ PA[system.pending_actions
Dual Control tároló]
+ end
+
+ FS -- eltérő role halmaz --> UR
+ JWT -- kiolvassa --> U
+ RBAC -- ellenőrzi --> RM
+ GR -- engedélyezi --> UR
+ DC -- naplóz --> PA
+ OR -- üres, nem használható --> OM
+```
+
+---
+
+## 6. Azonosított Problémák Részletesen
+
+### P1: `fleet.org_roles` tábla üres
+- A dinamikus szerepkör rendszer teljesen kiépített (OrgRole modell, OrgUserRole enum)
+- De a `fleet.org_roles` táblában egyetlen rekord sincs
+- Következmény: a `organization_members.role` mező validálatlan string-ként működik
+
+### P2: DEFAULT_RANK_MAP inkonzisztencia
+- A `DEFAULT_RANK_MAP` tartalmaz olyan rangokat (AUDITOR, ORGANIZATION_OWNER, PREMIUM_USER, GUEST), amelyek nem szerepelnek a `UserRole` enum-ban
+- Ezek a rangok soha nem kerülnek kiosztásra
+- Megoldás: vagy bővíteni a `UserRole` enumot, vagy törölni a felesleges rangokat
+
+### P3: Enum duplikáció
+- A `userrole` enum minden értéke kétszer szerepel (kis és NAGY betűs változat)
+- Az `orgtype` enum értékei szintén duplikáltak (business/BUSINESS, stb.)
+- Ez a korábbi migrációk során keletkezett
+
+### P4: Frontend-Backend eltérés
+- A frontend auth store 5 role-t definiál: guest, user, manager, admin, superadmin
+- A backend 10 role-t: superadmin, admin, region_admin, country_admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
+- A `manager` frontend role nem létezik a backendben, a `region_admin` és `country_admin` és `moderator` hiányzik a frontendből
+
+### P5: Hierarchikus scope nem kihasznált
+- A `ParameterScope` enum (GLOBAL, COUNTRY, REGION, ORGANIZATION, USER) teljesen kiépített
+- De minden `system_parameter` `global` scope_level-lel rendelkezik
+- A hierarchikus paraméterezés (ország → régió → szervezet → felhasználó) nincs implementálva
diff --git a/frontend/src/components/dashboard/ComplexExpenseModal.vue b/frontend/src/components/dashboard/ComplexExpenseModal.vue
index e31a736..babd7a5 100644
--- a/frontend/src/components/dashboard/ComplexExpenseModal.vue
+++ b/frontend/src/components/dashboard/ComplexExpenseModal.vue
@@ -265,7 +265,8 @@ async function handleSubmit() {
asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset
category_id: categoryId,
- amount_net: form.amount,
+ /** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
+ amount_gross: form.amount,
currency: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,
diff --git a/frontend/src/components/dashboard/CostsActionsCard.vue b/frontend/src/components/dashboard/CostsActionsCard.vue
new file mode 100644
index 0000000..9a8a130
--- /dev/null
+++ b/frontend/src/components/dashboard/CostsActionsCard.vue
@@ -0,0 +1,171 @@
+
+
{{ t('company.subscriptionTitle') || 'Előfizetés' }}
+ + ++ {{ t('company.currentPlan') || 'Jelenlegi csomag' }}: {{ org.subscription_plan }} +
+{{ t('company.addressTitle') }}
@@ -172,6 +214,11 @@ const isLoading = ref(false) const isSaving = ref(false) const org = ref