""" 📦 Subscription Tier Pydantic Schemas A subscription_tiers JSONB rules mezőjének erősen tipizált Pydantic modelljei. A rules struktúra a seed_packages.py-ban definiált formátumot követi: { "type": "private" | "corporate", "pricing_zones": { "HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"}, "DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"} }, "allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int}, "entitlements": ["SRV_..."], "affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int} } """ from __future__ import annotations from datetime import datetime from decimal import Decimal from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator # ── Belső (embedded) modellek a rules JSONB struktúrához ────────────────────── class PricingZoneModel(BaseModel): """Egyetlen árazási zóna adatai (ország-specifikus ár).""" monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj") yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj") currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja") credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)") class PricingModel(BaseModel): """Árazási információk egy előfizetési csomaghoz (legacy, egyzónás).""" monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)") yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)") currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja") credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)") class AllowancesModel(BaseModel): """Korlátok és keretek a csomagban.""" max_vehicles: int = Field(default=0, ge=0, description="Maximális járműszám") max_garages: int = Field(default=0, ge=0, description="Maximális garázsok száma") max_users: int = Field(default=0, ge=0, description="Maximális dolgozók/felhasználók száma (0 = nincs korlátozva)") monthly_free_credits: int = Field(default=0, ge=0, description="Havi ingyenes kreditek száma") class DurationModel(BaseModel): """Előfizetés időtartamának beállításai (duration_days stacking).""" days: int = Field(default=30, ge=1, description="Előfizetés időtartama napokban (pl. 30 = havi, 365 = éves)") allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik") class AdPolicyModel(BaseModel): """Hirdetési szabályok a csomaghoz.""" show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak") ad_free_grace_days: int = Field(default=0, ge=0, description="Hány nap türelmi idő a reklámok eltűnéséig fizetés után") max_daily_impressions: Optional[int] = Field(default=None, ge=0, description="Maximális napi hirdetésmegjelenítés (None = korlátlan)") class LifecycleModel(BaseModel): """Életciklus állapotok egy előfizetési csomaghoz.""" is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)") available_until: Optional[datetime] = Field(default=None, description="Meddig érhető el a csomag (None = nincs lejárat)") class AffiliateModel(BaseModel): """Partnerprogram (affiliate) beállítások.""" commission_rate_percent: int = Field(..., ge=0, le=100, description="Jutalék mértéke százalékban") referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma") class MarketingModel(BaseModel): """Marketing információk egy előfizetési csomaghoz (kártya megjelenítéshez).""" subtitle: Optional[str] = Field(default=None, description="Rövid leíró szöveg a kártyán (pl. 'Kisebb flotta esetén.')") badge: Optional[str] = Field(default=None, description="Jelvény szöveg (pl. 'Legnépszerűbb', 'Pro')") highlight_color: Optional[str] = Field(default=None, description="Kiemelő szín hex kódja (pl. '#70BC84')") class SubscriptionRulesModel(BaseModel): """ A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje. Fields: type: "private" (magánszemély) vagy "corporate" (vállalati) display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes") pricing: Árazási adatok (legacy, egyzónás) - deprecated, használd a pricing_zones-t pricing_zones: Több régiós árazási adatok (országkód -> PricingZoneModel) allowances: Korlátok (max jármű, garázs, kredit) entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT) affiliate: Partnerprogram beállítások lifecycle: Életciklus állapotok (opcionális, pl. is_public) duration: Előfizetés időtartamának beállításai (duration_days, stacking) ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days) marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color) """ type: Optional[str] = Field(default=None, pattern=r"^(private|corporate|addon)$", description="Csomag típusa (private/corporate/addon)") display_name: Optional[str] = Field( default=None, description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')", ) pricing: Optional[PricingModel] = Field( default=None, description="Árazási adatok (legacy, egyzónás). Deprecated: használd a pricing_zones-t.", ) pricing_zones: Optional[Dict[str, PricingZoneModel]] = Field( default=None, description="Több régiós árazás. Kulcs: országkód (pl. 'HU', 'DE', 'DEFAULT').", ) allowances: Optional[AllowancesModel] = Field( default=None, description="Korlátok (max jármű, garázs, kredit). Opcionális a hiányos seed adatok miatt." ) entitlements: List[str] = Field(default_factory=list, description="Jogosultságok (entitlement) listája") affiliate: Optional[AffiliateModel] = Field( default=None, description="Partnerprogram beállítások. Opcionális a hiányos seed adatok miatt." ) lifecycle: Optional[LifecycleModel] = Field( default=None, description="Életciklus állapotok (is_public, available_until)" ) duration: Optional[DurationModel] = Field( default=None, description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)" ) ad_policy: Optional[AdPolicyModel] = Field( default=None, description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)" ) marketing: Optional[MarketingModel] = Field( default=None, description="Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)" ) @field_validator("type") @classmethod def validate_type(cls, v: Optional[str]) -> Optional[str]: if v is None: return v if v.lower() not in ("private", "corporate", "addon"): raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.") return v.lower() # ── API Response / Request modellek ─────────────────────────────────────────── class SubscriptionTierResponse(BaseModel): """Teljes SubscriptionTier rekord visszaadása (GET /, GET /{id}).""" id: int name: str rules: SubscriptionRulesModel is_custom: bool tier_level: int = Field(default=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise") feature_capabilities: dict = Field(default_factory=dict, description="Feature capability flags JSONB") is_default_fallback: bool = Field(default=False, description="Singleton: maximum one tier can be the default fallback") trial_days_on_signup: int = Field(default=0, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0") model_config = ConfigDict(from_attributes=True) class PublicSubscriptionTierResponse(BaseModel): """ Publikus előfizetési csomag válasz a frontend számára. Tartalmazza a feloldott (resolved) árazást a felhasználó régiója alapján. """ id: int name: str rules: SubscriptionRulesModel is_custom: bool resolved_pricing: Optional[PricingZoneModel] = Field( default=None, description="A felhasználó régiójára feloldott árazás (egyetlen PricingZoneModel).", ) model_config = ConfigDict(from_attributes=True) class SubscriptionTierCreate(BaseModel): """Új előfizetési csomag létrehozása (POST /).""" name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)") type: Optional[str] = Field(default="base", description="Csomag típusa: 'base' (alap) vagy 'addon' (kiegészítő)") rules: SubscriptionRulesModel is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e") tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise") feature_capabilities: dict = Field(default_factory=dict, description="Feature capability flags JSONB") is_default_fallback: bool = Field(default=False, description="Singleton: maximum one tier can be the default fallback") trial_days_on_signup: int = Field(default=0, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0") @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: if not v.strip(): raise ValueError("A csomag neve nem lehet üres.") return v.strip().lower() class SubscriptionTierUpdate(BaseModel): """Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id}).""" name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév") type: Optional[str] = Field(default=None, description="Csomag típusa: 'base' vagy 'addon'") rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB") is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés") tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level") feature_capabilities: Optional[dict] = Field(default=None, description="Feature capability flags JSONB") is_default_fallback: Optional[bool] = Field(default=None, description="Singleton: maximum one tier can be the default fallback") trial_days_on_signup: Optional[int] = Field(default=None, ge=0, description="Singleton: maximum one tier can have trial_days_on_signup > 0") @field_validator("name") @classmethod def validate_name(cls, v: Optional[str]) -> Optional[str]: if v is not None and not v.strip(): raise ValueError("A csomag neve nem lehet üres.") return v.strip().lower() if v else v class SubscriptionTierListResponse(BaseModel): """Csomagok listázásának válaszformátuma (GET /).""" total: int tiers: List[SubscriptionTierResponse]