admin_users_personels
This commit is contained in:
189
docs/p0_subscription_jsonb_structural_audit_report.md
Normal file
189
docs/p0_subscription_jsonb_structural_audit_report.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# P0 DATA STRUCTURAL AUDIT REPORT: `_extract_limit()` & JSONB Tier Structure
|
||||
|
||||
**Date:** 2026-06-28
|
||||
**Author:** System Architect
|
||||
**Scope:** [`system.subscription_tiers`](backend/app/models/core_logic.py:68), [`finance.org_subscriptions`](backend/app/models/core_logic.py:143), [`_extract_limit()`](backend/app/api/v1/endpoints/admin_organizations.py:397)
|
||||
**Status:** ✅ Structural audit complete — `_extract_limit` logic is **correct**, DB JSONB is **valid**
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: `system.subscription_tiers` (Base Tier Rules)
|
||||
|
||||
### Raw JSON Structure
|
||||
|
||||
All three corporate tiers have their limits stored at **`rules["allowances"]`** (nested object, 2nd level):
|
||||
|
||||
| Tier | ID | `jsonb_typeof(rules)` | `allowances.max_vehicles` | `allowances.max_garages` |
|
||||
|------|----|----------------------|--------------------------|--------------------------|
|
||||
| `private_pro_v1` | 14 | `object` ✅ | 3 | 1 |
|
||||
| `corp_premium_v1` | 16 | `object` ✅ | 20 | 3 |
|
||||
| `corp_premium_plus_v1` | 17 | `object` ✅ | 50 | 10 |
|
||||
|
||||
### Full JSON for `corp_premium_plus_v1` (id=17)
|
||||
|
||||
```json
|
||||
{
|
||||
"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": "Ceiges Preimium 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 }, "DEFAULT": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 } }
|
||||
}
|
||||
```
|
||||
|
||||
### Confirmed: `rules` is a proper JSONB **object**, NOT a string
|
||||
|
||||
```sql
|
||||
SELECT jsonb_typeof(rules) FROM system.subscription_tiers WHERE id = 17;
|
||||
-- Result: "object" ✅ (not "string")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: `finance.org_subscriptions` (Active Subs & Extra Allowances)
|
||||
|
||||
| org_id | Sub ID | Tier | Tier ID | `extra_allowances` |
|
||||
|--------|--------|------|---------|-------------------|
|
||||
| 15 | 2 | `corp_premium_v1` | 16 | (not queried) |
|
||||
| 43 | 30 | `private_pro_v1` | 14 | `{}` |
|
||||
| 44 | 22 | `corp_premium_v1` | 16 | (not queried) |
|
||||
| 45 | 21 | `corp_premium_plus_v1` | 17 | (not queried) |
|
||||
| 49 | 20 | `corp_premium_v1` | 16 | (not queried) |
|
||||
|
||||
For org_id=43: `extra_allowances` = `{}` (empty JSONB object) — no extra allowances purchased.
|
||||
|
||||
---
|
||||
|
||||
## 3. TEST SCRIPT: `_extract_limit()` Behavior
|
||||
|
||||
### Implementation (exact copy from [`admin_organizations.py:397`](backend/app/api/v1/endpoints/admin_organizations.py:397))
|
||||
|
||||
The function searches at **3 levels**:
|
||||
1. **Level 1**: Direct keys at rules root (`rules["max_vehicles"]`)
|
||||
2. **Level 2**: Nested under `rules["allowances"]` (`rules["allowances"]["max_vehicles"]`)
|
||||
3. **Level 3**: Nested under `rules["limits"]` (`rules["limits"]["max_vehicles"]`)
|
||||
|
||||
### Test Results — ALL PASS ✅
|
||||
|
||||
| Tier | Vehicles | Branches | Users | Status |
|
||||
|------|----------|----------|-------|--------|
|
||||
| `corp_premium_plus_v1` | **50** ✅ | **10** ✅ | 0 | **PASS** |
|
||||
| `corp_premium_v1` | **20** ✅ | **3** ✅ | 0 | **PASS** |
|
||||
| `private_pro_v1` | **3** ✅ | **1** ✅ | 0 | **PASS** |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Input | Result | Expected | Status |
|
||||
|-------|--------|----------|--------|
|
||||
| Empty dict `{}` | 5 (default) | 5 | ✅ |
|
||||
| `None` | 5 (default) | 5 | ✅ |
|
||||
| String (not dict) | 5 (default) | 5 | ✅ |
|
||||
| Direct key match | 100 | 100 | ✅ |
|
||||
| `allowances.max_vehicles` | 50 | 50 | ✅ |
|
||||
| `limits.max_vehicles` | 25 | 25 | ✅ |
|
||||
|
||||
### Double-Encoding Simulation
|
||||
|
||||
When the JSON is **double-encoded** (string stored inside JSONB instead of object):
|
||||
|
||||
```
|
||||
Input: '"{\\"allowances\\": {\\"max_vehicles\\": 50}}"'
|
||||
→ json.loads() returns str, not dict
|
||||
→ _extract_limit(str, ...) returns default=0 ❌
|
||||
```
|
||||
|
||||
✅ **Confirmed: Current DB data is NOT double-encoded.** (`jsonb_typeof = "object"`)
|
||||
|
||||
---
|
||||
|
||||
## 4. ROOT CAUSE ANALYSIS
|
||||
|
||||
### The `_extract_limit()` function is **NOT the problem**
|
||||
|
||||
All tests confirm the function correctly extracts limits from the current JSONB structure. It fully supports the `allowances` nested format used by all tiers.
|
||||
|
||||
### The REAL problem is likely in the **code flow** before `_extract_limit` is called
|
||||
|
||||
The endpoint [`get_organization_details()`](backend/app/api/v1/endpoints/admin_organizations.py:462) has **two code paths**:
|
||||
|
||||
#### Path A (line 579-664) — Uses `finance.org_subscriptions` (NEW system)
|
||||
|
||||
```python
|
||||
active_subs = sub_result.scalars().all() # Query finance.org_subscriptions
|
||||
if active_subs:
|
||||
base_subs = [s for s in active_subs if s.tier and s.tier.type in (None, '', 'base')]
|
||||
primary_sub = base_subs_sorted[0] if base_subs_sorted else ...
|
||||
if primary_sub and primary_sub.tier:
|
||||
primary_rules = primary_sub.tier.rules or {} # ← THIS must work
|
||||
```
|
||||
|
||||
#### Path B (line 665-691) — Uses `fleet.organizations.subscription_tier_id` (LEGACY)
|
||||
|
||||
```python
|
||||
elif org.subscription_tier:
|
||||
rules = org.subscription_tier.rules or {} # ← LEGACY path
|
||||
```
|
||||
|
||||
### Potential failure scenarios (outside `_extract_limit`):
|
||||
|
||||
| Scenario | Symptom | Likelihood |
|
||||
|----------|---------|------------|
|
||||
| **Path A → `active_subs` is empty** (no `finance.org_subscriptions` row) | Falls to Path B | Medium |
|
||||
| **Path A → `primary_sub.tier` is `None`** (FK broken, tier missing) | Skips limit calc, `subscription_summary` stays `None` | Low |
|
||||
| **Path B → `org.subscription_tier` points to wrong tier** (old FK mismatch) | Wrong limits returned | Medium |
|
||||
| **`org.subscription_tier.rules` is `None`** (tier exists but rules column is NULL) | `{} or None` → `{}` → default | Low |
|
||||
|
||||
---
|
||||
|
||||
## 5. RECOMMENDATIONS
|
||||
|
||||
### ✅ Short-term: Verify runtime dataflow
|
||||
|
||||
1. **Test with exact org_id** that shows the 0 limit:
|
||||
```sql
|
||||
SELECT os.id, os.org_id, os.tier_id, st.name, st.rules IS NOT NULL as has_rules
|
||||
FROM finance.org_subscriptions os
|
||||
JOIN system.subscription_tiers st ON os.tier_id = st.id
|
||||
WHERE os.org_id = <problematic_org_id> AND os.is_active = true;
|
||||
```
|
||||
|
||||
2. **Check if the endpoint takes Path A or Path B** by inspecting logs/Debug mode.
|
||||
|
||||
### ✅ Long-term: Code improvements (separate ticket)
|
||||
|
||||
1. **Add logging** at key decision points in [`get_organization_details()`](backend/app/api/v1/endpoints/admin_organizations.py:579-614):
|
||||
- Log `active_subs` count
|
||||
- Log which path is taken (A vs B)
|
||||
- Log `primary_rules` keys and extracted limits
|
||||
- Log if `primary_sub.tier` is None
|
||||
|
||||
2. **Add defensive fallback** — if `_extract_limit` returns 0 but `rules` has `allowances`, log a warning:
|
||||
```python
|
||||
base_vehicles = _extract_limit(primary_rules, ["max_vehicles", "max_assets"], fc_fallback)
|
||||
if base_vehicles == 0 and primary_rules.get("allowances", {}).get("max_vehicles"):
|
||||
logger.warning(f"_extract_limit returned 0 despite allowances.max_vehicles={primary_rules['allowances']['max_vehicles']}")
|
||||
```
|
||||
|
||||
### 🔍 Most Probable Root Cause
|
||||
|
||||
The issue is likely that **the org(s) reporting 0 limits do NOT have an active row in `finance.org_subscriptions`** (Path A), and the **legacy Path B is failing** because `org.subscription_tier.rules` is `None` or the old FK points to a tier that doesn't have `allowances` populated.
|
||||
|
||||
**Next step**: Identify the exact org_id(s) that show 0 and check their `finance.org_subscriptions` presence and `fleet.organizations.subscription_tier_id` value.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Test Script Location
|
||||
|
||||
The standalone test script is at:
|
||||
[`tests/active/test_extract_limit_standalone.py`](tests/active/test_extract_limit_standalone.py)
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
docker compose exec sf_api python3 /app/tests/test_extract_limit_standalone.py
|
||||
```
|
||||
Reference in New Issue
Block a user