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:
127
.roo/history.md
127
.roo/history.md
@@ -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
|
||||
- `test_subscription_feature_flags.py`: All 6 steps passed (login, feature flags, check, public tiers)
|
||||
- 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.
|
||||
|
||||
@@ -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))
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
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 {}
|
||||
|
||||
# Total = Base + Extra
|
||||
asset_limit = base_vehicles + extra_vehicles
|
||||
branch_limit = base_branches + extra_branches
|
||||
user_limit = base_users + extra_users
|
||||
# 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=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,
|
||||
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=extra,
|
||||
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,
|
||||
|
||||
@@ -69,6 +69,10 @@ class SubscriptionTier(Base):
|
||||
"""
|
||||
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).
|
||||
|
||||
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"
|
||||
__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}
|
||||
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 ──
|
||||
# Defines what features/capabilities this subscription tier unlocks.
|
||||
# Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50}
|
||||
|
||||
445
tests/active/test_extract_limit_standalone.py
Normal file
445
tests/active/test_extract_limit_standalone.py
Normal 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()
|
||||
Reference in New Issue
Block a user