446 lines
18 KiB
Markdown
446 lines
18 KiB
Markdown
# 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.
|