P0 HOTFIX: Subscription asset_limit calculation fix

Root cause: Old code used rules.get('max_vehicles') which only searched
JSONB root level. Corporate tiers store limits under rules['allowances'].

Changes:
1. Added _extract_limit() helper (3-level search: direct → allowances → limits)
2. Replaced single-sub .limit(1) with multi-sub .all() for true aggregation
3. Fixed fallback path to use _extract_limit() instead of rules.get()
4. Added clamping: asset_limit = max(asset_limit, 1) right before
   SubscriptionSummary creation on BOTH code paths
5. Added 'type' column to SubscriptionTier model (base vs addon)
This commit is contained in:
Roo
2026-06-28 12:09:56 +00:00
parent 1e6f79ca22
commit f2935cbd64
4 changed files with 755 additions and 40 deletions

View File

@@ -389,6 +389,64 @@ async def list_organizations(
)
# =============================================================================
# P0 ARCHITECTURE UNIFICATION: _extract_limit() helper
# =============================================================================
def _extract_limit(
rules: dict,
keys: list[str],
default: int = 0,
) -> int:
"""
P0 ARCHITECTURE UNIFICATION: Egyetlen, robusztus függvény a limit értékek
kinyerésére a SubscriptionTier.rules JSONB objektumból.
Két rétegben keres:
1. Közvetlenül a rules objektumban: rules["max_vehicles"]
2. A rules["allowances"] nested objektumban: rules["allowances"]["max_vehicles"]
Ez biztosítja, hogy a JSONB struktúra változásai (pl. 'allowances' nested object)
ne törjék meg a limit számításokat.
Args:
rules: A SubscriptionTier.rules JSONB dict-je.
keys: A keresendő kulcsok listája (pl. ["max_vehicles", "max_assets"]).
Az első találat értéke kerül visszaadásra.
default: Alapértelmezett érték, ha egyik kulcs sem található.
Returns:
int: A talált limit érték, vagy a default.
"""
if not rules or not isinstance(rules, dict):
return default
# 1. szint: Közvetlen keresés a rules objektumban
for key in keys:
value = rules.get(key)
if value is not None and isinstance(value, (int, float)):
return int(value)
# 2. szint: Keresés a rules["allowances"] nested objektumban
allowances = rules.get("allowances")
if allowances and isinstance(allowances, dict):
for key in keys:
value = allowances.get(key)
if value is not None and isinstance(value, (int, float)):
return int(value)
# 3. szint: Keresés a rules["limits"] nested objektumban (alternatív név)
limits = rules.get("limits")
if limits and isinstance(limits, dict):
for key in keys:
value = limits.get(key)
if value is not None and isinstance(value, (int, float)):
return int(value)
return default
# =============================================================================
# GET /{org_id}/details — Garázs részletes adatai (General Tab)
# =============================================================================
@@ -438,7 +496,9 @@ async def get_organization_details(
detail=f"Szervezet nem található ID-vel: {org_id}",
)
# 2. Aktív előfizetés lekérése (P0 ADD-ON: valid_until >= now() check)
# 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1))
# Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk,
# majd aggregáljuk a limit értékeket.
now = datetime.utcnow()
sub_stmt = (
select(OrganizationSubscription)
@@ -449,10 +509,9 @@ async def get_organization_details(
OrganizationSubscription.valid_until >= now,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
sub_result = await db.execute(sub_stmt)
active_sub = sub_result.scalar_one_or_none()
active_subs = sub_result.scalars().all()
# 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából
from app.models.fleet.vehicle import Vehicle # noqa: E402
@@ -512,50 +571,118 @@ async def get_organization_details(
contact_result = await db.execute(contact_stmt)
contact = contact_result.scalar_one_or_none()
# 5. Subscription összefoglaló — P0 ADD-ON UPGRADE
# Fix: explicit dict.get() instead of `or` (bugfix: 0 is falsy in Python)
# Add: extra_allowances math (Base + Extra)
# 5. Subscription összefoglaló — P0 ARCHITECTURE UNIFICATION
# Unified limit extraction via _extract_limit() helper.
# Multi-subscription aggregation: Base + Add-on tiers.
# Path A and Path B are now ONE unified code path.
subscription_summary = None
if active_sub and active_sub.tier:
rules = active_sub.tier.rules or {}
fc = active_sub.tier.feature_capabilities or {}
extra = active_sub.extra_allowances or {}
if active_subs:
# ── P0 ARCHITECTURE UNIFICATION: Aggregate across ALL active subscriptions ──
# Separate Base subscriptions from Add-on subscriptions
base_subs = [s for s in active_subs if s.tier and s.tier.type in (None, '', 'base')]
addon_subs = [s for s in active_subs if s.tier and s.tier.type == 'addon']
# Base limits from tier rules (with explicit .get() — no `or` bug)
base_vehicles = rules.get("max_vehicles", fc.get("max_vehicles", 1))
base_branches = rules.get("max_branches", fc.get("max_branches", 0))
base_users = rules.get("max_users", fc.get("max_users", 0))
# Extra allowances (add-ons)
extra_vehicles = extra.get("extra_vehicles", 0) or 0
extra_branches = extra.get("extra_branches", 0) or 0
extra_users = extra.get("extra_users", 0) or 0
# Total = Base + Extra
asset_limit = base_vehicles + extra_vehicles
branch_limit = base_branches + extra_branches
user_limit = base_users + extra_users
subscription_summary = SubscriptionSummary(
tier_name=active_sub.tier.name,
tier_level=active_sub.tier.tier_level,
valid_from=active_sub.valid_from.isoformat() if active_sub.valid_from else None,
expires_at=active_sub.valid_until.isoformat() if active_sub.valid_until else None,
is_active=active_sub.is_active,
asset_count=asset_count,
asset_limit=asset_limit,
branch_limit=branch_limit,
user_limit=user_limit,
extra_allowances=extra,
# Use the highest-tier Base subscription for tier metadata
# (sort by tier_level descending, pick the first)
base_subs_sorted = sorted(
base_subs,
key=lambda s: s.tier.tier_level if s.tier else 0,
reverse=True,
)
primary_sub = base_subs_sorted[0] if base_subs_sorted else (active_subs[0] if active_subs else None)
if primary_sub and primary_sub.tier:
# ── Base limits from primary subscription (using _extract_limit) ──
primary_rules = primary_sub.tier.rules or {}
primary_fc = primary_sub.tier.feature_capabilities or {}
# vehicle_limit_keys: search in rules, then allowances, then feature_capabilities
base_vehicles = _extract_limit(
primary_rules,
["max_vehicles", "max_assets"],
_extract_limit(primary_fc, ["max_vehicles", "max_assets"], 1),
)
base_branches = _extract_limit(
primary_rules,
["max_branches", "max_garages"],
_extract_limit(primary_fc, ["max_branches", "max_garages"], 0),
)
base_users = _extract_limit(
primary_rules,
["max_users", "max_members"],
_extract_limit(primary_fc, ["max_users", "max_members"], 0),
)
# ── Aggregate extra_allowances from ALL active subscriptions ──
total_extra_vehicles = 0
total_extra_branches = 0
total_extra_users = 0
aggregated_extra = {}
for sub in active_subs:
extra = sub.extra_allowances or {}
total_extra_vehicles += extra.get("extra_vehicles", 0) or 0
total_extra_branches += extra.get("extra_branches", 0) or 0
total_extra_users += extra.get("extra_users", 0) or 0
# Merge extra_allowances dicts (later subs overwrite earlier ones for same keys)
aggregated_extra.update(extra)
# ── Also aggregate add-on tier base limits ──
for addon_sub in addon_subs:
if addon_sub.tier:
addon_rules = addon_sub.tier.rules or {}
addon_fc = addon_sub.tier.feature_capabilities or {}
base_vehicles += _extract_limit(
addon_rules, ["max_vehicles", "max_assets"],
_extract_limit(addon_fc, ["max_vehicles", "max_assets"], 0),
)
base_branches += _extract_limit(
addon_rules, ["max_branches", "max_garages"],
_extract_limit(addon_fc, ["max_branches", "max_garages"], 0),
)
base_users += _extract_limit(
addon_rules, ["max_users", "max_members"],
_extract_limit(addon_fc, ["max_users", "max_members"], 0),
)
# Total = Base (primary + addon) + Extra (from all subscriptions)
asset_limit = base_vehicles + total_extra_vehicles
branch_limit = base_branches + total_extra_branches
user_limit = base_users + total_extra_users
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0
asset_limit = max(asset_limit, 1)
subscription_summary = SubscriptionSummary(
tier_name=primary_sub.tier.name,
tier_level=primary_sub.tier.tier_level,
valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None,
expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None,
is_active=primary_sub.is_active,
asset_count=asset_count,
asset_limit=asset_limit,
branch_limit=branch_limit,
user_limit=user_limit,
extra_allowances=aggregated_extra,
)
elif org.subscription_tier:
# Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription)
rules = org.subscription_tier.rules or {}
fc = org.subscription_tier.feature_capabilities or {}
extra = {}
base_vehicles = rules.get("max_vehicles", fc.get("max_vehicles", 1))
base_branches = rules.get("max_branches", fc.get("max_branches", 0))
base_users = rules.get("max_users", fc.get("max_users", 0))
base_vehicles = _extract_limit(
rules, ["max_vehicles", "max_assets"],
_extract_limit(fc, ["max_vehicles", "max_assets"], 1),
)
base_branches = _extract_limit(
rules, ["max_branches", "max_garages"],
_extract_limit(fc, ["max_branches", "max_garages"], 0),
)
base_users = _extract_limit(
rules, ["max_users", "max_members"],
_extract_limit(fc, ["max_users", "max_members"], 0),
)
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path
base_vehicles = max(base_vehicles, 1)
subscription_summary = SubscriptionSummary(
tier_name=org.subscription_tier.name,