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

9.4 KiB
Raw Blame History

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

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)

{
  "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

The Formula (line 680)

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)

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)

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:

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

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

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):

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 — 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 endpoint already handles this correctly:

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.