357 lines
12 KiB
Python
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,
|
|
}
|