pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
@@ -50,7 +50,8 @@ class PurchaseResponse(BaseModel):
|
||||
Response schema for a completed package purchase.
|
||||
|
||||
Contains the full receipt: payment details, subscription info,
|
||||
and commission distribution results.
|
||||
commission distribution results, and optionally a checkout_url
|
||||
for paid tiers in simulate_redirect mode.
|
||||
"""
|
||||
success: bool = Field(..., description="Whether the purchase was successful")
|
||||
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
|
||||
@@ -63,6 +64,8 @@ class PurchaseResponse(BaseModel):
|
||||
currency: str = Field("EUR", description="The payment currency")
|
||||
gateway: str = Field("mock", description="The payment gateway used")
|
||||
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
|
||||
checkout_url: Optional[str] = Field(None, description="Checkout URL for payment redirect (simulate_redirect mode)")
|
||||
payment_status: Optional[str] = Field(None, description="Payment status: PENDING, COMPLETED, etc.")
|
||||
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
|
||||
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
|
||||
error: Optional[str] = Field(None, description="Error message if success=False")
|
||||
|
||||
@@ -22,7 +22,7 @@ from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
|
||||
@@ -58,6 +58,16 @@ class DurationModel(BaseModel):
|
||||
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")
|
||||
@@ -98,6 +108,7 @@ class SubscriptionRulesModel(BaseModel):
|
||||
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)
|
||||
"""
|
||||
@@ -131,6 +142,10 @@ class SubscriptionRulesModel(BaseModel):
|
||||
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)"
|
||||
@@ -149,6 +164,104 @@ class SubscriptionRulesModel(BaseModel):
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
@@ -222,6 +335,36 @@ class SubscriptionTierUpdate(BaseModel):
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
|
||||
import uuid
|
||||
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
|
||||
from typing import Optional, Any, Dict
|
||||
from datetime import date, datetime
|
||||
|
||||
@@ -43,6 +43,17 @@ class UserResponse(UserBase):
|
||||
# P0: Real subscription limits from the assigned subscription_tier JSONB rules
|
||||
max_vehicles: int = 1
|
||||
max_garages: int = 1
|
||||
# ── P0 Subscription Card Upgrade ──
|
||||
subscription_display_name: Optional[str] = None
|
||||
subscription_tier_id: Optional[int] = None
|
||||
subscription_valid_from: Optional[str] = None
|
||||
# ── P0 AUTO-RENEWAL FIELDS ──
|
||||
subscription_auto_renew: bool = False
|
||||
subscription_next_renewal_date: Optional[str] = None
|
||||
subscription_wallet_auto_deduct: bool = False
|
||||
# ── P0 Flip Card ──
|
||||
active_addons: list = Field(default_factory=list, description="Aktív add-on előfizetések")
|
||||
# ── End of subscription fields ──
|
||||
scope_level: str
|
||||
scope_id: Optional[str] = None
|
||||
ui_mode: str = "personal"
|
||||
|
||||
Reference in New Issue
Block a user