Files
service-finder/plans/p0_subscription_ui_timers_ad_policy_plan.md

16 KiB

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
  • Kulcs mezők: id, name (unique), rules (JSONB), is_custom
  • JSONB rules struktúra: Lásd a seed_packages.py definícióit
  • Jelenlegi csomagok: 10 db (4 private + 6 corporate)

B) finance.org_subscriptions — Szervezeti előfizetés rekordok

  • Modell: OrganizationSubscription
  • 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
  • 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
  • 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() 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 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 végpont jelenleg nem adja vissza az előfizetési adatokat. Tervezett bővítés:

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

{
  "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)

{
  "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)

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

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 kiegészítése az ad_policy mezővel:

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) — Hamburger menü: Dashboard, Járművek, Költségek, Szerviz kereső
  2. 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):

<!-- Előfizetésem & Csomagok -->
<button
  @click="navigateToSubscription"
  class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
  <svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
  </svg>
  {{ t('menu.subscription') }}
</button>

Helye az OrganizationLayout.vue hamburger menüben (a Company Data után):

<button
  @click="navigateToSubscription"
  class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
  <!-- Wallet/Subscription icon -->
  <svg class="w-4 h-4 text-white/40" ...>
  {{ t('menu.subscription') }}
</button>

B) Új Route: /subscription

A router/index.ts-ben új route hozzáadása:

{
  path: '/subscription',
  name: 'subscription',
  component: () => import('../views/SubscriptionView.vue'),
  meta: { requiresAuth: true }
}

C) Új View: SubscriptionView.vue

Létrehozandó: frontend/src/views/SubscriptionView.vue

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ó:

@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 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 Bővítsük ki a választ subscription blokkal (tier adatok + valid_until)
1.3 AdPolicyModel Pydantic séma subscription.py Add hozzá az ad_policy mezőt a SubscriptionRulesModel-hez
1.4 AdPolicyService létrehozása services/ad_policy_service.py Ú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 Új hamburger menüpont
2.2 "Előfizetésem" menüpont OrganizationLayout.vue Új hamburger menüpont
2.3 /subscription route router/index.ts Új route regisztrálása
2.4 SubscriptionView.vue views/SubscriptionView.vue Új nézet: aktuális csomag + countdown + csomagváltás
2.5 i18n kulcsok locales/hu.json, 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 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 → AdPolicyModelAdPolicyServiceGET /public
  2. Seed: ad_policy hozzáadása a csomagokhoz
  3. Frontend: Menüpontok → Route → SubscriptionView → i18n