# Költség/Billing Rendszer Elemzés & Módosítási Javaslat (v2) ## Premium szintű költségbontás — Subscription-alapú kategória láthatóság + Km tracking **Készült:** 2026.06.12. **Verzió:** v2.0 (User feedback alapján átdolgozva) **Státusz:** Jóváhagyásra vár --- ## 1. Jelenlegi Állapot Elemzés ### 1.1 Előfizetési Architektúra | Komponens | Tábla | Mező | Státusz | |-----------|-------|------|---------| | [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `subscription_plan: str` (default="FREE") | ✅ Megvan | | [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `subscription_expires_at: datetime` | ✅ Megvan | | [`User`](backend/app/models/identity/identity.py:122) | `identity.users` | `ui_mode: str` ("personal"/"business") | ✅ Megvan | | [`Organization`](backend/app/models/marketplace/organization.py:35) | `marketplace.organizations` | `subscription_plan: str` (default="FREE") | ✅ Megvan | | [`SubscriptionTier`](backend/app/models/core_logic.py:12) | `system.subscription_tiers` | `name: str`, `rules: JSONB` | ✅ Megvan | | [`subscription_service.py`](backend/app/services/) | **Hiányzó fájl** | Gitea #239 | ❌ **Nincs meg** | | [`subscription_worker.py`](backend/app/workers/system/subscription_worker.py:32) | Cron robot | Auto-downgrade expired → FREE | ✅ Megvan | ### 1.2 Költség Modellek #### [`AssetCost`](backend/app/models/vehicle/asset.py:239) (`vehicle.asset_costs`) — **Modern/Aktív tábla** - **Használja:** [`POST /expenses/`](backend/app/api/v1/endpoints/expenses.py:12), [`CostService.record_cost()`](backend/app/services/cost_service.py:23) - **Mezői:** `id` (UUID), `asset_id`, `organization_id`, `category_id`, `amount_net`, `currency`, `date`, `invoice_number`, `data` (JSONB) - **`data` JSONB tárol:** `mileage_at_cost`, `description` - **⚠️ Nincs subscription check** — bármely felhasználó bármilyen adatot rögzíthet #### [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) (`vehicle.cost_categories`) - **Meglévő mezők:** `id`, `parent_id` (hierarchia), `code`, `name`, `description`, `is_system`, `visibility` ("both"/"b2b"), `accounting_code` - **Jelenlegi visibility map:** FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER → `"both"` ; FINANCE, ADMIN → `"b2b"` - **❌ Nincs `subscription_tier` mező** — nem lehet megkülönböztetni FREE és PREMIUM kategóriákat #### [`Asset.current_mileage`](backend/app/models/vehicle/asset.py:111) — **Meglévő km mező** - `current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)` — Az Asset modellen - **❌ Probléma:** Nincs automatizmus, ami frissítené amikor költséget rögzítenek - **❌ Probléma:** Nincs history tábla a km állás változásainak követésére #### [`AssetTelemetry`](backend/app/models/vehicle/asset.py:336) (`vehicle.asset_telemetry`) - 1:1 kapcsolat Asset-tel, `current_mileage: Mapped[int]` mezővel - **Technikai adósság:** Duplikálja az `Asset.current_mileage` mezőt #### [`VehicleOdometerState`](backend/app/models/vehicle/vehicle.py:111) (`vehicle.vehicle_odometer_states`) - Legacy tábla — `vehicle_model_definitions.id`-hez kötődik (nem az Asset UUID-hez) - Tárol: `last_recorded_odometer`, `daily_avg_distance`, `estimated_current_odometer` - **Nem használható** az új Asset-alapú architektúrában ### 1.3 API Végpontok Állapota | Végpont | Fájl | Subscription Check | Megjegyzés | |---------|------|-------------------|------------| | `POST /expenses/` | [`expenses.py:12`](backend/app/api/v1/endpoints/expenses.py:12) | ❌ | Csak DRAFT limit check van | | `GET /{vehicle_id}/summary` | [`analytics.py:66`](backend/app/api/v1/endpoints/analytics.py:66) | ❌ | TCO summary | | `GET /dashboard` | [`analytics.py:199`](backend/app/api/v1/endpoints/analytics.py:199) | ❌ | Mock data | | `POST /upgrade` | [`billing.py:20`](backend/app/api/v1/endpoints/billing.py:20) | ✅ | Megvan | ### 1.4 Hiányosságok (Gap Analysis) | # | Hiányosság | Leírás | Súlyosság | |---|-----------|--------|-----------| | 1 | **Nincs subscription gating** | A `POST /expenses/` nem ellenőrzi a subscription_plan-t | 🔴 Kritikus | | 2 | **Hiányzó `subscription_service.py`** | Gitea #239 — entitlement mátrix hiányzik | 🔴 Blokkoló | | 3 | **Nincs `CostCategory.min_tier` mező** | Nem lehet megkülönböztetni FREE/PREMIUM kategóriákat | 🟡 Magas | | 4 | **Km tracking nincs centralizálva** | `Asset.current_mileage` létezik, de nincs automatikus frissítés | 🟡 Magas | | 5 | **Nincs OdometerLog history** | Nincs audit trail a km állás változásokról | 🟡 Közepes | | 6 | **Párhuzamos telemetry tábla** | `AssetTelemetry` duplikálja `Asset.current_mileage`-t | 🟠 Alacsony | --- ## 2. Módosítási Javaslat (Logic Spec v2) ### 2.1 CostCategory Bővítés — `min_tier` mező **Nem kell új `UserCostCategory` modell!** A meglévő [`CostCategory`](backend/app/models/vehicle/vehicle.py:19) kap egy új mezőt: ```python class CostCategory(Base): # ... meglévő mezők ... # ÚJ: Minimum előfizetési szint a kategória használatához min_tier: Mapped[str] = mapped_column( String(20), default="free", server_default="'free'" ) ``` **CostCategory seed adat — kategória szintek:** | Kategória CSoport | `code` értékek | `visibility` | `min_tier` | Leírás | |-------------------|---------------|-------------|-----------|--------| | **Alap (FREE)** | FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, OTHER | `both` | `free` | Mindenki számára elérhető | | **Kiterjesztett (PREMIUM)** | PARKING, TOLL, FINANCE, ADMIN | `both`/`b2b` | `premium` | Csak PREMIUM+ tagoknak | | **Vállalati (VIP)** | *(speciális account kódok)* | `b2b` | `vip` | Csak VIP ügyfeleknek | ### 2.2 Odometer Rendszer — Centralizált Km Tracking #### 2.2.1 Új modell: [`OdometerReading`](backend/app/models/vehicle/asset.py) ```python class OdometerReading(Base): """ Kilométeróra állások history audit trail. Minden esemény (költség, szerviz, telemetria) itt rögzíti a km állást. Csak APPEND-only — soha nem törlődik, csak új rekord kerül hozzá. """ __tablename__ = "odometer_readings" __table_args__ = {"schema": "vehicle"} id: Mapped[int] = mapped_column(Integer, primary_key=True) asset_id: Mapped[uuid.UUID] = mapped_column( PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id", ondelete="CASCADE"), index=True, nullable=False ) mileage: Mapped[int] = mapped_column(Integer, nullable=False) # km állás recorded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) source: Mapped[str] = mapped_column(String(50), nullable=False) # source lehet: 'cost_entry', 'manual', 'telemetry', 'import', 'service_event' source_id: Mapped[Optional[str]] = mapped_column(String(100)) # source_id lehet: a kapcsolódó AssetCost UUID-ja, vagy esemény ID asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings") ``` #### 2.2.2 [`Asset`](backend/app/models/vehicle/asset.py:69) — Bővítés ```python class Asset(Base): # ... meglévő mezők ... # MEGLÉVŐ — marad, de automatikusan frissül: current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True) # ÚJ kapcsolat: odometer_readings: Mapped[List["OdometerReading"]] = relationship( "OdometerReading", back_populates="asset" ) ``` #### 2.2.3 [`CostService.record_cost()`](backend/app/services/cost_service.py:23) — Km frissítés ```python class CostService: async def record_cost(self, db: AsyncSession, cost_in: AssetCostCreate, user_id: int): # 1. Meglévő logika (OCR, XP, currency conversion) ... mileage = cost_in.data.get("mileage_at_cost") if cost_in.data else None # 2. NEW: OdometerReading log + Asset.current_mileage frissítés if mileage is not None: # a) Logold a km állást reading = OdometerReading( asset_id=cost_in.asset_id, mileage=mileage, source="cost_entry", source_id=str(new_cost.id) ) db.add(reading) # b) Frissítsd az Asset.current_mileage mezőt (ha nagyobb) asset = await db.get(Asset, cost_in.asset_id) if asset and mileage > asset.current_mileage: asset.current_mileage = mileage # 3. Meglévő logika folytatása ... ``` ### 2.3 `subscription_service.py` — Entitlement Mátrix (Gitea #239) ```python """ subscription_service.py — Gitea #239 Feature entitlement mátrix subscription szintek szerint. """ from enum import Enum class Feature(str, Enum): EXTENDED_CATEGORIES = "extended_categories" # PARKING, TOLL, FINANCE, ADMIN DOCUMENT_ATTACHMENT = "document_attachment" VAT_TRACKING = "vat_tracking" TCO_BREAKDOWN = "tco_breakdown" EXPORT_CSV = "export_csv" ANALYTICS_DASHBOARD = "analytics_dashboard" FEATURE_ENTITLEMENTS = { "FREE": [ Feature.TCO_BREAKDOWN, # Basic összesített TCO ], "PREMIUM": [ Feature.EXTENDED_CATEGORIES, # Kiterjesztett kategóriák Feature.DOCUMENT_ATTACHMENT, Feature.VAT_TRACKING, Feature.TCO_BREAKDOWN, # Részletes bontás Feature.EXPORT_CSV, Feature.ANALYTICS_DASHBOARD, ], "VIP": [ Feature.EXTENDED_CATEGORIES, Feature.DOCUMENT_ATTACHMENT, Feature.VAT_TRACKING, Feature.TCO_BREAKDOWN, Feature.EXPORT_CSV, Feature.ANALYTICS_DASHBOARD, ], } async def check_feature_access(db, user_id, feature): """Ellenőrzi a feature elérést a user subscription alapján.""" user = await db.get(User, user_id) if not user: return False return feature in FEATURE_ENTITLEMENTS.get(user.subscription_plan, []) async def require_feature(db, user_id, feature): """403-as hibát dob ha nincs hozzáférés.""" if not await check_feature_access(db, user_id, feature): raise HTTPException( status_code=403, detail=f"Feature '{feature.value}' requires PREMIUM subscription" ) async def get_available_categories(db, user_id): """Visszaadja a user számára elérhető CostCategory listát.""" user = await db.get(User, user_id) plan = user.subscription_plan if user else "FREE" query = """ SELECT * FROM vehicle.cost_categories WHERE min_tier IN ('free', :plan) ORDER BY parent_id NULLS FIRST, sort_order """ result = await db.execute(text(query), {"plan": plan.lower()}) return result.mappings().all() ``` ### 2.4 Végpont Módosítások #### 2.4.1 [`POST /expenses/`](backend/app/api/v1/endpoints/expenses.py:12) ```python @router.post("/", status_code=201) async def create_expense( expense: AssetCostCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): # 1. Ellenőrizd, hogy a kategória elérhető-e a user számára category = await db.get(CostCategory, expense.category_id) if not category: raise HTTPException(status_code=404, detail="Category not found") if category.min_tier != "free" and current_user.subscription_plan == "FREE": raise HTTPException( status_code=403, detail=f"Category '{category.name}' requires PREMIUM subscription" ) # 2. PREMIUM+ dokumentum csatolás check if expense.document_id: await require_feature(db, current_user.id, Feature.DOCUMENT_ATTACHMENT) # 3. Létrehozás new_cost = AssetCost( asset_id=expense.asset_id, organization_id=organization_id, category_id=expense.category_id, amount_net=expense.amount_net, currency=expense.currency, date=expense.date, invoice_number=expense.invoice_number, data=expense.data ) db.add(new_cost) await db.flush() # Kell az ID-hoz # 4. Odometer frissítés (ha van mileage_at_cost) if expense.data and expense.data.get("mileage_at_cost"): mileage = int(expense.data["mileage_at_cost"]) reading = OdometerReading( asset_id=expense.asset_id, mileage=mileage, source="cost_entry", source_id=str(new_cost.id) ) db.add(reading) asset = await db.get(Asset, expense.asset_id) if asset and mileage > asset.current_mileage: asset.current_mileage = mileage await db.commit() await db.refresh(new_cost) ``` #### 2.4.2 `GET /cost-categories/` — ÚJ végpont ```python @router.get("/cost-categories/") async def list_cost_categories( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Visszaadja a user számára elérhető költségkategóriákat.""" categories = await get_available_categories(db, current_user.id) return categories ``` ### 2.5 [`AssetCost`](backend/app/models/vehicle/asset.py:239) — Bővítés (minimális) Csak a `document_id` mező kerül hozzáadásra (az Evidence Storeba mutató hivatkozás). A többi funkció (VAT, notes) a meglévő `data` JSONB-ben tárolható, és csak PREMIUM-nak elérhető a frontenden. ```python class AssetCost(Base): # ... meglévő mezők ... # ÚJ (opcionális, PREMIUM feature): document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True)) ``` ### 2.6 Admin Kontroll (SystemParameter) | Paraméter | Scope | Default | Leírás | |-----------|-------|---------|--------| | `COST_EXTENDED_CATEGORIES_ENABLED` | GLOBAL | `true` | Kiterjesztett kategóriák bekapcsolása | | `COST_VAT_TRACKING_ENABLED` | GLOBAL | `false` | ÁFA követés bekapcsolása | | `COST_ODOMETER_REQUIRED` | GLOBAL | `true` | Km állás kötelező legyen-e | --- ## 3. Folyamatábra ```mermaid flowchart TD subgraph REQUEST["POST /expenses/"] A[Bejövő kérés] --> B{Van category_id?} B -->|Igen| C[CostCategory lekérése] C --> D{min_tier == free?} D -->|Igen| E[Engedélyezve] D -->|Nem| F{User subscription?} F -->|FREE| G[403 - Premium feature] F -->|PREMIUM+| H[Engedélyezve] E --> I[AssetCost létrehozása] H --> I I --> J{Van mileage_at_cost?} J -->|Igen| K[OdometerReading log] K --> L{ mileage > Asset.current_mileage ?} L -->|Igen| M[Asset.current_mileage frissítés] L -->|Nem| N[Nincs frissítés] M --> O[Commit] J -->|Nem| O N --> O end subgraph READ["GET /cost-categories/"] P[User subscription] --> Q{subscription_plan} Q -->|FREE| R[Csak free kategóriák] Q -->|PREMIUM+| S[Free + premium kategóriák] end ``` --- ## 4. Implementációs Terv ### 4.1 Lépések | # | Lépés | Érintett fájlok | Függőség | |---|-------|-----------------|----------| | 1 | **`subscription_service.py`** létrehozása | `backend/app/services/subscription_service.py` | — | | 2 | **`CostCategory.min_tier`** mező hozzáadása | [`backend/app/models/vehicle/vehicle.py:19`](backend/app/models/vehicle/vehicle.py:19) | #1 | | 3 | **`OdometerReading`** modell létrehozása | [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py) | — | | 4 | **`Asset`** kapcsolat + `AssetCost.document_id` | [`backend/app/models/vehicle/asset.py:69`](backend/app/models/vehicle/asset.py:69) | #3 | | 5 | **Pydantic sémák bővítése** | [`backend/app/schemas/asset_cost.py`](backend/app/schemas/asset_cost.py) | #2, #4 | | 6 | **`CostService.record_cost()`** — km frissítés | [`backend/app/services/cost_service.py:23`](backend/app/services/cost_service.py:23) | #3 | | 7 | **`POST /expenses/`** — subscription gating + km log | [`backend/app/api/v1/endpoints/expenses.py:12`](backend/app/api/v1/endpoints/expenses.py:12) | #1, #2, #6 | | 8 | **`GET /cost-categories/`** — új végpont | [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) | #2 | | 9 | **Seed script** — `min_tier` értékek beállítása | `backend/scripts/seed_cost_category_tiers.py` | #2 | | 10 | **Sync engine + tesztek** | `backend/app/scripts/sync_engine.py` | #2-#5 | ### 4.2 Adatbázis Szinkron ```bash # Modellek módosítása után: docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py ``` ### 4.3 Seed Script — Kategória szintek beállítása ```python # backend/scripts/seed_cost_category_tiers.py """CostCategory min_tier mező seedelése.""" TIER_MAP = { # FREE kategóriák (mindenki számára) "FUEL": "free", "MAINTENANCE": "free", "SERVICE": "free", "TIRE": "free", "INSURANCE": "free", "OTHER": "free", # PREMIUM kategóriák (csak előfizetőknek) "PARKING": "premium", "TOLL": "premium", "FINANCE": "premium", "ADMIN": "premium", # VIP kategóriák (speciális) # ... } ``` --- ## 5. Összefoglalás ### Mit változtat a terv: 1. **FREE felhasználók:** Csak az alábbi kategóriákba rögzíthetnek költséget: FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, OTHER. A km állást továbbra is megadhatják, és az automatikusan frissíti az `Asset.current_mileage` mezőt. 2. **PREMIUM+ felhasználók:** Minden kategória elérhető (FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, FINANCE, ADMIN, OTHER). Plusz: dokumentum csatolás, ÁFA követés, részletes TCO bontás. 3. **Km tracking:** Az `OdometerReading` tábla minden költségrögzítéskor naplózza a km állást. Az `Asset.current_mileage` automatikusan frissül (ha az új érték nagyobb). Ez folyamatos, megbízható audit trail-et biztosít. ### Amit NEM kell megvalósítani (v2 törlések): - ❌ `UserCostCategory` modell — a felhasználók nem hoznak létre saját kategóriákat - ❌ `CostTag` / `AssetCostTag` — nem kell külön címke rendszer - ❌ `AssetCost.user_category_id` — kategória kiválasztás nem szükséges ### Blokkoló: - **Gitea #239 (`subscription_service.py`)** — ezt mindenképp létre kell hozni az entitlement mátrixhoz --- **Jóváhagyásra vár:** Kérem a fenti v2 terv áttekintését. Ha elfogadásra kerül, létrehozom a Gitea kártyákat és megkezdjük az implementációt.