Files
service-finder/plans/p0_architecture_subscription_stacking_ad_engine.md

36 KiB

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() 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

{
  "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 kiegészítése:

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() függvény új logikája:

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() függvény hasonló stacking logikát kap a UserSubscription rekordokhoz:

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

# 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

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