-
-
-
{{ authStore.userName || t('dashboard.guest') }}
-
{{ authStore.user?.email }}
+
+
+
+
+
+
+
{{ authStore.userName || t('dashboard.guest') }}
+
{{ authStore.user?.email }}
+
+
+
+
+ Trust Score
+ A+
-
-
-
- Trust Score
- A+
-
-
+
-
-
-
-
{{ authStore.myOrganizations.length }}
-
{{ t('header.myCompanies') }}
-
-
-
{{ vehicleStore.vehicles.length }}
-
{{ t('dashboard.totalVehicles') }}
-
+
+
+
+
+
{{ authStore.myOrganizations.length }}
+
{{ t('header.myCompanies') }}
+
+
+
{{ vehicleStore.vehicles.length }}
+
{{ t('dashboard.totalVehicles') }}
-
-
-
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/subscription/PlanDetailsModal.vue b/frontend/src/components/subscription/PlanDetailsModal.vue
new file mode 100644
index 0000000..4d5d7b0
--- /dev/null
+++ b/frontend/src/components/subscription/PlanDetailsModal.vue
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ plan?.rules?.display_name || plan?.name }}
+
+
+ {{ plan.rules.marketing.subtitle }}
+
+
+
+
+
+
+
+
+
+
+ {{ isYearly ? formatPrice(resolvedYearlyPrice) : formatPrice(resolvedMonthlyPrice) }}
+
+
+ / {{ isYearly ? t('subscription.year') : t('subscription.month') }}
+
+
+ {{ t('subscription.savePercent', { percent: savingsPercent }) }}
+
+
+
+
+
+
+ {{ t('subscription.monthly') }}
+
+
+
+ {{ t('subscription.yearly') }}
+
+
+
+
+
+
+
{{ plan?.rules?.allowances?.max_vehicles ?? '—' }}
+
{{ t('subscription.maxVehicles') }}
+
+
+
{{ plan?.rules?.allowances?.max_garages ?? '—' }}
+
{{ t('subscription.maxGarages') }}
+
+
+
{{ plan?.rules?.allowances?.monthly_free_credits ?? '—' }}
+
{{ t('subscription.freeCredits') }}
+
+
+
+
+
+
+ {{ t('subscription.includedServices') }}
+
+
+
+ -
+
+
+
+
+
+ {{ t('subscription.features.adFree') }}
+
+
+
+ -
+
+
+
+ {{ entitlementLabel(entitlement) }}
+
+
+ -
+
+
+
+
+ {{ plan.rules.affiliate.commission_rate_percent }}% {{ t('subscription.features.affiliateCommission') || 'Partner jutalék' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/BaseCard.vue b/frontend/src/components/ui/BaseCard.vue
new file mode 100644
index 0000000..554d216
--- /dev/null
+++ b/frontend/src/components/ui/BaseCard.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index 8f49947..1899672 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -6,6 +6,39 @@ export default {
saving: 'Saving...',
optional: 'optional',
select: 'Select',
+ retry: 'Retry',
+ },
+ subscription: {
+ title: 'Subscription Plans',
+ subtitle: 'Choose the plan that suits you best and maximize your garage\'s potential!',
+ loadError: 'Failed to load subscription plans.',
+ month: 'month',
+ year: 'year',
+ free: 'Free',
+ currentPlan: 'Current Plan',
+ selectPlan: 'Switch to this plan',
+ detailsAndPurchase: 'Details & Purchase',
+ noPlan: 'No active subscription',
+ daysLeft: 'days left',
+ expired: 'Expired',
+ monthly: 'Monthly',
+ yearly: 'Yearly',
+ savePercent: 'Save {percent}%',
+ orderSimulation: 'Order (Simulation)',
+ planDetails: 'Plan Details',
+ includedServices: 'Included Services',
+ maxVehicles: 'Max vehicles',
+ maxGarages: 'Max garages',
+ freeCredits: 'Free credits',
+ features: {
+ vehicles: 'vehicles',
+ members: 'members',
+ storage: 'storage',
+ analytics: 'Data export & analytics',
+ apiAccess: 'API access',
+ prioritySupport: 'Priority support',
+ adFree: 'Ad-free',
+ },
},
landing: {
openGarage: 'Open Garage',
diff --git a/frontend/src/i18n/hu.ts b/frontend/src/i18n/hu.ts
index 1594255..d852dfc 100644
--- a/frontend/src/i18n/hu.ts
+++ b/frontend/src/i18n/hu.ts
@@ -6,6 +6,39 @@ export default {
saving: 'Mentés...',
optional: 'opcionális',
select: 'Válassz',
+ retry: 'Újrapróbálkozás',
+ },
+ subscription: {
+ title: 'Előfizetési Csomagok',
+ subtitle: 'Válaszd ki a számodra megfelelő csomagot, és maximalizáld a garázsod potenciálját!',
+ loadError: 'Hiba történt a csomagok betöltése közben.',
+ month: 'hó',
+ year: 'év',
+ free: 'Ingyenes',
+ currentPlan: 'Jelenlegi csomagod',
+ selectPlan: 'Váltás erre a csomagra',
+ detailsAndPurchase: 'Részletek & Vásárlás',
+ noPlan: 'Nincs aktív előfizetés',
+ daysLeft: 'nap van hátra',
+ expired: 'Lejárt',
+ monthly: 'Havi',
+ yearly: 'Éves',
+ savePercent: '{percent}% megtakarítás',
+ orderSimulation: 'Megrendelés (Szimuláció)',
+ planDetails: 'Csomag Részletei',
+ includedServices: 'Tartalmazott szolgáltatások',
+ maxVehicles: 'Max. jármű',
+ maxGarages: 'Max. garázs',
+ freeCredits: 'Ingyenes kredit',
+ features: {
+ vehicles: 'jármű',
+ members: 'tag',
+ storage: 'tárhely',
+ analytics: 'Adatexport és analitika',
+ apiAccess: 'API hozzáférés',
+ prioritySupport: 'Prioritásos támogatás',
+ adFree: 'Reklámmentes',
+ },
},
landing: {
openGarage: 'Garázs Nyitása',
diff --git a/frontend/src/layouts/OrganizationLayout.vue b/frontend/src/layouts/OrganizationLayout.vue
index cdddbe7..814fa1e 100644
--- a/frontend/src/layouts/OrganizationLayout.vue
+++ b/frontend/src/layouts/OrganizationLayout.vue
@@ -47,6 +47,17 @@
{{ t('company.companyDataMenu') }}
+
+
+
@@ -73,7 +84,7 @@
+
+
diff --git a/plans/p0_architecture_subscription_stacking_ad_engine.md b/plans/p0_architecture_subscription_stacking_ad_engine.md
new file mode 100644
index 0000000..3503955
--- /dev/null
+++ b/plans/p0_architecture_subscription_stacking_ad_engine.md
@@ -0,0 +1,962 @@
+# P0 Architecture Plan — Subscription Stacking & Extensible Ad Engine
+
+**Dátum:** 2026-06-18
+**Szerző:** Fast Coder (Core Developer)
+**Státusz:** Tervezési fázis — Jóváhagyásra vár
+**Cél:** Teljes architekturális terv a Subscription Time Stacking (napok halmozása) és a bővíthető Native Ad Engine (kampányok, kreatívok, elhelyezések, analitika) megvalósításához.
+
+---
+
+## 1. SUBSCRIPTION STACKING MATH
+
+### 1.1 Problémafelvetés
+
+Jelenleg a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény minden híváskor **felülírja** a `valid_from` mezőt `datetime.utcnow()`-ra, és **nem állítja be** a `valid_until` mezőt. Ez azt jelenti, hogy:
+
+- Ha egy szervezet ma megveszi a 30 napos csomagot, majd 10 nap múlva **hozzáad** még 30 napot, a hátralévő 20 nap **elveszik**.
+- A `valid_until` mező minden `OrganizationSubscription` és `UserSubscription` rekordban `NULL` marad.
+
+### 1.2 Megoldás: `duration_days` a JSONB `rules`-ban
+
+Minden `subscription_tiers.rules` JSONB blokkba bevezetésre kerül egy új `duration_days` mező, amely fix napokban határozza meg a csomag időtartamát. Ez elkerüli a naptári anomáliákat (pl. február 28., hónap vége).
+
+#### 1.2.1 JSONB `rules` bővítés
+
+```json
+{
+ "type": "private",
+ "display_name": "Privát Pro",
+ "duration_days": 30,
+ "pricing": {
+ "monthly_price": 4.99,
+ "yearly_price": 49.99,
+ "currency": "EUR",
+ "credit_price": 500
+ },
+ "allowances": {
+ "max_vehicles": 3,
+ "max_garages": 1,
+ "monthly_free_credits": 50
+ },
+ "entitlements": ["SRV_DATA_EXPORT"],
+ "lifecycle": { "is_public": true }
+}
+```
+
+**Tervezett `duration_days` értékek csomagonként:**
+
+| Csomag | `duration_days` | Megjegyzés |
+|--------|----------------|------------|
+| `private_free_v1` | `null` (korlátlan) | Ingyenes csomag, nem jár le |
+| `private_pro_v1` | `30` | Havi |
+| `private_vip_v1` | `30` | Havi |
+| `corp_premium_v1` | `30` | Havi |
+| `corp_premium_plus_v1` | `30` | Havi |
+| `corp_vip_v1` | `30` | Havi |
+| `private_test_v01` | `365` | Teszt — 1 év |
+| `org_test_v01` | `365` | Teszt — 1 év |
+| `corp_free_v1` | `null` (korlátlan) | Ingyenes céges |
+
+#### 1.2.2 Pydantic Schema Bővítés
+
+A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése:
+
+```python
+class SubscriptionRulesModel(BaseModel):
+ type: str = Field(..., pattern=r"^(private|corporate)$")
+ display_name: Optional[str] = None
+ duration_days: Optional[int] = Field(
+ default=None,
+ ge=1,
+ description="Csomag időtartama napokban. null = korlátlan (pl. ingyenes csomag)"
+ )
+ pricing: Optional[PricingModel] = None
+ allowances: Optional[AllowancesModel] = None
+ entitlements: List[str] = []
+ affiliate: Optional[AffiliateModel] = None
+ lifecycle: Optional[LifecycleModel] = None
+ ad_policy: Optional[AdPolicyModel] = None # Lásd 2. fejezet
+```
+
+### 1.3 Stacking Logika: `upgrade_org_subscription()` Átdolgozása
+
+A [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény új logikája:
+
+```python
+async def upgrade_org_subscription(
+ db: AsyncSession,
+ org_id: int,
+ tier_id: int,
+ actor_user_id: int
+) -> Dict[str, Any]:
+ """
+ Szervezet előfizetésének beállítása időhalmozással (Stacking).
+
+ Stacking logika:
+ 1. Ha az aktuális előfizetés MÉG ÉRVÉNYES (valid_until > now):
+ - Az új valid_until = régi valid_until + duration_days nap
+ - Az időhalmozás (stacking) megtörténik
+ 2. Ha az aktuális előfizetés LEJÁRT (valid_until < now) vagy NINCS:
+ - Az új valid_until = now + duration_days nap
+ - Friss kezdés
+ 3. Ha duration_days = null (korlátlan csomag):
+ - valid_until = null (soha nem jár le)
+ """
+ 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. Olvassuk ki a duration_days-t a tier rules-ból
+ rules = tier.rules or {}
+ duration_days = rules.get("duration_days")
+
+ # 4. Számítsuk ki az új valid_until-t (STACKING LOGIKA)
+ now = datetime.utcnow()
+ new_valid_until = None # Alapértelmezett: korlátlan
+
+ if duration_days is not None:
+ # Korlátozott időtartamú csomag
+ # Keressük meg az aktuális aktív előfizetést
+ 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 and existing_sub.valid_until and existing_sub.valid_until > now:
+ # STACKING: Még érvényes előfizetéshez hozzáadjuk az új napokat
+ remaining_days = (existing_sub.valid_until - now).days
+ logger.info(
+ f"Subscription stacking for org_id={org_id}: "
+ f"remaining={remaining_days}d, adding={duration_days}d"
+ )
+ new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
+ else:
+ # Nincs érvényes előfizetés — friss kezdés
+ new_valid_until = now + timedelta(days=duration_days)
+
+ # 5. Állítsa be a subscription_tier_id-t a szervezeten
+ org.subscription_tier_id = tier_id
+ org.subscription_plan = tier.name
+
+ # 6. Extract max_vehicles from tier rules
+ max_vehicles = rules.get("allowances", {}).get("max_vehicles", 1)
+ org.base_asset_limit = max_vehicles
+
+ # 7. Deaktiváljuk a régi előfizetést
+ if existing_sub:
+ existing_sub.is_active = False
+ existing_sub.valid_until = now # Meddig volt érvényben
+
+ # 8. Új aktív subscription rekord
+ new_sub = OrganizationSubscription(
+ org_id=org_id,
+ tier_id=tier_id,
+ valid_from=now,
+ valid_until=new_valid_until,
+ 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"valid_from={now}, valid_until={new_valid_until}, "
+ f"max_vehicles={max_vehicles}, actor={actor_user_id}"
+ )
+
+ return {
+ "success": True,
+ "organization_id": org_id,
+ "tier_id": tier_id,
+ "tier_name": tier.name,
+ "max_vehicles": max_vehicles,
+ "valid_from": now.isoformat(),
+ "valid_until": new_valid_until.isoformat() if new_valid_until else None,
+ "message": f"Organization {org_id} upgraded to {tier.name}"
+ }
+```
+
+### 1.4 Stacking Matematikai Összefoglaló
+
+```
+Képlet:
+ IF existing_sub.valid_until > NOW:
+ new_valid_until = existing_sub.valid_until + duration_days
+ ELSE:
+ new_valid_until = NOW + duration_days
+
+Példa:
+ - Március 1.: 30 napos csomag vásárlás → valid_until = március 31.
+ - Március 15.: Újabb 30 nap hozzáadása → valid_until = március 31. + 30 nap = április 30.
+ - Eredmény: 45 nap maradt (március 15-től április 30-ig) ahelyett, hogy csak 30 nap lenne.
+```
+
+### 1.5 `upgrade_subscription()` (User-level) Átdolgozása
+
+A [`upgrade_subscription()`](backend/app/services/billing_engine.py:722) függvény hasonló stacking logikát kap a `UserSubscription` rekordokhoz:
+
+```python
+async def upgrade_subscription(
+ db: AsyncSession,
+ user_id: int,
+ target_package: str
+) -> Dict[str, Any]:
+ """
+ Felhasználói előfizetés frissítése időhalmozással.
+ Ugyanaz a stacking logika, mint az org változatban.
+ """
+ from app.models.core_logic import SubscriptionTier, UserSubscription
+ from app.models.identity import User
+
+ # 1. Ellenőrizze, hogy a cél csomag létezik-e
+ stmt = select(SubscriptionTier).where(SubscriptionTier.name == target_package)
+ result = await db.execute(stmt)
+ tier = result.scalar_one_or_none()
+ if not tier:
+ raise ValueError(f"Subscription tier '{target_package}' not found")
+
+ rules = tier.rules or {}
+ duration_days = rules.get("duration_days")
+ now = datetime.utcnow()
+
+ # 2. Stacking logika a UserSubscription rekordokon
+ new_valid_until = None
+ if duration_days is not None:
+ sub_stmt = select(UserSubscription).where(
+ UserSubscription.user_id == user_id,
+ UserSubscription.is_active == True
+ )
+ sub_result = await db.execute(sub_stmt)
+ existing_sub = sub_result.scalar_one_or_none()
+
+ if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now:
+ new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
+ else:
+ new_valid_until = now + timedelta(days=duration_days)
+
+ # 3. Ár kiszámítása és levonás (meglévő logika)
+ price = rules.get("price", 0.0)
+ # ... (ár számítás, wallet levonás)
+
+ # 4. Frissítse a felhasználó adatait
+ user_stmt = select(User).where(User.id == user_id)
+ user_result = await db.execute(user_stmt)
+ user = user_result.scalar_one()
+ user.subscription_plan = target_package
+ user.subscription_expires_at = new_valid_until # Már nem 30 nap fix!
+
+ # 5. UserSubscription rekord frissítése
+ if existing_sub:
+ existing_sub.is_active = False
+ existing_sub.valid_until = now
+
+ new_sub = UserSubscription(
+ user_id=user_id,
+ tier_id=tier.id,
+ valid_from=now,
+ valid_until=new_valid_until,
+ is_active=True
+ )
+ db.add(new_sub)
+
+ return {
+ "success": True,
+ "new_plan": target_package,
+ "price_paid": price,
+ "valid_from": now.isoformat(),
+ "valid_until": new_valid_until.isoformat() if new_valid_until else None
+ }
+```
+
+---
+
+## 2. EXTENSIBLE AD ENGINE SCHEMA
+
+### 2.1 Új Adatbázis Séma: `marketing` Schema
+
+A Native Ad Engine egy új `marketing` adatbázis sémában kap helyet, elkülönítve a core üzleti logikától. Ez biztosítja a jövőbeli bővíthetőséget (pl. kattintások, konverziók, A/B tesztelés).
+
+#### 2.1.1 SQLAlchemy Modellek
+
+```python
+# backend/app/models/marketing.py
+"""
+📢 Native Ad Engine — Marketing Schema Models
+
+Schema: marketing
+Táblák:
+ - campaigns: Hirdetési kampányok
+ - creatives: Kreatív anyagok (belső/külső)
+ - placements: Elhelyezési zónák
+ - campaign_placements: Kampány ↔ Elhelyezés kapcsolótábla
+ - ad_impressions: Megjelenítések naplózása
+ - ad_clicks: Kattintások naplózása (jövőbeli bővítés)
+"""
+
+from datetime import datetime
+from typing import Optional
+from sqlalchemy import (
+ String, Integer, ForeignKey, Boolean, DateTime,
+ Numeric, Text, Enum as SAEnum
+)
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.sql import func
+import enum
+import uuid
+
+from app.database import Base
+
+
+class CampaignStatus(enum.Enum):
+ DRAFT = "draft"
+ SCHEDULED = "scheduled"
+ ACTIVE = "active"
+ PAUSED = "paused"
+ COMPLETED = "completed"
+ CANCELLED = "cancelled"
+
+
+class CreativeType(enum.Enum):
+ IMAGE = "image" # Belső kép
+ HTML_SNIPPET = "html_snippet" # Külső ad kód (pl. Google Ads)
+ VIDEO = "video" # Videó (jövőbeli)
+ CAROUSEL = "carousel" # Több képből álló (jövőbeli)
+
+
+class Campaign(Base):
+ """
+ Hirdetési kampány.
+
+ Egy kampány több kreatívot és több elhelyezést is célozhat.
+ A kampányoknak van prioritása és státusza.
+ """
+ __tablename__ = "campaigns"
+ __table_args__ = {"schema": "marketing"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ uuid: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True),
+ server_default=func.gen_random_uuid(),
+ unique=True,
+ comment="Publikus azonosító (UUID) a frontend számára"
+ )
+ name: Mapped[str] = mapped_column(
+ String(200), nullable=False,
+ comment="Kampány neve (pl. '2026 Q3 Flotta Akció')"
+ )
+ description: Mapped[Optional[str]] = mapped_column(
+ Text, comment="Belső leírás a kampányról"
+ )
+
+ # ── Időbeli korlátok ──
+ start_date: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False,
+ comment="Kampány kezdete"
+ )
+ end_date: Mapped[Optional[datetime]] = mapped_column(
+ DateTime(timezone=True), nullable=True,
+ comment="Kampány vége (None = nincs lejárat)"
+ )
+
+ # ── Prioritás és státusz ──
+ priority: Mapped[int] = mapped_column(
+ Integer, server_default=text("0"),
+ comment="Prioritás (magasabb = fontosabb). 0 = legalacsonyabb"
+ )
+ status: Mapped[CampaignStatus] = mapped_column(
+ SAEnum(CampaignStatus, name="campaign_status", schema="marketing"),
+ server_default=text("'draft'"),
+ comment="Kampány státusza"
+ )
+
+ # ── Célzás ──
+ targeting_rules: Mapped[Optional[dict]] = mapped_column(
+ JSONB, server_default=text("'{}'::jsonb"),
+ comment="Célzási szabályok JSONB (pl. {'country_codes': ['HU', 'GB'], 'min_tier': 'premium'})"
+ )
+
+ # ── Korlátok ──
+ daily_impression_limit: Mapped[Optional[int]] = mapped_column(
+ Integer, comment="Napi max megjelenítés (None = korlátlan)"
+ )
+ total_impression_limit: Mapped[Optional[int]] = mapped_column(
+ Integer, comment="Összes max megjelenítés (None = korlátlan)"
+ )
+
+ # ── Meta ──
+ 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()
+ )
+ created_by: Mapped[Optional[int]] = mapped_column(
+ Integer, ForeignKey("identity.users.id"), nullable=True,
+ comment="Kampány létrehozója (admin user ID)"
+ )
+
+ # ── Kapcsolatok ──
+ creatives: Mapped[list["Creative"]] = relationship(
+ "Creative", back_populates="campaign", cascade="all, delete-orphan"
+ )
+ placements: Mapped[list["Placement"]] = relationship(
+ "Placement", secondary="marketing.campaign_placements",
+ back_populates="campaigns"
+ )
+
+
+class Creative(Base):
+ """
+ Kreatív anyag (hirdetés).
+
+ Két fő típus:
+ - IMAGE: Belső kép + target_url (saját rendszerben tárolt kép)
+ - HTML_SNIPPET: Külső ad kód (pl. Google Ads, Taboola snippet)
+ """
+ __tablename__ = "creatives"
+ __table_args__ = {"schema": "marketing"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ campaign_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
+ nullable=False
+ )
+
+ # ── Típus ──
+ creative_type: Mapped[CreativeType] = mapped_column(
+ SAEnum(CreativeType, name="creative_type", schema="marketing"),
+ nullable=False,
+ comment="Kreatív típusa: image (belső) vagy html_snippet (külső)"
+ )
+
+ # ── IMAGE típushoz ──
+ image_url: Mapped[Optional[str]] = mapped_column(
+ String(500),
+ comment="Kép URL (IMAGE típusnál). Lehet relatív (storage) vagy abszolút"
+ )
+ alt_text: Mapped[Optional[str]] = mapped_column(
+ String(200),
+ comment="Kép alternatív szövege (akadálymentesítés)"
+ )
+
+ # ── HTML_SNIPPET típushoz ──
+ html_snippet: Mapped[Optional[str]] = mapped_column(
+ Text,
+ comment="HTML kód külső hirdetési hálózatoktól (pl. Google Ads tag)"
+ )
+
+ # ── Közös mezők ──
+ title: Mapped[Optional[str]] = mapped_column(
+ String(200),
+ comment="Hirdetés címe (megjeleníthető a kép alatt)"
+ )
+ description: Mapped[Optional[str]] = mapped_column(
+ Text,
+ comment="Hirdetés leírása"
+ )
+ target_url: Mapped[Optional[str]] = mapped_column(
+ String(1000),
+ comment="Cél URL (hova vezet a kattintás)"
+ )
+ payload: Mapped[Optional[dict]] = mapped_column(
+ JSONB, server_default=text("'{}'::jsonb"),
+ comment="Extra adatok (pl. tracking params, utm_source)"
+ )
+
+ # ── Súlyozás ──
+ weight: Mapped[int] = mapped_column(
+ Integer, server_default=text("1"),
+ comment="Súly a rotációban (magasabb = gyakrabban jelenik meg)"
+ )
+
+ # ── Kapcsolatok ──
+ campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
+
+
+class Placement(Base):
+ """
+ Elhelyezési zóna a frontenden.
+
+ Példák:
+ - 'dashboard_sidebar'
+ - 'vehicle_detail_below'
+ - 'service_search_top'
+ - 'footer_banner'
+ """
+ __tablename__ = "placements"
+ __table_args__ = {"schema": "marketing"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ name: Mapped[str] = mapped_column(
+ String(100), unique=True, nullable=False,
+ comment="Zóna neve (pl. 'dashboard_sidebar')"
+ )
+ display_name: Mapped[Optional[str]] = mapped_column(
+ String(200),
+ comment="Megjelenítési név az admin felületen"
+ )
+ description: Mapped[Optional[str]] = mapped_column(
+ Text, comment="Leírás a zóna elhelyezkedéséről"
+ )
+
+ # ── Korlátok ──
+ max_creatives: Mapped[int] = mapped_column(
+ Integer, server_default=text("1"),
+ comment="Maximum hány kreatív jelenhet meg egyszerre ebben a zónában"
+ )
+
+ # ── Tier szűrés ──
+ allowed_subscription_tiers: Mapped[Optional[dict]] = mapped_column(
+ JSONB, server_default=text("'{}'::jsonb"),
+ comment="Engedélyezett előfizetési tier-ek JSONB tömbként (pl. ['free', 'premium'])"
+ )
+
+ # ── Meta ──
+ is_active: Mapped[bool] = mapped_column(
+ Boolean, server_default=text("true")
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+ # ── Kapcsolatok ──
+ campaigns: Mapped[list["Campaign"]] = relationship(
+ "Campaign", secondary="marketing.campaign_placements",
+ back_populates="placements"
+ )
+
+
+class CampaignPlacement(Base):
+ """
+ Kapcsolótábla: Kampány ↔ Elhelyezés (M:N kapcsolat).
+
+ Lehetővé teszi, hogy egy kampány több zónában is megjelenjen,
+ és egy zónában több kampány is fusson.
+ """
+ __tablename__ = "campaign_placements"
+ __table_args__ = {"schema": "marketing"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ campaign_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
+ nullable=False
+ )
+ placement_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"),
+ nullable=False
+ )
+
+ # ── Override mezők (felülírhatják a kampány szintű beállításokat) ──
+ priority_override: Mapped[Optional[int]] = mapped_column(
+ Integer,
+ comment="Prioritás felülírása ebben a zónában"
+ )
+ daily_impression_limit_override: Mapped[Optional[int]] = mapped_column(
+ Integer,
+ comment="Napi limit felülírása ebben a zónában"
+ )
+
+
+class AdImpression(Base):
+ """
+ Hirdetés megjelenítések naplózása.
+
+ Minden egyes megjelenítés rögzítésre kerül a kampány
+ és kreatív szintű analitikához.
+ """
+ __tablename__ = "ad_impressions"
+ __table_args__ = {"schema": "marketing"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ campaign_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.campaigns.id"), nullable=False,
+ index=True
+ )
+ creative_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.creatives.id"), nullable=False,
+ index=True
+ )
+ placement_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.placements.id"), nullable=False
+ )
+ user_id: Mapped[Optional[int]] = mapped_column(
+ Integer, ForeignKey("identity.users.id"), nullable=True,
+ comment="Felhasználó aki látta (None = nem bejelentkezett)"
+ )
+
+ # ── Meta ──
+ seen_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(),
+ index=True
+ )
+ ip_address: Mapped[Optional[str]] = mapped_column(
+ String(45), comment="IP cím (anonimizálható)"
+ )
+ user_agent: Mapped[Optional[str]] = mapped_column(
+ Text, comment="User agent string"
+ )
+ session_id: Mapped[Optional[str]] = mapped_column(
+ String(100), index=True,
+ comment="Munkamenet azonosító (deduplikációhoz)"
+ )
+
+ # ── Kapcsolatok ──
+ campaign: Mapped["Campaign"] = relationship()
+ creative: Mapped["Creative"] = relationship()
+ placement: Mapped["Placement"] = relationship()
+
+
+class AdClick(Base):
+ """
+ Hirdetés kattintások naplózása (jövőbeli bővítés).
+
+ Elkülönítve az impression táblától a jobb skálázhatóság érdekében.
+ """
+ __tablename__ = "ad_clicks"
+ __table_args__ = {"schema": "marketing"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ impression_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("marketing.ad_impressions.id"), nullable=False,
+ comment="Melyik megjelenítéshez tartozik a kattintás"
+ )
+ clicked_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+ target_url: Mapped[Optional[str]] = mapped_column(
+ String(1000),
+ comment="Cél URL a kattintáskor (tracking paraméterekkel)"
+ )
+```
+
+### 2.2 Adatbázis Séma Diagram (Text)
+
+```
+marketing.campaigns
+├── id (PK, SERIAL)
+├── uuid (UUID, UNIQUE)
+├── name (VARCHAR 200)
+├── description (TEXT)
+├── start_date (TIMESTAMPTZ)
+├── end_date (TIMESTAMPTZ, nullable)
+├── priority (INTEGER, default=0)
+├── status (ENUM: draft/scheduled/active/paused/completed/cancelled)
+├── targeting_rules (JSONB)
+├── daily_impression_limit (INTEGER, nullable)
+├── total_impression_limit (INTEGER, nullable)
+├── created_at (TIMESTAMPTZ)
+├── updated_at (TIMESTAMPTZ, nullable)
+└── created_by (FK → identity.users.id, nullable)
+
+marketing.creatives
+├── id (PK, SERIAL)
+├── campaign_id (FK → marketing.campaigns.id, CASCADE)
+├── creative_type (ENUM: image/html_snippet/video/carousel)
+├── image_url (VARCHAR 500, nullable)
+├── alt_text (VARCHAR 200, nullable)
+├── html_snippet (TEXT, nullable)
+├── title (VARCHAR 200, nullable)
+├── description (TEXT, nullable)
+├── target_url (VARCHAR 1000, nullable)
+├── payload (JSONB)
+├── weight (INTEGER, default=1)
+└── campaign (relationship → Campaign)
+
+marketing.placements
+├── id (PK, SERIAL)
+├── name (VARCHAR 100, UNIQUE)
+├── display_name (VARCHAR 200, nullable)
+├── description (TEXT, nullable)
+├── max_creatives (INTEGER, default=1)
+├── allowed_subscription_tiers (JSONB)
+├── is_active (BOOLEAN, default=true)
+├── created_at (TIMESTAMPTZ)
+└── campaigns (M:N → campaigns via campaign_placements)
+
+marketing.campaign_placements
+├── id (PK, SERIAL)
+├── campaign_id (FK → marketing.campaigns.id, CASCADE)
+├── placement_id (FK → marketing.placements.id, CASCADE)
+├── priority_override (INTEGER, nullable)
+└── daily_impression_limit_override (INTEGER, nullable)
+
+marketing.ad_impressions
+├── id (PK, SERIAL)
+├── campaign_id (FK → marketing.campaigns.id, INDEX)
+├── creative_id (FK → marketing.creatives.id, INDEX)
+├── placement_id (FK → marketing.placements.id)
+├── user_id (FK → identity.users.id, nullable)
+├── seen_at (TIMESTAMPTZ, INDEX)
+├── ip_address (VARCHAR 45, nullable)
+├── user_agent (TEXT, nullable)
+└── session_id (VARCHAR 100, INDEX)
+
+marketing.ad_clicks (jövőbeli)
+├── id (PK, SERIAL)
+├── impression_id (FK → marketing.ad_impressions.id)
+├── clicked_at (TIMESTAMPTZ)
+└── target_url (VARCHAR 1000, nullable)
+```
+
+### 2.3 Hirdetés Kiválasztási Logika (Prioritás + Fallback)
+
+Amikor a backend kiválasztja a megfelelő hirdetést egy adott zónához, az alábbi algoritmust követi:
+
+```
+1. SZŰRÉS: Aktív kampányok a zónában
+ SELECT c.* FROM campaigns c
+ JOIN campaign_placements cp ON cp.campaign_id = c.id
+ WHERE cp.placement_id = :placement_id
+ AND c.status = 'active'
+ AND c.start_date <= NOW()
+ AND (c.end_date IS NULL OR c.end_date >= NOW())
+
+2. SZŰRÉS: Felhasználó tier-jének megfelelő kampányok
+ - Ellenőrzi a placement.allowed_subscription_tiers JSONB tömböt
+ - Csak azokat a kampányokat tartja meg, ahol a user tier szerepel a listában
+
+3. SZŰRÉS: Napi/össz limit ellenőrzés
+ - Ellenőrzi a campaign.daily_impression_limit-t
+ - Ellenőrzi a campaign.total_impression_limit-t
+ - Kiszűri a limitet elért kampányokat
+
+4. SORREND: Prioritás szerinti rendezés
+ - Elsődleges: priority (magasabb = előrébb)
+ - Másodlagos: random (egyenlő prioritás esetén rotáció)
+
+5. KIVÁLASZTÁS: A legmagasabb prioritású kampány kreatívjai közül
+ - Súlyozott random választás a creative.weight alapján
+ - Ha nincs megfelelő kampány → FALLBACK: üres placeholder vagy default ad
+
+6. MEGJELENÍTÉS: AdImpression naplózása
+ - Rögzíti a campaign_id, creative_id, placement_id, user_id adatokat
+```
+
+#### 2.3.1 Python Pseudo-kód
+
+```python
+class AdSelectionService:
+ """
+ Hirdetés kiválasztó szolgáltatás.
+
+ A megfelelő hirdetés kiválasztása egy adott zónához,
+ figyelembe véve a kampány prioritását, a felhasználó tier-jét
+ és a napi/össz megjelenítési limiteket.
+ """
+
+ @staticmethod
+ async def select_ad_for_placement(
+ db: AsyncSession,
+ placement_name: str,
+ user_tier: str = "free",
+ user_id: Optional[int] = None
+ ) -> Optional[dict]:
+ """
+ Kiválasztja a legmegfelelőbb hirdetést egy adott zónához.
+
+ Args:
+ db: Database session
+ placement_name: Zóna neve (pl. 'dashboard_sidebar')
+ user_tier: Felhasználó tier szintje
+ user_id: Felhasználó ID (naplózáshoz)
+
+ Returns:
+ Optional[dict]: Kiválasztott kreatív adatai vagy None
+ """
+ now = datetime.utcnow()
+
+ # 1. Keressük meg a zónát
+ placement = await db.execute(
+ select(Placement).where(
+ Placement.name == placement_name,
+ Placement.is_active == True
+ )
+ )
+ placement = placement.scalar_one_or_none()
+ if not placement:
+ return None
+
+ # 2. Ellenőrizzük, hogy a user tier engedélyezett-e ebben a zónában
+ allowed_tiers = placement.allowed_subscription_tiers or []
+ if allowed_tiers and user_tier not in allowed_tiers:
+ return None
+
+ # 3. Aktív kampányok lekérdezése ebben a zónában
+ # Rendezés: priority DESC, majd random
+ campaigns_query = (
+ select(Campaign)
+ .join(CampaignPlacement, CampaignPlacement.campaign_id == Campaign.id)
+ .where(
+ CampaignPlacement.placement_id == placement.id,
+ Campaign.status == CampaignStatus.ACTIVE,
+ Campaign.start_date <= now,
+ or_(Campaign.end_date.is_(None), Campaign.end_date >= now),
+ )
+ .order_by(Campaign.priority.desc())
+ )
+ result = await db.execute(campaigns_query)
+ campaigns = result.scalars().all()
+
+ if not campaigns:
+ return None # Fallback: nincs aktív kampány
+
+ # 4. Válasszuk ki a legmagasabb prioritású kampányt
+ # (több azonos prioritás esetén random)
+ top_priority = campaigns[0].priority
+ top_campaigns = [c for c in campaigns if c.priority == top_priority]
+ selected_campaign = random.choice(top_campaigns)
+
+ # 5. Válasszunk kreatívot a kampányból (súlyozott random)
+ creatives = selected_campaign.creatives
+ if not creatives:
+ return None
+
+ # Súlyozott random választás
+ total_weight = sum(c.weight for c in creatives)
+ if total_weight == 0:
+ selected_creative = random.choice(creatives)
+ else:
+ r = random.randint(1, total_weight)
+ cumulative = 0
+ selected_creative = creatives[0]
+ for c in creatives:
+ cumulative += c.weight
+ if r <= cumulative:
+ selected_creative = c
+ break
+
+ # 6. Naplózzuk a megjelenítést
+ impression = AdImpression(
+ campaign_id=selected_campaign.id,
+ creative_id=selected_creative.id,
+ placement_id=placement.id,
+ user_id=user_id,
+ seen_at=now,
+ )
+ db.add(impression)
+ await db.flush()
+
+ # 7. Visszatérés a kreatív adataival
+ result_data = {
+ "creative_id": selected_creative.id,
+ "campaign_id": selected_campaign.id,
+ "creative_type": selected_creative.creative_type.value,
+ "title": selected_creative.title,
+ "description": selected_creative.description,
+ "target_url": selected_creative.target_url,
+ "impression_id": impression.id,
+ }
+
+ if selected_creative.creative_type == CreativeType.IMAGE:
+ result_data["image_url"] = selected_creative.image_url
+ result_data["alt_text"] = selected_creative.alt_text
+ elif selected_creative.creative_type == CreativeType.HTML_SNIPPET:
+ result_data["html_snippet"] = selected_creative.html_snippet
+
+ return result_data
+
+
+### 2.4 Admin API Végpontok (Tervezet)
+
+Az Ad Engine kezeléséhez az alábbi admin REST végpontok szükségesek:
+
+| Metódus | Végpont | Leírás |
+|---------|---------|--------|
+| `GET` | `/api/v1/admin/marketing/campaigns` | Kampányok listázása |
+| `POST` | `/api/v1/admin/marketing/campaigns` | Új kampány létrehozása |
+| `GET` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány részletei |
+| `PATCH` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány módosítása |
+| `DELETE` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány törlése (soft-delete) |
+| `GET` | `/api/v1/admin/marketing/creatives` | Kreatívok listázása |
+| `POST` | `/api/v1/admin/marketing/creatives` | Új kreatív létrehozása |
+| `GET` | `/api/v1/admin/marketing/placements` | Elhelyezési zónák listázása |
+| `POST` | `/api/v1/admin/marketing/placements` | Új zóna létrehozása |
+| `GET` | `/api/v1/admin/marketing/analytics/impressions` | Megjelenítési statisztikák |
+| `GET` | `/api/v1/admin/marketing/analytics/summary` | Összesített analitika |
+
+### 2.5 Publikus API Végpont (Frontend számára)
+
+| Metódus | Végpont | Leírás |
+|---------|---------|--------|
+| `GET` | `/api/v1/marketing/ad/{placement_name}` | Hirdetés lekérése egy adott zónához |
+
+Ez a végpont meghívja az `AdSelectionService.select_ad_for_placement()` függvényt, és visszaadja a kiválasztott kreatív adatait a frontend számára.
+
+---
+
+## 3. IMPLEMENTÁCIÓS ÜTEMTERV
+
+### Fázis 1: Backend Alapok (Subscription Stacking)
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 1.1 | `duration_days` hozzáadása a Pydantic sémához | [`subscription.py`](backend/app/schemas/subscription.py:55) | `SubscriptionRulesModel` bővítése `duration_days` mezővel |
+| 1.2 | `upgrade_org_subscription()` stacking logika | [`billing_engine.py:805`](backend/app/services/billing_engine.py:805) | Teljes átírás: duration_days olvasás, valid_until számítás stackinggel |
+| 1.3 | `upgrade_subscription()` stacking logika | [`billing_engine.py:722`](backend/app/services/billing_engine.py:722) | User-level stacking implementálása |
+| 1.4 | `duration_days` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése `duration_days`-szel |
+| 1.5 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
+
+### Fázis 2: Ad Engine Modellek és Séma
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 2.1 | `marketing` schema modellek | [`models/marketing.py`](backend/app/models/) | Campaign, Creative, Placement, CampaignPlacement, AdImpression, AdClick |
+| 2.2 | Sync engine futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py` |
+| 2.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | `ad_policy` mező hozzáadása a `SubscriptionRulesModel`-hez |
+| 2.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
+| 2.5 | `AdSelectionService` létrehozása | [`services/ad_selection_service.py`](backend/app/services/) | Hirdetés kiválasztó algoritmus |
+
+### Fázis 3: Admin API Végpontok
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 3.1 | Admin marketing router | [`endpoints/admin_marketing.py`](backend/app/api/v1/endpoints/) | CRUD végpontok kampányokhoz, kreatívokhoz, elhelyezésekhez |
+| 3.2 | Publikus ad végpont | [`endpoints/marketing.py`](backend/app/api/v1/endpoints/) | `GET /api/v1/marketing/ad/{placement_name}` |
+| 3.3 | Router regisztráció | [`api.py`](backend/app/api/v1/api.py) | Új router-ek hozzáadása az API-hoz |
+
+### Fázis 4: Seed Adatok és Tesztelés
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 4.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
+| 4.2 | Seed marketing adatok | [`seed_marketing.py`](backend/app/scripts/) | Teszt kampányok, kreatívok, elhelyezések létrehozása |
+| 4.3 | Tesztelés | — | API tesztek a stacking és ad selection logikára |
+
+---
+
+## 4. FÜGGŐSÉGEK ÉS KOCKÁZATOK
+
+| Függőség | Hatás | Megoldás |
+|----------|-------|----------|
+| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A stacking logika nem működik a meglévő rekordokon | Backfill szkript: `UPDATE finance.org_subscriptions SET valid_until = valid_from + INTERVAL '30 days' WHERE valid_until IS NULL AND is_active = true` |
+| A `duration_days` mező hiányzik a meglévő seed csomagokból | Az új stacking logika nem tudja kiszámolni az időtartamot | A `seed_packages.py` újrafuttatása a `duration_days` mezővel |
+| Az új `marketing` séma táblái nem léteznek | Az Ad Engine nem működik | A `sync_engine.py` futtatása a modellek létrehozása után |
+| A frontend jelenleg nem kérdez le hirdetéseket | A hirdetési zónák üresek maradnak | A frontend `AdService` létrehozása a Vue alkalmazásban |
+
+---
+
+## 5. JÓVÁHAGYÁS
+
+A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
+
+1. **Subscription Stacking:** `duration_days` séma → `upgrade_org_subscription()` → `upgrade_subscription()` → seed frissítés
+2. **Ad Engine Modellek:** `marketing` séma → sync engine → Pydantic sémák
+3. **Ad Engine Service:** `AdPolicyService` → `AdSelectionService`
+4. **Admin API:** CRUD végpontok → publikus ad végpont
+5. **Seed Adatok:** `ad_policy` → teszt kampányok
\ No newline at end of file
diff --git a/plans/p0_subscription_ui_timers_ad_policy_plan.md b/plans/p0_subscription_ui_timers_ad_policy_plan.md
new file mode 100644
index 0000000..4acf1e0
--- /dev/null
+++ b/plans/p0_subscription_ui_timers_ad_policy_plan.md
@@ -0,0 +1,428 @@
+# P0 Architecture Plan — Subscription UI, Expiration Timers & Smart Ad Policy
+
+**Dátum:** 2026-06-18
+**Szerző:** Fast Coder (Core Developer)
+**Státusz:** Tervezési fázis — Jóváhagyásra vár
+**Cél:** Teljes körű architekturális terv a Subscription UI (Frontend), Expiration Timers (Backend) és a dinamikus, tier-alapú Smart Ad Policy rendszerhez.
+
+---
+
+## 1. AUDIT: Backend Modellek — Előfizetés Időzítők és Állapot
+
+### 1.1 Jelenlegi Adatmodell Áttekintés
+
+A rendszer három rétegben tárolja az előfizetési információkat:
+
+#### A) `system.subscription_tiers` — Csomagdefiníciók
+- **Modell:** [`SubscriptionTier`](backend/app/models/core_logic.py:12)
+- **Kulcs mezők:** `id`, `name` (unique), `rules` (JSONB), `is_custom`
+- **JSONB `rules` struktúra:** Lásd a [`seed_packages.py`](backend/app/scripts/seed_packages.py:37) definícióit
+- **Jelenlegi csomagok:** 10 db (4 private + 6 corporate)
+
+#### B) `finance.org_subscriptions` — Szervezeti előfizetés rekordok
+- **Modell:** [`OrganizationSubscription`](backend/app/models/core_logic.py:25)
+- **Kulcs mezők:**
+ - `org_id` → FK `fleet.organizations.id`
+ - `tier_id` → FK `system.subscription_tiers.id`
+ - `valid_from` (DateTime, server_default=now)
+ - **`valid_until`** (DateTime, nullable) — **EZ A LEJÁRATI IDŐ**
+ - `is_active` (Boolean, default=True)
+
+#### C) `finance.user_subscriptions` — Felhasználói előfizetés rekordok
+- **Modell:** [`UserSubscription`](backend/app/models/core_logic.py:44)
+- **Kulcs mezők:** `user_id`, `tier_id`, `valid_from`, **`valid_until`**, `is_active`, `created_at`, `updated_at`
+
+#### D) `fleet.organizations` — Szervezet (denormalizált subscription adatok)
+- **Modell:** [`Organization`](backend/app/models/marketplace/organization.py:70)
+- **Kulcs mezők:**
+ - `subscription_plan` (String, legacy — server_default='FREE')
+ - `subscription_tier_id` (FK → `system.subscription_tiers.id`, nullable)
+ - `base_asset_limit` (Integer, server_default=1)
+ - `purchased_extra_slots` (Integer, server_default=0)
+
+### 1.2 Lejárati Idő (valid_until) Jelenlegi Állapota
+
+| Tábla | Oszlop | Típus | Alapértelmezés | Jelenlegi Használat |
+|-------|--------|-------|----------------|---------------------|
+| `finance.org_subscriptions` | `valid_until` | `DateTime(timezone=True)` | `NULL` | **Nincs beállítva** a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvényben — csak `valid_from` kerül beállításra |
+| `finance.user_subscriptions` | `valid_until` | `DateTime(timezone=True)` | `NULL` | **Nincs beállítva** a felhasználói előfizetés létrehozáskor |
+| `identity.users` | `subscription_expires_at` | (a kódban használva) | — | A [`billing_engine.py:792`](backend/app/services/billing_engine.py:792) beállítja: `user.subscription_expires_at = datetime.utcnow() + timedelta(days=30)` |
+
+**Következtetés:** A `valid_until` mező jelenleg **nem töltődik be** a szervezeti előfizetés hozzárendelésekor. Ez egy hiányosság, amit pótolni kell.
+
+### 1.3 Tervezett Módosítás: `GET /api/v1/organizations/my` Subscription Adatokkal
+
+A [`GET /my`](backend/app/api/v1/endpoints/organizations.py:180) végpont jelenleg **nem adja vissza** az előfizetési adatokat. Tervezett bővítés:
+
+```python
+# Jelenlegi válasz (hiányos):
+{
+ "organization_id": 1,
+ "subscription_plan": "FREE",
+ "subscription_tier_id": None,
+ # ... nincs subscription info
+}
+
+# Tervezett bővítés:
+{
+ "organization_id": 1,
+ "subscription_plan": "corp_premium_v1",
+ "subscription_tier_id": 16,
+ "subscription": {
+ "tier_id": 16,
+ "tier_name": "corp_premium_v1",
+ "display_name": "Céges Prémium",
+ "valid_from": "2026-06-01T00:00:00Z",
+ "valid_until": "2026-07-01T00:00:00Z", # LEJÁRAT
+ "is_active": True,
+ "allowances": {
+ "max_vehicles": 20,
+ "max_garages": 3,
+ "monthly_free_credits": 300
+ },
+ "pricing": {
+ "monthly_price": 29.99,
+ "yearly_price": 299.99,
+ "currency": "EUR"
+ }
+ }
+}
+```
+
+**Implementációs terv:**
+1. A [`GET /my`](backend/app/api/v1/endpoints/organizations.py:180) végpontban minden Organization rekordhoz töltsük be a hozzá tartozó aktív `OrganizationSubscription` rekordot (JOIN vagy selectinload).
+2. Ha van aktív subscription, adjuk vissza a `valid_until`, `tier.rules` (allowances, pricing, display_name) adatokat.
+3. Ha nincs, a subscription blokk legyen `null`.
+
+---
+
+## 2. SMART AD POLICY — JSONB Architektúra
+
+### 2.1 Tervezési Döntés
+
+A Tervező elvetette az egyszerű `hide_ads` boolean megközelítést. Ehelyett egy **3 szintű, tier-alapú hirdetési politika** kerül bevezetésre, amely a `system.subscription_tiers.rules` JSONB oszlopba ágyazva él.
+
+### 2.2 Az `ad_policy` JSON Struktúra
+
+```json
+{
+ "ad_policy": {
+ "external_display": {
+ "enabled": true | false,
+ "networks": ["google_ads", "taboola", "outbrain"],
+ "max_per_page": 3,
+ "placement": ["sidebar", "between_cards", "footer"]
+ },
+ "internal_upsell": {
+ "enabled": true | false,
+ "show_banners": true | false,
+ "banner_type": "soft" | "aggressive",
+ "upsell_target_tier": "private_pro_v1",
+ "frequency": "once_per_session" | "every_n_actions"
+ },
+ "partner_offers": {
+ "enabled": true | false,
+ "show_sponsored_services": true | false,
+ "show_coupons": true | false,
+ "max_offers_per_page": 2
+ }
+ }
+}
+```
+
+### 2.3 Példa: "Free" Csomag (`private_free_v1`)
+
+```json
+{
+ "type": "private",
+ "display_name": "Privát Ingyenes",
+ "pricing": { "monthly_price": 0, "yearly_price": 0, "currency": "EUR", "credit_price": 0 },
+ "allowances": { "max_vehicles": 1, "max_garages": 1, "monthly_free_credits": 0 },
+ "entitlements": [],
+ "lifecycle": { "is_public": true },
+ "ad_policy": {
+ "external_display": {
+ "enabled": true,
+ "networks": ["google_ads"],
+ "max_per_page": 3,
+ "placement": ["sidebar", "between_cards", "footer"]
+ },
+ "internal_upsell": {
+ "enabled": true,
+ "show_banners": true,
+ "banner_type": "aggressive",
+ "upsell_target_tier": "private_pro_v1",
+ "frequency": "every_n_actions"
+ },
+ "partner_offers": {
+ "enabled": true,
+ "show_sponsored_services": true,
+ "show_coupons": true,
+ "max_offers_per_page": 2
+ }
+ }
+}
+```
+
+### 2.4 Példa: "Premium" Csomag (`corp_premium_v1`)
+
+```json
+{
+ "type": "corporate",
+ "display_name": "Céges Prémium",
+ "pricing": { "monthly_price": 29.99, "yearly_price": 299.99, "currency": "EUR", "credit_price": 3000 },
+ "allowances": { "max_vehicles": 20, "max_garages": 3, "monthly_free_credits": 300 },
+ "entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
+ "lifecycle": { "is_public": true },
+ "ad_policy": {
+ "external_display": {
+ "enabled": false,
+ "networks": [],
+ "max_per_page": 0,
+ "placement": []
+ },
+ "internal_upsell": {
+ "enabled": false,
+ "show_banners": false,
+ "banner_type": "soft",
+ "upsell_target_tier": "corp_premium_plus_v1",
+ "frequency": "once_per_session"
+ },
+ "partner_offers": {
+ "enabled": true,
+ "show_sponsored_services": true,
+ "show_coupons": false,
+ "max_offers_per_page": 1
+ }
+ }
+}
+```
+
+### 2.5 Backend Service Terv: `AdPolicyService`
+
+Létrehozandó fájl: [`backend/app/services/ad_policy_service.py`](backend/app/services/)
+
+```python
+class AdPolicyService:
+ """
+ Szolgáltatás a hirdetési politika lekérdezésére a felhasználó/szervezet
+ aktuális előfizetési csomagja alapján.
+ """
+
+ @staticmethod
+ async def get_ad_policy(db: AsyncSession, org_id: int) -> dict:
+ """
+ Visszaadja a szervezet aktuális ad_policy-ját.
+ Ha nincs tier hozzárendelve, a 'private_free_v1' alapértelmezett policy-t adja.
+ """
+ pass
+
+ @staticmethod
+ def should_show_external_ads(policy: dict) -> bool:
+ """Ellenőrzi, hogy a külső hirdetések engedélyezettek-e."""
+ return policy.get("external_display", {}).get("enabled", False)
+
+ @staticmethod
+ def should_show_upsell_banner(policy: dict) -> bool:
+ """Ellenőrzi, hogy a belső upsell banner megjelenjen-e."""
+ return policy.get("internal_upsell", {}).get("enabled", False)
+```
+
+### 2.6 Pydantic Schema Bővítés
+
+A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése az `ad_policy` mezővel:
+
+```python
+class AdPolicyExternalDisplay(BaseModel):
+ enabled: bool = False
+ networks: List[str] = []
+ max_per_page: int = 0
+ placement: List[str] = []
+
+class AdPolicyInternalUpsell(BaseModel):
+ enabled: bool = False
+ show_banners: bool = False
+ banner_type: str = "soft"
+ upsell_target_tier: Optional[str] = None
+ frequency: str = "once_per_session"
+
+class AdPolicyPartnerOffers(BaseModel):
+ enabled: bool = False
+ show_sponsored_services: bool = False
+ show_coupons: bool = False
+ max_offers_per_page: int = 0
+
+class AdPolicyModel(BaseModel):
+ external_display: AdPolicyExternalDisplay = Field(default_factory=AdPolicyExternalDisplay)
+ internal_upsell: AdPolicyInternalUpsell = Field(default_factory=AdPolicyInternalUpsell)
+ partner_offers: AdPolicyPartnerOffers = Field(default_factory=AdPolicyPartnerOffers)
+```
+
+---
+
+## 3. UI Routing & Catalog — Frontend Terv
+
+### 3.1 Jelenlegi Navigációs Struktúra
+
+A frontend két fő layoutot használ:
+
+1. **`PrivateLayout.vue`** ([`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue)) — Hamburger menü: Dashboard, Járművek, Költségek, Szerviz kereső
+2. **`OrganizationLayout.vue`** ([`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue)) — Hamburger menü: Company Data
+
+**Hiányzó elem:** Egyik layoutban sincs "Előfizetésem & Csomagok" menüpont.
+
+### 3.2 Tervezett UI Változtatások
+
+#### A) Új menüpont: "Előfizetésem & Csomagok"
+
+**Helye a `PrivateLayout.vue` hamburger menüben** (a Szerviz kereső után, a lista végén):
+
+```html
+
+
+```
+
+**Helye az `OrganizationLayout.vue` hamburger menüben** (a Company Data után):
+
+```html
+
+```
+
+#### B) Új Route: `/subscription`
+
+A [`router/index.ts`](frontend/src/router/index.ts)-ben új route hozzáadása:
+
+```typescript
+{
+ path: '/subscription',
+ name: 'subscription',
+ component: () => import('../views/SubscriptionView.vue'),
+ meta: { requiresAuth: true }
+}
+```
+
+#### C) Új View: `SubscriptionView.vue`
+
+Létrehozandó: [`frontend/src/views/SubscriptionView.vue`](frontend/src/views/)
+
+Funkciók:
+- Megjeleníti az aktuális csomag adatait (név, lejárati idő, limitek)
+- Lejárati idő visszaszámláló (countdown timer)
+- "Csomag váltása" gomb → megnyitja a csomagkatalógust
+- Hirdetési politika státuszának megjelenítése (pl. "A Te csomagodban nincsenek külső hirdetések")
+
+### 3.3 `GET /api/v1/subscriptions/public` — Publikus Csomagkatalógus API
+
+**Tervezett új végpont:**
+
+```
+GET /api/v1/subscriptions/public?type=private|corporate
+```
+
+**Szűrési logika:**
+- Ha a garázs `org_type == 'individual'` → csak `private_` prefixű csomagokat ad vissza
+- Ha a garázs `org_type != 'individual'` (business, fleet_owner, stb.) → csak `corp_` prefixű csomagokat ad vissza
+- Minden esetben csak azokat a csomagokat, ahol `rules.lifecycle.is_public == true`
+
+**Tervezett implementáció:**
+
+```python
+@router.get("/public", response_model=SubscriptionTierListResponse)
+async def get_public_subscriptions(
+ org_type: Optional[str] = Query(None, description="Szűrés típus szerint: private vagy corporate"),
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Publikus előfizetési csomagok listázása.
+ - Ha org_type='individual', csak private_ csomagok
+ - Ha org_type='business' (vagy más), csak corp_ csomagok
+ - Csak az is_public=true csomagok
+ """
+ stmt = select(SubscriptionTier)
+
+ # Szűrés típus szerint
+ if org_type and org_type.lower() == 'individual':
+ stmt = stmt.where(SubscriptionTier.rules["type"].as_string() == "private")
+ elif org_type:
+ stmt = stmt.where(SubscriptionTier.rules["type"].as_string() == "corporate")
+
+ # Csak publikus csomagok
+ stmt = stmt.where(
+ ~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
+ )
+
+ result = await db.execute(stmt.order_by(SubscriptionTier.id))
+ tiers = result.scalars().all()
+
+ return SubscriptionTierListResponse(
+ total=len(tiers),
+ tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers]
+ )
+```
+
+---
+
+## 4. Implementációs Ütemterv
+
+### Fázis 1: Backend Alapok (Elsőbbséges)
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 1.1 | `valid_until` beállítása | [`billing_engine.py:869`](backend/app/services/billing_engine.py:869) | A `upgrade_org_subscription()` függvényben állítsuk be a `valid_until = now + 30 nap` értéket |
+| 1.2 | `GET /my` subscription adatokkal | [`organizations.py:180`](backend/app/api/v1/endpoints/organizations.py:180) | Bővítsük ki a választ subscription blokkal (tier adatok + valid_until) |
+| 1.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | Add hozzá az `ad_policy` mezőt a `SubscriptionRulesModel`-hez |
+| 1.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
+| 1.5 | `GET /api/v1/subscriptions/public` | Új végpont | Publikus csomagkatalógus API org_type alapú szűréssel |
+
+### Fázis 2: Frontend UI
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 2.1 | "Előfizetésem" menüpont | [`PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue) | Új hamburger menüpont |
+| 2.2 | "Előfizetésem" menüpont | [`OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue) | Új hamburger menüpont |
+| 2.3 | `/subscription` route | [`router/index.ts`](frontend/src/router/index.ts) | Új route regisztrálása |
+| 2.4 | `SubscriptionView.vue` | [`views/SubscriptionView.vue`](frontend/src/views/) | Új nézet: aktuális csomag + countdown + csomagváltás |
+| 2.5 | i18n kulcsok | [`locales/hu.json`](backend/static/locales/hu.json), [`locales/en.json`](backend/static/locales/en.json) | `menu.subscription` és kapcsolódó fordítások |
+
+### Fázis 3: Seed Adatbázis Frissítés
+
+| # | Feladat | Fájl | Leírás |
+|---|---------|------|--------|
+| 3.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
+| 3.2 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
+
+---
+
+## 5. Függőségek és Kockázatok
+
+| Függőség | Hatás | Megoldás |
+|----------|-------|----------|
+| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A lejárati idő visszaszámláló nem működik | A `upgrade_org_subscription()` bővítése + backfill szkript a meglévő rekordokhoz |
+| A frontend auth store (`auth.ts`) jelenleg nem tárol subscription adatokat | A UI nem tudja megjeleníteni a csomag infót | A `GET /my` válasz bővítése után a store automatikusan frissül |
+| A meglévő seed csomagok nem tartalmaznak `ad_policy`-t | A Smart Ad Policy nem működik a régi csomagokon | A `seed_packages.py` frissítése és újrafuttatása |
+
+---
+
+## 6. Jóváhagyás
+
+A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
+
+1. **Backend:** `valid_until` javítás → `GET /my` bővítés → `AdPolicyModel` → `AdPolicyService` → `GET /public`
+2. **Seed:** `ad_policy` hozzáadása a csomagokhoz
+3. **Frontend:** Menüpontok → Route → SubscriptionView → i18n