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

357 lines
12 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.
- Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó
országkódja alapján feloldott árazást tartalmazza.
- A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag).
"""
# 1. Determine user's country code
country_code = await get_user_country_code(current_user, db)
# 2. Fetch all public tiers
stmt = (
select(SubscriptionTier)
.order_by(SubscriptionTier.id)
)
result = await db.execute(stmt)
tiers = result.scalars().all()
# 3. Build response with resolved pricing
response: List[PublicSubscriptionTierResponse] = []
for t in tiers:
rules = t.rules or {}
if not rules.get("lifecycle", {}).get("is_public", True):
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)
return {
"subscription": subscription_data,
"tier": user_tier,
"features": feature_flags.get("features", {}),
"expires_at": feature_flags.get("expires_at"),
"source": source,
}
@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,
}