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) dataJSONB 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_tiermező — nem lehet megkülönböztetni FREE és PREMIUM kategóriákat
Asset.current_mileage — 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 (vehicle.asset_telemetry)
- 1:1 kapcsolat Asset-tel,
current_mileage: Mapped[int]mezővel - Technikai adósság: Duplikálja az
Asset.current_mileagemező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 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
# 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:
-
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_mileagemezőt. -
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.
-
Km tracking: Az
OdometerReadingtábla minden költségrögzítéskor naplózza a km állást. AzAsset.current_mileageautomatikusan 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):
- ❌
UserCostCategorymodell — 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.