# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py """ Subscription Public API Endpoints. Provides: - GET /public — Public package catalog with dynamic pricing resolution based on the user's organization country (or fallback to DEFAULT zone). - GET /my — Current user's/org's subscription details with feature flags - GET /feature-flags — All feature flags resolved for the current user """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.api import deps from app.db.session import get_db from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription from app.models.identity import User from app.models.marketplace.organization import Organization from app.schemas.subscription import ( PublicSubscriptionTierResponse, SubscriptionTierResponse, PricingZoneModel, ) from app.services.subscription_service import SubscriptionService, SUBSCRIPTION_FEATURES router = APIRouter() # ── Pricing Resolution ──────────────────────────────────────────────────────── DEFAULT_COUNTRY = "DEFAULT" def resolve_pricing( rules: dict, country_code: str, ) -> Optional[dict]: """ Feloldja a megfelelő árazást a `pricing_zones` JSONB mezőből a felhasználó országkódja alapján. **Logika:** 1. Ha a `rules` tartalmaz `pricing_zones`-t, megkeresi a country_code-nak megfelelő zónát. 2. Ha nincs pontos egyezés, visszaesik a "DEFAULT" zónára. 3. Ha a `pricing_zones` hiányzik, visszaesik a legacy `pricing` mezőre. 4. Ha semmi sincs, None-t ad vissza. """ if not rules or not isinstance(rules, dict): return None # 1. Try pricing_zones first pricing_zones = rules.get("pricing_zones") if pricing_zones and isinstance(pricing_zones, dict): # Exact match if country_code in pricing_zones: return pricing_zones[country_code] # Fallback to DEFAULT if "DEFAULT" in pricing_zones: return pricing_zones["DEFAULT"] # 2. Fallback to legacy pricing legacy_pricing = rules.get("pricing") if legacy_pricing and isinstance(legacy_pricing, dict): return { "monthly_price": legacy_pricing.get("monthly_price", 0), "yearly_price": legacy_pricing.get("yearly_price", 0), "currency": legacy_pricing.get("currency", "EUR"), "credit_price": legacy_pricing.get("credit_price"), } return None async def get_user_country_code( current_user: User, db: AsyncSession, ) -> str: """ Meghatározza a felhasználó országkódját. **Logika:** 1. Ha a felhasználónak van aktív szervezete (active_organization_id), lekéri annak country_code mezőjét. 2. Ha nincs aktív szervezet, a felhasználó Person rekordjának address országát próbálja meg lekérni. 3. Ha egyik sem elérhető, visszaadja a "DEFAULT" értéket. """ # 1. Try active organization active_org_id = getattr(current_user, "active_organization_id", None) if active_org_id: org_stmt = select(Organization.country_code).where( Organization.id == active_org_id, Organization.is_deleted == False, ) org_result = await db.execute(org_stmt) org_country = org_result.scalar_one_or_none() if org_country: return org_country.upper() # 2. Try user's person -> address country person = getattr(current_user, "person", None) if person: address = getattr(person, "address", None) if address: country = getattr(address, "country", None) if country: return country.upper() # 3. Fallback return DEFAULT_COUNTRY # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get( "/public", response_model=List[PublicSubscriptionTierResponse], summary="Publikus csomagok listázása feloldott árazással", description=( "Visszaadja az összes publikus előfizetési csomagot, " "a felhasználó régiójára feloldott árazással (resolved_pricing). " "A feloldás a pricing_zones JSONB mezőből történik a felhasználó " "országkódja alapján." ), ) async def get_public_subscriptions( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ): """ Publikus előfizetési csomagok listázása dinamikus árazással. **Szabály:** - Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true (JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus. - Defense-in-Depth: A `rules.type` mező alapján szűrjük a csomagokat a felhasználó aktuális kontextusa szerint. Ha a user aktív szervezete `individual` típusú (vagy nincs aktív szervezet), csak `private`/`consumer` típusú csomagokat adunk vissza. Ha a szervezet business típusú, csak `corporate`/`business` típusú csomagokat. - Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó országkódja alapján feloldott árazást tartalmazza. """ # 1. Determine user's country code country_code = await get_user_country_code(current_user, db) # 2. Determine if user is in corporate context (defense-in-depth) active_org_id = getattr(current_user, "active_organization_id", None) is_corporate = False if active_org_id: org_type_stmt = select(Organization.org_type).where( Organization.id == active_org_id, Organization.is_deleted == False, ) org_type_result = await db.execute(org_type_stmt) org_type = org_type_result.scalar_one_or_none() if org_type and org_type != "individual": is_corporate = True # 3. Fetch all public tiers (filter out non-public at SQL level) stmt = ( select(SubscriptionTier) .where( # Only show tiers where lifecycle.is_public is NOT explicitly 'false' # Using SQLAlchemy JSONB path query (same approach as admin_packages.py line 134) ~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"]) ) .order_by(SubscriptionTier.id) ) result = await db.execute(stmt) tiers = result.scalars().all() # 4. Build response with resolved pricing and type filtering response: List[PublicSubscriptionTierResponse] = [] for t in tiers: rules = t.rules or {} if not rules.get("lifecycle", {}).get("is_public", True): continue # Defense-in-Depth: Filter by target audience type rules_type = rules.get("type", None) if rules_type: if is_corporate: # In corporate context: only show corporate/business packages if rules_type not in ("corporate", "business"): continue else: # In private context: only show private/consumer packages if rules_type in ("corporate", "business"): continue # Resolve pricing for this user's country resolved = resolve_pricing(rules, country_code) resolved_pricing = None if resolved: resolved_pricing = PricingZoneModel(**resolved) response.append( PublicSubscriptionTierResponse( id=t.id, name=t.name, rules=rules, is_custom=t.is_custom, resolved_pricing=resolved_pricing, ) ) return response # ── P0 Feature Flag Endpoints ───────────────────────────────────────────────── @router.get( "/my", summary="Saját előfizetés részletei", description=( "Visszaadja a bejelentkezett felhasználó aktuális előfizetési adatait. " "Először a szervezeti előfizetést (OrganizationSubscription) ellenőrzi, " "ha nincs, akkor a felhasználói előfizetést (UserSubscription). " "Tartalmazza a tier adatokat, allowances, pricing, feature_capabilities mezőket." ), ) async def get_my_subscription( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ): """ Visszaadja a felhasználó aktuális előfizetési adatait. **Feloldási sorrend:** 1. Aktív szervezet OrganizationSubscription (ha van active_organization_id) 2. UserSubscription (ha nincs org subscription) 3. Fallback: alapértelmezett 'free' adatok **Response struktúra:** ```json { "subscription": { "tier_id": 16, "tier_name": "corp_premium_v1", "display_name": "Céges Prémium", "valid_from": "...", "valid_until": "...", "is_active": true, "allowances": {"max_vehicles": 20, ...}, "pricing": {"monthly_price": 29.99, ...}, "feature_capabilities": {"can_export_data": true, ...} }, "tier": "premium", "features": { "export_csv": true, "analytics_tco": true, ... }, "source": "organization" | "user" | "default" } ``` """ user_id = current_user.id active_org_id = getattr(current_user, "active_organization_id", None) subscription_data = None source = "default" # 1. Try org subscription first if active_org_id: org_sub = await SubscriptionService.get_org_subscription_details(db, active_org_id) if org_sub: subscription_data = org_sub source = "organization" # 2. Fallback to user subscription if not subscription_data: user_sub = await SubscriptionService.get_user_subscription_details(db, user_id) if user_sub: subscription_data = user_sub source = "user" # 3. Resolve tier and feature flags user_tier = await SubscriptionService.get_user_tier(db, user_id) feature_flags = await SubscriptionService.get_user_feature_flags(db, user_id) # ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ── # Check if there's a pending downgrade on the current subscription has_pending_downgrade = False pending_tier_name = None pending_effective_date = None if subscription_data: pending_sub = None if source == "organization" and active_org_id: pending_stmt = select(OrganizationSubscription).options( selectinload(OrganizationSubscription.pending_tier) ).where( OrganizationSubscription.org_id == active_org_id, OrganizationSubscription.is_active == True, OrganizationSubscription.pending_tier_id.is_not(None), ).order_by(OrganizationSubscription.id.desc()).limit(1) pending_result = await db.execute(pending_stmt) pending_sub = pending_result.scalar_one_or_none() elif source == "user": pending_stmt = select(UserSubscription).options( selectinload(UserSubscription.pending_tier) ).where( UserSubscription.user_id == user_id, UserSubscription.is_active == True, UserSubscription.pending_tier_id.is_not(None), ).order_by(UserSubscription.id.desc()).limit(1) pending_result = await db.execute(pending_stmt) pending_sub = pending_result.scalar_one_or_none() if pending_sub and pending_sub.pending_tier: has_pending_downgrade = True pt = pending_sub.pending_tier pending_tier_name = ( pt.rules.get("display_name", pt.name) if pt.rules else pt.name ) pending_effective_date = ( pending_sub.valid_until.isoformat() if pending_sub.valid_until else None ) return { "subscription": subscription_data, "tier": user_tier, "features": feature_flags.get("features", {}), "expires_at": feature_flags.get("expires_at"), "source": source, # ── P0 Pending downgrade fields ── "has_pending_downgrade": has_pending_downgrade, "pending_tier_name": pending_tier_name, "pending_effective_date": pending_effective_date, } @router.get( "/feature-flags", summary="Feature flag-ek listázása", description=( "Visszaadja az összes feature flag állapotát a felhasználó " "előfizetési szintje alapján. A frontend useFeatureFlag() composable " "ezt a végpontot használja a funkciók engedélyezéséhez/letiltásához." ), ) async def get_feature_flags( db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ): """ Visszaadja az összes feature flag állapotát. **Response:** ```json { "tier": "premium", "features": { "export_csv": true, "analytics_tco": true, "analytics_detailed": false, "multi_vehicle": true, "unlimited_vehicles": true, "subscription_management": true, "vehicle_tracking": true, "cost_tracking": true, "service_booking": true, "advanced_reports": true, "team_management": true, "priority_support": true, "white_label": false, "api_webhooks": false, "custom_integrations": false }, "expires_at": "2026-07-24T12:00:00" } ``` """ feature_flags = await SubscriptionService.get_user_feature_flags( db, current_user.id ) return feature_flags @router.get( "/check/{feature_key}", summary="Egy adott feature flag ellenőrzése", description=( "Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz. " "Használható a frontend által komponens szintű guard-okhoz." ), ) async def check_feature_access( feature_key: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_user), ): """ Ellenőrzi egy adott feature elérhetőségét. **Response:** ```json { "feature_key": "analytics_tco", "granted": true, "tier": "premium", "required_tier": "premium" } ``` Hibák: - 404: Ismeretlen feature_key """ if feature_key not in SUBSCRIPTION_FEATURES: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Unknown feature key: {feature_key}", ) user_tier = await SubscriptionService.get_user_tier(db, current_user.id) granted = SubscriptionService.can_access_feature(user_tier, feature_key) required_tier = SUBSCRIPTION_FEATURES[feature_key] return { "feature_key": feature_key, "granted": granted, "tier": user_tier, "required_tier": required_tier, }