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

@@ -823,3 +823,130 @@ Detailed fix spec written to `docs/admin_garage_subscription_tab_bugfix_spec.md`
- `sync_engine.py`: 1278 elements OK — system perfectly in sync - `sync_engine.py`: 1278 elements OK — system perfectly in sync
- `test_subscription_feature_flags.py`: All 6 steps passed (login, feature flags, check, public tiers) - `test_subscription_feature_flags.py`: All 6 steps passed (login, feature flags, check, public tiers)
- Python syntax check: ✅ Syntax OK - Python syntax check: ✅ Syntax OK
## 2026-06-27 - P0 CORE ARCHITECTURE: Multi-Subscription Aggregation (Base + Add-ons)
### 🎯 Cél
"1 Base Tier + N Add-on Tiers" modell implementálása. A rendszer most már több aktív előfizetést kezel szervezetenként, és aggregálja a korlátokat (asset_limit, branch_limit, user_limit) a Base Tier Rules + Base Extra Allowances + SUM(Add-on Tier Rules) forrásokból.
### 🔧 Módosított fájlok
**1. `backend/app/models/core_logic.py`**`SubscriptionTier.type` oszlop hozzáadása
- Új `type: Mapped[Optional[str]]` mező (`String(20)`, nullable, default NULL)
- 'base' = core tier, 'addon' = add-on tier, NULL = backward compat (base)
**2. `backend/app/api/v1/endpoints/admin_organizations.py`** — Multi-subscription aggregation
- `SubscriptionSummary.active_addons: List[str]` mező hozzáadása
- `get_organization_details`: `.limit(1)` eltávolítva, minden aktív sub lekérése
- Base vs Add-on szeparáció: `sub.tier.type` és `rules.get("type")` alapján
- Aggregált limit számítás: `asset_limit = base_vehicles + extra_vehicles + addon_vehicles`
- `max(asset_limit, 1)` fallback csak a végösszegre alkalmazva
- `list_organizations`: bulk query-ben is base/addon szeparáció
### ✅ Verifikáció
- Sync Engine: ✅ 1279 OK, 0 Fixed, 0 Extra
- Python syntax: ✅ Mindkét fájl hibátlanul compile-ol
- Subscription feature flags test: ✅ ALL TESTS COMPLETED
## 2026-06-27 - P0 HOTFIX: Subscription PATCH 422 Error & Add-on Only Update
### 🎯 Cél
Két bug javítása a Subscription PATCH flow-ban:
1. **Backend 422 Error**: `tier_id` kötelező volt a `OrgSubscriptionUpdate` sémában, így add-on-only mentéskor Pydantic validation hibát dobott.
2. **Add-on-only mód**: Ha `tier_id` nincs megadva, a meglévő aktív előfizetés `extra_allowances` és `valid_until` mezői frissüljenek, ne pedig új sor jöjjön létre.
### 🔧 Módosított fájlok
- `backend/app/api/v1/endpoints/admin_organizations.py`
- **Schema** (47-68. sor): `tier_id: int = Field(...)``tier_id: Optional[int] = Field(default=None, ...)`
- **Endpoint** (1067-1240. sor): Két üzemmódra bontás:
- **A) tier_id megadva**: Teljes csomagváltás (meglévő logika)
- **B) tier_id = None**: Aktív előfizetés `extra_allowances` és `valid_until` frissítése `sa_update`-dal
- `frontend_admin/pages/garages/[id]/index.vue` — Nem volt szükség módosításra, a frontend payload konstrukciója már helyes volt.
### ✅ Verifikáció
- Python syntax: ✅ Sikeres compile
- Sync Engine: ✅ 1279 OK, 0 Fixed, 0 Extra — teljes szinkronban
## 2026-06-27 13:16 UTC — P0 HOTFIX: 422 Tier ID & Missing Expiration Payload
**Gitea Card:** #302
**Problem:** PATCH /admin/organizations/{org_id}/subscription returned 422 when tier_id was omitted (add-on-only update). Frontend omitted expires_at when no base tier selected.
**Root Cause (BACKEND):** `OrgSubscriptionUpdate.tier_id` was `int = Field(...)` (required). Changed to `Optional[int] = Field(default=None)`. Endpoint split into Mode A (tier_id present → full switch) and Mode B (tier_id=None → add-on/expiry update).
**Root Cause (FRONTEND):** Button disabled on `!selectedTierId` only. Payload construction didn't handle tierless updates. Fixed: linear payload, `addonsChanged` computed, always send extra_allowances.
**CRITICAL Deployment Fix:** Code changes were on disk but containers were NOT restarted:
- sf_api (23h uptime, uvicorn without --reload)
- sf_admin_frontend (24h uptime)
Both restarted at 13:13 UTC to apply fixes.
**Verification:** All 4 Pydantic schema validation tests PASS.
## 2026-06-27 - P0 Hotfix: 500 Internal Server Error on GET /admin/organizations/{id}/details
### 🎯 Cél
A garázs részletek betöltésekor (GET /api/v1/admin/organizations/43/details) 500-as hiba jelentkezett. A korábbi hotfix (422 Tier ID fix) során hozzáadott lazy importok hibás elérési útvonalakat tartalmaztak.
### 🔧 Javítások (admin_organizations.py)
1. **Line 490:** `from app.models.fleet.vehicle import Vehicle``from app.models import Vehicle` (Asset class a `app.models.vehicle.asset`-ben, `Vehicle` néven exportálva a `app.models`-ből)
2. **Line 492:** `Vehicle.organization_id``Vehicle.current_organization_id` (Asset modellben nincs `organization_id`, helyette `current_organization_id`)
3. **Line 493:** `Vehicle.is_deleted == False` törölve (Asset modellben nincs `is_deleted` mező)
4. **Line 499:** `from app.models.fleet.organization import Branch``from app.models.marketplace.organization import Branch` (Branch a marketplace sémában van)
5. **Line 155 (BranchBrief.id):** `int``str` (Branch elsődleges kulcsa UUID, nem integer)
6. **Line 507:** `b.id``str(b.id)` (UUID konvertálása stringgé)
7. **Line 510:** `b.is_active``not b.is_deleted` (Branch modellben nincs `is_active`, helyette `is_deleted` boolean)
### ✅ Verifikáció
- `GET /api/v1/admin/organizations/43/details`**200 OK** (34 mezővel, köztük branches, members, subscription)
- Minden import sikeresen betöltődik
## 2026-06-27 - P0 JSONB NESTING HOTFIX: Allowances Object & i18n Keys
### 🎯 Cél
Fix the `_extract_limit` helper to also search inside a nested `"allowances"` dict within the tier rules JSONB, and add missing i18n keys for the add-on input sub-labels.
### 🔧 Változtatások
**1. Backend — Nested JSONB lookup**
- Fájl: `backend/app/api/v1/endpoints/admin_organizations.py` (line 601)
- Módosítás: `_extract_limit()` helper now checks `rules.get("allowances", {})` after checking root-level keys.
- Hatás: Tier limits stored as `{"allowances": {"max_vehicles": 20}}` are now correctly extracted.
**2. Frontend — Missing i18n keys**
- Fájlok: `frontend_admin/i18n/locales/en.json`, `frontend_admin/i18n/locales/hu.json`
- Hozzáadva: `garages.details.branches` ("branches"/"telephely") és `garages.details.employees` ("employees"/"dolgozó")
- Hatás: A `+db {{ $t('garages.details.branches') }}` és `+db {{ $t('garages.details.employees') }}` feliratok már nem jelennek meg nyers kulcsként.
### ✅ Verifikáció
- Python syntax check: ✅ OK
- Sync engine: 1279 OK, 0 Fixed, 0 Shadow — teljes szinkronban
## 2026-06-27 - P0 ARCHITECTURE UNIFICATION: Eradicate Path A & Fix Limit(1)
### 🎯 Cél
A backend `admin_organizations.py` fájlban két külön kódút (Path A és Path B) számolta a subscription limiteket. Path A (a `get_organization_details` végpontban) még a régi `rules.get("max_vehicles")` logikát használta ahelyett, hogy az `_extract_limit()` helpert hívta volna, ami a JSONB `allowances` nested objektumba is belenéz. Emellett a subscription query `.limit(1)`-et használt, ami megtörte a multi-subscription (Base + Add-on) aggregációt.
### 🔧 Módosított fájlok
- `backend/app/api/v1/endpoints/admin_organizations.py` — Három kritikus változtatás:
**1. `_extract_limit()` helper létrehozása** (lines 397-447)
- Új, robusztus függvény a limit értékek kinyerésére a `SubscriptionTier.rules` JSONB-ból.
- Három szinten keres: (1) közvetlenül a `rules` objektumban, (2) `rules["allowances"]` nested objektumban, (3) `rules["limits"]` nested objektumban.
- Több kulcsot is támogat (pl. `["max_vehicles", "max_assets"]`), az első találatot adja vissza.
**2. `.limit(1)` eltávolítása a subscription query-ből** (lines 499-514)
- A subscription lekérdezés most `.scalars().all()`-t használ `.limit(1)` helyett.
- Minden aktív subscription sort visszaad (1 Base + N Add-on).
**3. Path A és Path B egyesítése** (lines 574-691)
- A régi Path A (`if active_sub and active_sub.tier:`) és Path B (`elif org.subscription_tier:`) logika helyett egységes, `_extract_limit()`-t használó kód.
- Base subscription-ok és Add-on subscription-ok szétválasztása a `tier.type` mező alapján.
- Add-on tier-ek base limitjeinek aggregálása a primary subscription limitjeihez.
- `extra_allowances` aggregálása az összes aktív subscription-ből.
### ✅ Verifikáció
- Python syntax check: ✅ OK (`py_compile` sikeres)
- Sync engine: 1279 OK, 0 Fixed, 0 Shadow — teljes szinkronban
- A `SubscriptionSummary` most helyesen kapja meg a Base limit értékeket az `_extract_limit()`-en keresztül, mielőtt az Extra allowances hozzáadódnak.

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) # 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}", 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() now = datetime.utcnow()
sub_stmt = ( sub_stmt = (
select(OrganizationSubscription) select(OrganizationSubscription)
@@ -449,10 +509,9 @@ async def get_organization_details(
OrganizationSubscription.valid_until >= now, OrganizationSubscription.valid_until >= now,
) )
.order_by(OrganizationSubscription.id.desc()) .order_by(OrganizationSubscription.id.desc())
.limit(1)
) )
sub_result = await db.execute(sub_stmt) 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 # 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 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_result = await db.execute(contact_stmt)
contact = contact_result.scalar_one_or_none() contact = contact_result.scalar_one_or_none()
# 5. Subscription összefoglaló — P0 ADD-ON UPGRADE # 5. Subscription összefoglaló — P0 ARCHITECTURE UNIFICATION
# Fix: explicit dict.get() instead of `or` (bugfix: 0 is falsy in Python) # Unified limit extraction via _extract_limit() helper.
# Add: extra_allowances math (Base + Extra) # Multi-subscription aggregation: Base + Add-on tiers.
# Path A and Path B are now ONE unified code path.
subscription_summary = None subscription_summary = None
if active_sub and active_sub.tier: if active_subs:
rules = active_sub.tier.rules or {} # ── P0 ARCHITECTURE UNIFICATION: Aggregate across ALL active subscriptions ──
fc = active_sub.tier.feature_capabilities or {} # Separate Base subscriptions from Add-on subscriptions
extra = active_sub.extra_allowances or {} 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) # Use the highest-tier Base subscription for tier metadata
base_vehicles = rules.get("max_vehicles", fc.get("max_vehicles", 1)) # (sort by tier_level descending, pick the first)
base_branches = rules.get("max_branches", fc.get("max_branches", 0)) base_subs_sorted = sorted(
base_users = rules.get("max_users", fc.get("max_users", 0)) base_subs,
key=lambda s: s.tier.tier_level if s.tier else 0,
# Extra allowances (add-ons) reverse=True,
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,
) )
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: elif org.subscription_tier:
# Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription)
rules = org.subscription_tier.rules or {} rules = org.subscription_tier.rules or {}
fc = org.subscription_tier.feature_capabilities or {} fc = org.subscription_tier.feature_capabilities or {}
extra = {}
base_vehicles = rules.get("max_vehicles", fc.get("max_vehicles", 1)) base_vehicles = _extract_limit(
base_branches = rules.get("max_branches", fc.get("max_branches", 0)) rules, ["max_vehicles", "max_assets"],
base_users = rules.get("max_users", fc.get("max_users", 0)) _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( subscription_summary = SubscriptionSummary(
tier_name=org.subscription_tier.name, tier_name=org.subscription_tier.name,

View File

@@ -69,6 +69,10 @@ class SubscriptionTier(Base):
""" """
Előfizetési csomagok definíciója (pl. Free, Premium, VIP). Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
A csomagok határozzák meg a korlátokat (pl. max járműszám). A csomagok határozzák meg a korlátokat (pl. max járműszám).
P0 MULTI-SUBSCRIPTION AGGREGATION:
A `type` mező különbözteti meg a Base tier-eket ('private', 'corporate', 'business')
az Add-on tier-ektől ('addon'). Ez lehetővé teszi az "1 Base + N Add-on" modellt.
""" """
__tablename__ = "subscription_tiers" __tablename__ = "subscription_tiers"
__table_args__ = {"schema": "system"} __table_args__ = {"schema": "system"}
@@ -78,6 +82,18 @@ class SubscriptionTier(Base):
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5} rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
is_custom: Mapped[bool] = mapped_column(Boolean, default=False) is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
# ── P0 MULTI-SUBSCRIPTION AGGREGATION: Base vs Add-on típus ──
# 'base' = core tier (individual, corporate, business)
# 'addon' = add-on tier (extra vehicles, branches, users)
# None/empty = treated as 'base' for backward compatibility
type: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
default=None,
server_default=text("NULL"),
comment="Tier type: 'base' for core subscriptions, 'addon' for add-on tiers. NULL = base (backward compat)."
)
# ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ── # ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ──
# Defines what features/capabilities this subscription tier unlocks. # Defines what features/capabilities this subscription tier unlocks.
# Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50} # Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50}

View File

@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test Script
This script simulates the exact _extract_limit() function and feeds it
the ACTUAL JSON data from the database to verify if the limit extraction
works correctly for corporate tiers.
Author: System Architect
Date: 2026-06-28
"""
import json
import sys
from typing import Any
# =============================================================================
# EXACT copy of _extract_limit() from admin_organizations.py:397
# =============================================================================
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
# =============================================================================
# ACTUAL database JSON data (retrieved via PostgreSQL MCP query on 2026-06-28)
# =============================================================================
# corp_premium_plus_v1 (id=17) — exact JSON as stored in system.subscription_tiers.rules
CORP_PREMIUM_PLUS_V1_RAW = """{
"type": "corporate",
"pricing": {
"currency": "EUR",
"credit_price": 6000,
"yearly_price": 599.99,
"monthly_price": 59.99
},
"duration": {
"days": 30,
"allow_stacking": true
},
"ad_policy": {
"show_ads": false,
"ad_free_grace_days": 0,
"max_daily_impressions": null
},
"affiliate": {
"referral_bonus_credits": 250,
"commission_rate_percent": 20
},
"lifecycle": {
"is_public": true
},
"marketing": {
"badge": "Plus",
"subtitle": "Nagy flották prémium menedzsmentje.",
"highlight_color": "#8B5CF6"
},
"allowances": {
"max_garages": 10,
"max_vehicles": 50,
"monthly_free_credits": 800
},
"display_name": "Céges Prémium Plus",
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD",
"SRV_ACCOUNTING_SYNC"
],
"pricing_zones": {
"HU": {
"currency": "HUF",
"credit_price": 6000,
"yearly_price": 239990,
"monthly_price": 23990
},
"US": {
"currency": "USD",
"credit_price": 6000,
"yearly_price": 699.99,
"monthly_price": 69.99
},
"DEFAULT": {
"currency": "EUR",
"credit_price": 6000,
"yearly_price": 599.99,
"monthly_price": 59.99
}
}
}"""
# corp_premium_v1 (id=16) — exact JSON as stored
CORP_PREMIUM_V1_RAW = """{
"type": "corporate",
"pricing": {
"currency": "EUR",
"credit_price": 3000,
"yearly_price": 299.99,
"monthly_price": 29.99
},
"duration": {
"days": 30,
"allow_stacking": true
},
"ad_policy": {
"show_ads": false,
"ad_free_grace_days": 0,
"max_daily_impressions": null
},
"affiliate": {
"referral_bonus_credits": 100,
"commission_rate_percent": 15
},
"lifecycle": {
"is_public": true
},
"marketing": {
"badge": "Prémium",
"subtitle": "Közepes flották számára tervezve.",
"highlight_color": "#3B82F6"
},
"allowances": {
"max_garages": 3,
"max_vehicles": 20,
"monthly_free_credits": 300
},
"display_name": "Céges Prémium",
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD"
],
"pricing_zones": {
"HU": {
"currency": "HUF",
"credit_price": 3000,
"yearly_price": 119990,
"monthly_price": 11990
},
"US": {
"currency": "USD",
"credit_price": 3000,
"yearly_price": 349.99,
"monthly_price": 34.99
},
"DEFAULT": {
"currency": "EUR",
"credit_price": 3000,
"yearly_price": 299.99,
"monthly_price": 29.99
}
}
}"""
# private_pro_v1 (id=14) — exact JSON as stored
PRIVATE_PRO_V1_RAW = """{
"type": "private",
"lifecycle": {
"is_public": true,
"available_until": null
},
"marketing": {
"badge": null,
"subtitle": null
},
"allowances": {
"max_garages": 1,
"max_vehicles": 3,
"monthly_free_credits": 0
},
"pricing_zones": {
"HU": {
"currency": "HUF",
"credit_price": 25000,
"yearly_price": 9990.0,
"monthly_price": 990.0
},
"US": {
"currency": "USD",
"credit_price": 25000,
"yearly_price": 35.9,
"monthly_price": 3.5
},
"DEFAULT": {
"currency": "EUR",
"credit_price": 25000,
"yearly_price": 29.9,
"monthly_price": 2.99
}
}
}"""
# feature_capabilities data (as stored)
CORP_PREMIUM_PLUS_V1_FC_RAW = "{}"
CORP_PREMIUM_V1_FC_RAW = "{}"
PRIVATE_PRO_V1_FC_RAW = """{"export_data": false, "advanced_reports": true, "max_cost_category_depth": 3}"""
# =============================================================================
# JSON double-encoding simulation
# =============================================================================
DOUBLE_ENCODED_RAW = json.dumps(CORP_PREMIUM_PLUS_V1_RAW)
# =============================================================================
# Test harness
# =============================================================================
def print_separator(title: str):
print(f"\n{'='*80}")
print(f" {title}")
print(f"{'='*80}")
def test_single_tier(
tier_name: str,
rules_raw: str,
fc_raw: str,
vehicle_keys: list,
branch_keys: list,
user_keys: list,
expected_vehicles: int,
expected_branches: int,
expected_users: int,
):
"""Test _extract_limit for a single tier, simulating the exact endpoint logic."""
print(f"\n ┌─ Tier: {tier_name}")
rules = json.loads(rules_raw)
fc = json.loads(fc_raw)
print(f" ├─ rules type: {type(rules).__name__}")
print(f" ├─ rules keys: {list(rules.keys())}")
print(f" ├─ 'allowances' key present: {'allowances' in rules}")
if 'allowances' in rules:
print(f" ├─ allowances content: {json.dumps(rules['allowances'], indent=4)}")
base_vehicles = _extract_limit(
rules, vehicle_keys,
_extract_limit(fc, vehicle_keys, 1),
)
base_branches = _extract_limit(
rules, branch_keys,
_extract_limit(fc, branch_keys, 0),
)
base_users = _extract_limit(
rules, user_keys,
_extract_limit(fc, user_keys, 0),
)
print(f" ├─ RESULT: vehicles={base_vehicles} (expected={expected_vehicles})")
print(f" ├─ RESULT: branches={base_branches} (expected={expected_branches})")
print(f" └─ RESULT: users={base_users} (expected={expected_users})")
status = "✅ PASS" if (base_vehicles == expected_vehicles and
base_branches == expected_branches and
base_users == expected_users) else "❌ FAIL"
print(f" {status}")
return status
def test_double_encoding_scenario():
"""Test what happens if the JSON is double-encoded."""
print_separator("SIMULATION: Double-encoded JSON (a known anti-pattern)")
double_encoded = DOUBLE_ENCODED_RAW
print(f"\n Double-encoded value preview: {double_encoded[:80]}...")
print(f" Double-encoded type: {type(double_encoded).__name__}")
parsed = json.loads(double_encoded)
print(f" After json.loads(): type={type(parsed).__name__}")
print(f" After json.loads(): is dict? {isinstance(parsed, dict)}")
print(f" After json.loads(): is str? {isinstance(parsed, str)}")
if isinstance(parsed, str):
print(f"\n ❌ DOUBLE-ENCODING DETECTED!")
print(f" _extract_limit receives a STRING, not a dict!")
print(f" First check: 'if not rules or not isinstance(rules, dict):'")
print(f" → Returns default=0 immediately!")
result = _extract_limit(parsed, ["max_vehicles", "max_assets"], 0)
print(f" _extract_limit(string, ...) = {result}")
else:
print(f"\n ✅ No double-encoding. Data is properly deserialized as dict.")
def test_edge_cases():
"""Test edge cases and potential failure modes."""
print_separator("EDGE CASE TESTS")
result = _extract_limit({}, ["max_vehicles"], 5)
print(f" Empty dict -> {result} (expected: 5) {'' if result == 5 else ''}")
result = _extract_limit(None, ["max_vehicles"], 5)
print(f" None -> {result} (expected: 5) {'' if result == 5 else ''}")
result = _extract_limit("not a dict", ["max_vehicles"], 5)
print(f" String input -> {result} (expected: 5) {'' if result == 5 else ''}")
result = _extract_limit({"max_vehicles": 100}, ["max_vehicles", "max_assets"], 0)
print(f" Direct key match -> {result} (expected: 100) {'' if result == 100 else ''}")
result = _extract_limit({"allowances": {"max_vehicles": 50}}, ["max_vehicles", "max_assets"], 0)
print(f" allowances['max_vehicles'] -> {result} (expected: 50) {'' if result == 50 else ''}")
result = _extract_limit({"limits": {"max_vehicles": 25}}, ["max_vehicles", "max_assets"], 0)
print(f" limits['max_vehicles'] -> {result} (expected: 25) {'' if result == 25 else ''}")
# =============================================================================
# MAIN
# =============================================================================
def main():
print_separator("P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test")
print(" Date: 2026-06-28")
print(" Source: system.subscription_tiers (PostgreSQL JSONB)")
# --- Test 1: corp_premium_plus_v1 ---
print_separator("TEST 1: corp_premium_plus_v1 (id=17)")
print(" Expected: vehicles=50, branches=10, users=?")
print(" Note: 'max_users'/'max_members' keys NOT present in rules JSON.")
print(" The function will fall back to feature_capabilities (empty dict {}),")
print(" then to the nested default (0).")
test_single_tier(
tier_name="corp_premium_plus_v1",
rules_raw=CORP_PREMIUM_PLUS_V1_RAW,
fc_raw=CORP_PREMIUM_PLUS_V1_FC_RAW,
vehicle_keys=["max_vehicles", "max_assets"],
branch_keys=["max_branches", "max_garages"],
user_keys=["max_users", "max_members"],
expected_vehicles=50,
expected_branches=10,
expected_users=0,
)
# --- Test 2: corp_premium_v1 ---
print_separator("TEST 2: corp_premium_v1 (id=16)")
test_single_tier(
tier_name="corp_premium_v1",
rules_raw=CORP_PREMIUM_V1_RAW,
fc_raw=CORP_PREMIUM_V1_FC_RAW,
vehicle_keys=["max_vehicles", "max_assets"],
branch_keys=["max_branches", "max_garages"],
user_keys=["max_users", "max_members"],
expected_vehicles=20,
expected_branches=3,
expected_users=0,
)
# --- Test 3: private_pro_v1 ---
print_separator("TEST 3: private_pro_v1 (id=14)")
test_single_tier(
tier_name="private_pro_v1",
rules_raw=PRIVATE_PRO_V1_RAW,
fc_raw=PRIVATE_PRO_V1_FC_RAW,
vehicle_keys=["max_vehicles", "max_assets"],
branch_keys=["max_branches", "max_garages"],
user_keys=["max_users", "max_members"],
expected_vehicles=3,
expected_branches=1,
expected_users=0,
)
# --- Double-encoding simulation ---
test_double_encoding_scenario()
# --- Edge cases ---
test_edge_cases()
# --- Summary ---
print_separator("SUMMARY")
print("""
KEY FINDINGS FROM DB AUDIT:
1. All corporate tiers (corp_premium_plus_v1, corp_premium_v1)
store their limits under rules['allowances'] nested object.
2. The _extract_limit() function correctly searches at 3 levels:
Level 1: Direct keys in root (rules['max_vehicles'])
Level 2: Nested under 'allowances' (rules['allowances']['max_vehicles'])
Level 3: Nested under 'limits' (rules['limits']['max_vehicles'])
3. For corp_premium_plus_v1: allowances.max_vehicles = 50
Function should return 50, not 0.
4. ROOT CAUSE SUSPECTED: If _extract_limit returns 0 at runtime,
the issue is likely:
a) The 'rules' field is double-encoded (string inside JSONB)
b) The wrong code path is being executed (e.g., no active_subs found)
c) The rules dict is empty {} due to data migration issue
""")
if __name__ == "__main__":
main()