""" 📩 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, model_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 RenewalConfigModel(BaseModel): """Auto-renewal beĂĄllĂ­tĂĄsok egy elƑfizetĂ©si csomaghoz (tier.rules.renewal JSONB).""" enabled: bool = Field(default=False, description="Can this tier be auto-renewed?") auto_renew_default: bool = Field(default=False, description="Default value for auto_renew when subscribing") renewal_period_days: Optional[int] = Field(default=None, ge=1, description="Renewal period in days (None = use duration.days)") retry_on_failure: bool = Field(default=True, description="Retry failed renewal attempts?") max_retry_count: int = Field(default=3, ge=0, description="Max consecutive failed retries before disabling auto-renew") grace_period_days: int = Field(default=7, ge=0, description="Grace period in days after expiry before suspension") 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) renewal: Auto-renewal beĂĄllĂ­tĂĄsok (enabled, auto_renew_default, retry_on_failure) 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)" ) renewal: Optional[RenewalConfigModel] = Field( default=None, description="Auto-renewal beĂĄllĂ­tĂĄsok (enabled, auto_renew_default, retry_on_failure, max_retry_count, grace_period_days)" ) 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() @model_validator(mode="before") @classmethod def handle_legacy_data(cls, data: Any) -> Any: """ P0 CRITICAL: Sanitize legacy/incomplete database rows before Pydantic validation. Legacy rows may have: - `pricing: {}` (empty dict) → convert to None - `type: "base"` or missing → convert to None (will be treated as private) - `lifecycle: {}` (empty dict) → convert to None - missing `renewal` block → already None (no action needed) - `affiliate: {}` (empty dict) → convert to None - `marketing: {}` (empty dict) → convert to None - `allowances: {}` (empty dict) → convert to None - `duration: {}` (empty dict) → convert to None - `ad_policy: {}` (empty dict) → convert to None - `pricing_zones: {"HU": {}}` (incomplete zone) → skip/remove empty zones """ if not isinstance(data, dict): return data # ── Sanitize nested dicts: convert empty dicts to None ── empty_dict_keys = [ "pricing", "pricing_zones", "allowances", "lifecycle", "duration", "renewal", "ad_policy", "affiliate", "marketing", ] for key in empty_dict_keys: val = data.get(key) if val is not None and isinstance(val, dict) and len(val) == 0: data[key] = None # ── Handle legacy type values (e.g. "base", empty string) ── raw_type = data.get("type") if raw_type is not None and isinstance(raw_type, str): raw_lower = raw_type.strip().lower() if raw_lower not in ("private", "corporate", "addon"): # Legacy "base" → map to "private" for backward compat # or just set to None to let the default apply if raw_lower == "base": data["type"] = "private" else: data["type"] = "private" elif raw_type is None: # Missing type → default to private data["type"] = "private" # ── Sanitize pricing zones: remove empty zones, ensure valid fields ── zones = data.get("pricing_zones") if isinstance(zones, dict): cleaned_zones = {} for code, zone_data in zones.items(): if isinstance(zone_data, dict): # Provide defaults for missing fields cleaned_zones[code] = { "monthly_price": zone_data.get("monthly_price", 0) or 0, "yearly_price": zone_data.get("yearly_price", 0) or 0, "currency": zone_data.get("currency", "EUR") or "EUR", "credit_price": zone_data.get("credit_price"), } data["pricing_zones"] = cleaned_zones if cleaned_zones else None # ── Sanitize pricing (legacy): ensure required fields exist ── legacy_pricing = data.get("pricing") if isinstance(legacy_pricing, dict): if "monthly_price" not in legacy_pricing or legacy_pricing.get("monthly_price") is None: legacy_pricing["monthly_price"] = 0 if "yearly_price" not in legacy_pricing or legacy_pricing.get("yearly_price") is None: legacy_pricing["yearly_price"] = 0 if "currency" not in legacy_pricing or not legacy_pricing.get("currency"): legacy_pricing["currency"] = "EUR" # ── Sanitize lifecycle: ensure at least is_public exists ── lifecycle = data.get("lifecycle") if isinstance(lifecycle, dict): if "is_public" not in lifecycle: lifecycle["is_public"] = True # ── Sanitize allowances: ensure all numeric fields have defaults ── allowances = data.get("allowances") if isinstance(allowances, dict): allowances.setdefault("max_vehicles", 0) allowances.setdefault("max_garages", 0) allowances.setdefault("max_users", 0) allowances.setdefault("monthly_free_credits", 0) # ── Sanitize renewal: ensure all fields have defaults ── renewal = data.get("renewal") if isinstance(renewal, dict): renewal.setdefault("enabled", False) renewal.setdefault("auto_renew_default", False) renewal.setdefault("retry_on_failure", True) renewal.setdefault("max_retry_count", 3) renewal.setdefault("grace_period_days", 7) if "renewal_period_days" not in renewal: renewal["renewal_period_days"] = None return data # ── 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 SubscriptionDetailResponse(BaseModel): """ RĂ©szletes elƑfizetĂ©s adatok vĂĄlasz a frontend szĂĄmĂĄra. Tartalmazza a subscription rekord mezƑit, beleĂ©rtve az auto-renewal adatokat. """ tier_id: int tier_name: str display_name: str valid_from: Optional[str] = None valid_until: Optional[str] = None is_active: bool # ── P0 AUTO-RENEWAL FIELDS ── auto_renew: bool = Field(default=False, description="Auto-renewal engedĂ©lyezve") next_renewal_date: Optional[str] = Field(default=None, description="KövetkezƑ auto-renewal dĂĄtuma") wallet_auto_deduct: bool = Field(default=False, description="Auto-levonĂĄs a walletbƑl megĂșjĂ­tĂĄskor") renewal_failure_count: int = Field(default=0, description="Sikertelen megĂșjĂ­tĂĄsi kĂ­sĂ©rletek szĂĄma") provider_subscription_id: Optional[str] = Field(default=None, description="KĂŒlsƑ fizetĂ©si gateway subscription ID") # ── Tier metadata ── allowances: dict = Field(default_factory=dict) pricing: dict = Field(default_factory=dict) duration: Optional[dict] = None renewal: Optional[dict] = Field(default=None, description="Tier renewal konfigurĂĄciĂł a rules-bĂłl") ad_policy: Optional[dict] = None entitlements: list = Field(default_factory=list) feature_capabilities: dict = Field(default_factory=dict) extra_allowances: dict = Field(default_factory=dict) model_config = ConfigDict(from_attributes=True) class SubscriptionTierListResponse(BaseModel): """Csomagok listĂĄzĂĄsĂĄnak vĂĄlaszformĂĄtuma (GET /).""" total: int tiers: List[SubscriptionTierResponse]