372 lines
18 KiB
Python
372 lines
18 KiB
Python
"""
|
|
📦 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]
|