Files
service-finder/plans/cost_billing_analysis_premium_breakdown_v1.md

18 KiB

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 identity.users subscription_plan: str (default="FREE") Megvan
User identity.users subscription_expires_at: datetime Megvan
User identity.users ui_mode: str ("personal"/"business") Megvan
Organization marketplace.organizations subscription_plan: str (default="FREE") Megvan
SubscriptionTier system.subscription_tiers name: str, rules: JSONB Megvan
subscription_service.py Hiányzó fájl Gitea #239 Nincs meg
subscription_worker.py Cron robot Auto-downgrade expired → FREE Megvan

1.2 Költség Modellek

AssetCost (vehicle.asset_costs) — Modern/Aktív tábla

  • Használja: POST /expenses/, CostService.record_cost()
  • 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 (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_mileageMeglé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 (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 (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 Csak DRAFT limit check van
GET /{vehicle_id}/summary analytics.py:66 TCO summary
GET /dashboard analytics.py:199 Mock data
POST /upgrade 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 kap egy új mezőt:

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

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 — Bővítés

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() — Km frissítés

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)

"""
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/

@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

@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 — 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.

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

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 #1
3 OdometerReading modell létrehozása backend/app/models/vehicle/asset.py
4 Asset kapcsolat + AssetCost.document_id backend/app/models/vehicle/asset.py:69 #3
5 Pydantic sémák bővítése backend/app/schemas/asset_cost.py #2, #4
6 CostService.record_cost() — km frissítés backend/app/services/cost_service.py:23 #3
7 POST /expenses/ — subscription gating + km log 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 #2
9 Seed scriptmin_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

# 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

# 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.