Files
service-finder/docs/p0_subscription_math_trace_report.md
2026-06-29 14:11:15 +00:00

260 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P0 DEEP DIVE DIAGNOSTIC — Subscription Math Trace Report
**Date:** 2026-06-27
**Author:** Fast Coder (Core Developer)
**Scope:** Root cause analysis of `asset_limit` returning 0 for `corp_premium_plus_v1` tier
---
## 1. DATABASE DUMP — Raw JSONB for `corp_premium_plus_v1`
### Query
```sql
SELECT id, name, rules, feature_capabilities
FROM system.subscription_tiers
WHERE name = 'corp_premium_plus_v1';
```
### Result
| Column | Value |
|--------|-------|
| `id` | `17` |
| `name` | `corp_premium_plus_v1` |
| `rules` (JSONB) | See below |
| `feature_capabilities` | `{}` (empty object) |
### Raw `rules` JSONB (pretty-printed)
```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": "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 }
}
}
```
### Key Observation
The vehicle limit **`max_vehicles: 50`** is stored **inside a nested `allowances` object** within the `rules` JSONB column:
```
rules → allowances → max_vehicles: 50
```
The `feature_capabilities` column is **empty** (`{}`).
---
## 2. MATH TRACE — The Aggregation Logic
### File: [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:600)
### The Formula (line 680)
```python
asset_limit = base_vehicles + extra_vehicles + addon_vehicles
```
Where:
- `base_vehicles` ← extracted from the **base tier's** `rules` + `feature_capabilities` + `extra_allowances`
- `extra_vehicles` ← from `base_extra.get("extra_vehicles", 0)` (admin overrides in `OrganizationSubscription.extra_allowances`)
- `addon_vehicles` ← summed across all active add-on subscriptions
### The `_extract_limit` Function (lines 601630)
```python
def _extract_limit(
rules: dict,
fc: dict,
extra: dict,
vehicle_keys: tuple = ("max_vehicles", "vehicle_limit", "max_assets", "assets_limit"),
branch_keys: tuple = ("max_branches", "branch_limit", "max_garages", "garages_limit"),
user_keys: tuple = ("max_users", "user_limit", "max_members", "members_limit"),
default: int = 0,
):
def _pick(keys: tuple) -> int:
# 1. Check root level of rules, fc, extra
for d in (rules, fc, extra):
for k in keys:
val = d.get(k)
if val is not None and isinstance(val, (int, float)):
return int(val)
# 2. Check nested 'allowances' object inside rules
allowances = rules.get("allowances", {})
if isinstance(allowances, dict):
for k in keys:
val = allowances.get(k)
if val is not None and isinstance(val, (int, float)):
return int(val)
return default
return _pick(vehicle_keys), _pick(branch_keys), _pick(user_keys)
```
### How It's Called (line 648)
```python
base_vehicles, base_branches, base_users = _extract_limit(rules, fc, base_extra)
```
Where:
- `rules` = `active_base_sub.tier.rules` (the JSONB shown above)
- `fc` = `active_base_sub.tier.feature_capabilities``{}` (empty)
- `base_extra` = `active_base_sub.extra_allowances``{}` (empty, unless admin set overrides)
---
## 3. ROOT CAUSE ANALYSIS
### Step-by-step trace of `_pick(vehicle_keys)`:
**Phase 1 — Root level scan of `rules`, `fc`, `extra`:**
The function iterates over `(rules, fc, extra)` and checks each key in `vehicle_keys = ("max_vehicles", "vehicle_limit", "max_assets", "assets_limit")`.
| Dictionary | `max_vehicles` | `vehicle_limit` | `max_assets` | `assets_limit` |
|------------|---------------|-----------------|--------------|----------------|
| `rules` (root level) | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
| `fc` = `{}` | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
| `extra` = `{}` | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
**Result: `None` from all three dictionaries.**
**Phase 2 — Nested `allowances` scan:**
```python
allowances = rules.get("allowances", {}) # → {"max_garages": 10, "max_vehicles": 50, "monthly_free_credits": 800}
```
Now it checks `allowances.get("max_vehicles")`**`50`** ✓
**This SHOULD work!** The nested `allowances` fallback was added as a "P0 JSONB NESTING HOTFIX" (see comment on line 612).
### The Real Problem: TWO CODE PATHS
The `get_organization_details` function has **two separate code paths** for computing the subscription summary:
#### Path A: `active_sub` exists (line 519550) — **OLD CODE**
```python
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 {}
# 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))
```
**BUG:** This path uses `rules.get("max_vehicles", ...)` directly — it does **NOT** check the nested `allowances` object! Since `max_vehicles` is at `rules → allowances → max_vehicles`, not at `rules → max_vehicles`, this returns the fallback value of `1`.
#### Path B: Multi-subscription aggregation (line 600699) — **NEW CODE with `_extract_limit`**
```python
if active_base_sub and active_base_sub.tier:
rules = active_base_sub.tier.rules or {}
fc = active_base_sub.tier.feature_capabilities or {}
base_extra = active_base_sub.extra_allowances or {}
base_vehicles, base_branches, base_users = _extract_limit(rules, fc, base_extra)
```
**This path DOES work correctly** because `_extract_limit` checks the nested `allowances` object.
### Which path is being hit?
Looking at the subscription query (lines 443455):
```python
sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
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()
```
This query returns a **single** subscription row (`.limit(1)`). If the org has a `finance.org_subscriptions` row with `is_active=True` and `valid_until >= now`, then `active_sub` is set and **Path A** is executed — which has the bug.
**Path B** (the multi-subscription aggregation with `_extract_limit`) is only reached if the subscription query returns NO rows (`active_sub` is None), which would be the case if the org has no subscription record.
### Summary of the Bug
| Aspect | Detail |
|--------|--------|
| **Bug Location** | [`admin_organizations.py:525`](backend/app/api/v1/endpoints/admin_organizations.py:525) — Path A |
| **Root Cause** | `rules.get("max_vehicles", ...)` only checks the **root level** of the `rules` JSONB. The actual value is nested inside `rules → allowances → max_vehicles`. |
| **Affected Data** | All tiers that store limits inside a nested `allowances` object (all tiers in the DB use this structure) |
| **Why it returns 0** | It doesn't return 0 — it returns `1` (the fallback from `fc.get("max_vehicles", 1)`). But the **Total Limit** equals the **Extra Limit** because the base calculation returns only the fallback `1` instead of the real `50`. |
| **Fix** | Path A needs to also check the nested `allowances` object, just like `_extract_limit` does in Path B. |
---
## 4. COMPARISON: `evidence.py` Works Correctly
Interestingly, the [`evidence.py`](backend/app/api/v1/endpoints/evidence.py:36) endpoint already handles this correctly:
```python
if tier and tier.rules:
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
max_allowed = max(int(max_vehicles), 1)
```
It directly accesses `rules → allowances → max_vehicles` with the nested `.get("allowances", {}).get("max_vehicles", 1)` pattern.
---
## 5. CONCLUSION
The `_extract_limit` function (Path B) is **correct** — it properly handles the nested `allowances` structure. However, the **old Path A** (lines 519550) is still active and is the one being hit when the org has a valid `finance.org_subscriptions` row. Path A uses `rules.get("max_vehicles", ...)` which only checks the root level of the JSONB, missing the nested `allowances` object.
**The fix is to update Path A** (lines 525527) to also search inside the nested `allowances` object, or to refactor Path A to use the same `_extract_limit` helper that Path B uses.