Files
service-finder/backend/app/api/v1/endpoints/subscriptions.py

435 lines
15 KiB
Python

# /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,
}