Files
cfw-autumn/server/src/_luaScripts/luaUtils/loadBalances.lua
2025-11-20 16:58:07 +00:00

710 lines
28 KiB
Lua

-- loadBalances.lua
-- Shared function to load customer balances with merged entity balances (customer + entities)
-- Returns: { [featureId] = { granted_balance, purchased_balance, current_balance, usage, ... } } or nil if not in cache
-- Helper function to safely convert values to numbers for arithmetic
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Helper function to parse HGETALL result into balance data object
local function parseBalanceHash(balanceHash)
local balanceData = {}
-- Define field types for parsing
local numericFields = {
granted_balance = true,
purchased_balance = true,
current_balance = true,
usage = true,
max_purchase = true,
_breakdown_count = true,
_rollover_count = true
}
local booleanFields = {
unlimited = true,
overage_allowed = true
}
local jsonFields = {
feature = true,
reset = true
}
for i = 1, #balanceHash, 2 do
local key = balanceHash[i]
local value = balanceHash[i + 1]
-- Check for null first before parsing
if value == "null" then
balanceData[key] = cjson.null
elseif numericFields[key] then
balanceData[key] = tonumber(value)
elseif booleanFields[key] then
balanceData[key] = (value == "true")
elseif jsonFields[key] then
-- Parse JSON fields (feature object and reset object)
if value ~= "null" and value ~= "" then
balanceData[key] = cjson.decode(value)
else
balanceData[key] = cjson.null
end
else
balanceData[key] = value
end
end
return balanceData
end
-- Helper function to fetch and parse rollover items
-- Returns: array of rollover data objects, or nil if any key is missing (partial eviction)
-- cacheKey: base cache key (customer or entity cache key)
-- featureId: feature ID
-- rolloverCount: number of rollover items to fetch
local function fetchRollovers(cacheKey, featureId, rolloverCount)
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = buildRolloverCacheKey(cacheKey, featureId, i)
local rolloverHash = redis.call("HGETALL", rolloverKey)
-- If rollover key is missing, return nil (partial eviction detected)
if #rolloverHash == 0 then
return nil
end
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
return rollovers
end
-- Helper function to fetch and parse breakdown items
-- Returns: array of breakdown data objects, or nil if any key is missing (partial eviction)
-- cacheKey: base cache key (customer or entity cache key)
-- featureId: feature ID
-- breakdownCount: number of breakdown items to fetch
local function fetchBreakdown(cacheKey, featureId, breakdownCount)
local breakdown = {}
-- Define field types for parsing breakdown items
local breakdownNumericFields = {
granted_balance = true,
purchased_balance = true,
current_balance = true,
usage = true,
max_purchase = true
}
local breakdownBooleanFields = {
overage_allowed = true
}
local breakdownJsonFields = {
reset = true
}
for i = 0, breakdownCount - 1 do
local breakdownKey = buildBreakdownCacheKey(cacheKey, featureId, i)
local breakdownHash = redis.call("HGETALL", breakdownKey)
-- If breakdown key is missing, return nil (partial eviction detected)
if #breakdownHash == 0 then
return nil
end
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif breakdownNumericFields[key] then
breakdownData[key] = tonumber(value)
elseif breakdownBooleanFields[key] then
breakdownData[key] = (value == "true")
elseif breakdownJsonFields[key] then
-- Parse reset JSON object
if value ~= "null" and value ~= "" then
breakdownData[key] = cjson.decode(value)
else
breakdownData[key] = cjson.null
end
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
return breakdown
end
-- ============================================================================
-- MERGE BALANCE UTILITIES
-- ============================================================================
-- Helper function to merge numeric balance fields (sums values)
-- Mutates target by adding source's numeric fields
local function mergeBalanceNumericFields(target, source)
target.granted_balance = toNum(target.granted_balance) + toNum(source.granted_balance)
target.purchased_balance = toNum(target.purchased_balance) + toNum(source.purchased_balance)
target.current_balance = toNum(target.current_balance) + toNum(source.current_balance)
target.usage = toNum(target.usage) + toNum(source.usage)
target.max_purchase = toNum(target.max_purchase or 0) + toNum(source.max_purchase or 0)
end
-- Helper function to merge overage_allowed (true if at least one is true)
-- Mutates target
local function mergeBalanceOverageAllowed(target, source)
if source.overage_allowed == true then
target.overage_allowed = true
end
end
-- Helper function to merge reset objects (uses minimum resets_at)
-- Mutates target
local function mergeBalanceReset(target, source)
if source.reset and source.reset ~= cjson.null and type(source.reset) == "table" and source.reset.resets_at then
local sourceResetsAt = source.reset.resets_at
if type(sourceResetsAt) == "number" then
if target.reset and target.reset ~= cjson.null and type(target.reset) == "table" and target.reset.resets_at then
local targetResetsAt = target.reset.resets_at
if type(targetResetsAt) == "number" then
if sourceResetsAt < targetResetsAt then
target.reset.resets_at = sourceResetsAt
end
else
target.reset.resets_at = sourceResetsAt
end
else
-- Initialize reset object if it doesn't exist
target.reset = {
interval = source.reset.interval,
interval_count = source.reset.interval_count,
resets_at = sourceResetsAt
}
end
end
end
end
-- Helper function to generate breakdown item key for matching
-- Key format: "interval_count:interval:overage_allowed"
-- Example: "1:month:true" or "1:month:false"
local function getBreakdownItemKey(breakdownItem)
if not breakdownItem then
return nil
end
local intervalCount = 1
local interval = "none"
-- Extract interval and interval_count from reset object
if breakdownItem.reset and breakdownItem.reset ~= cjson.null and type(breakdownItem.reset) == "table" then
interval = breakdownItem.reset.interval or "none"
intervalCount = breakdownItem.reset.interval_count or 1
end
-- Get overage_allowed (usage model)
local overageAllowed = breakdownItem.overage_allowed or false
-- Return key in format: "interval_count:interval:overage_allowed"
return tostring(intervalCount) .. ":" .. interval .. ":" .. tostring(overageAllowed)
end
-- Helper function to merge source balance into target balance
-- Mutates targetBalance by adding sourceBalance's balances, usage, breakdowns, and rollovers
-- Also handles minimum resets_at (earliest reset time) and overage_allowed (true if any is true)
local function mergeFeatureBalances(targetBalance, sourceBalance)
if not sourceBalance then return end
-- Merge top-level balance fields
mergeBalanceNumericFields(targetBalance, sourceBalance)
mergeBalanceOverageAllowed(targetBalance, sourceBalance)
mergeBalanceReset(targetBalance, sourceBalance)
-- Merge breakdown balances and usage
-- Breakdown items are matched by key (interval_count:interval:overage_allowed)
-- If a matching breakdown exists, merge it; otherwise, add as new breakdown item
if sourceBalance.breakdown then
for _, sourceBreakdown in ipairs(sourceBalance.breakdown) do
local sourceKey = getBreakdownItemKey(sourceBreakdown)
local foundMatch = false
-- Try to find matching breakdown by key
if targetBalance.breakdown then
for _, targetBreakdown in ipairs(targetBalance.breakdown) do
local targetKey = getBreakdownItemKey(targetBreakdown)
if sourceKey and targetKey and sourceKey == targetKey then
-- Found matching breakdown - merge it
mergeBalanceNumericFields(targetBreakdown, sourceBreakdown)
mergeBalanceOverageAllowed(targetBreakdown, sourceBreakdown)
mergeBalanceReset(targetBreakdown, sourceBreakdown)
foundMatch = true
break
end
end
end
-- If no matching breakdown found, add as new breakdown item
if not foundMatch then
if not targetBalance.breakdown then
targetBalance.breakdown = {}
end
-- Create a copy of the source breakdown to add
local newBreakdown = {
granted_balance = sourceBreakdown.granted_balance,
purchased_balance = sourceBreakdown.purchased_balance,
current_balance = sourceBreakdown.current_balance,
usage = sourceBreakdown.usage,
max_purchase = sourceBreakdown.max_purchase,
overage_allowed = sourceBreakdown.overage_allowed,
reset = sourceBreakdown.reset
}
table.insert(targetBalance.breakdown, newBreakdown)
end
end
end
-- Merge rollover balances
if sourceBalance.rollovers and #sourceBalance.rollovers > 0 then
-- Both have rollovers, merge them
if targetBalance.rollovers and #targetBalance.rollovers > 0 then
for i, targetRollover in ipairs(targetBalance.rollovers) do
local sourceRollover = sourceBalance.rollovers[i]
if sourceRollover then
targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance)
end
end
end
end
end
-- ============================================================================
-- LOAD SINGLE BALANCE (WITH _key FIELDS FOR REDIS OPERATIONS)
-- ============================================================================
-- Load a single balance from Redis cache (no merging)
-- Used by batchDeduction.lua for on-demand balance loading with Redis operation keys
-- Parameters:
-- cacheKey: Base cache key (customer or entity cache key)
-- featureId: Feature ID to load
-- Returns: balance object with _key fields for Redis operations, or nil if not found
local function loadBalance(cacheKey, featureId)
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash == 0 then
return nil
end
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
balanceData._key = balanceKey -- Add Redis key for operations
-- Fetch rollovers using helper function
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil
local rollovers = fetchRollovers(cacheKey, featureId, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
-- Add _key fields to rollovers for Redis operations
if #rollovers > 0 then
for index, rollover in ipairs(rollovers) do
rollover._key = buildRolloverCacheKey(cacheKey, featureId, index - 1)
rollover._index = index - 1
end
balanceData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil
local breakdown = fetchBreakdown(cacheKey, featureId, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
-- Add _key fields to breakdown items for Redis operations
if #breakdown > 0 then
for index, breakdownItem in ipairs(breakdown) do
breakdownItem._key = buildBreakdownCacheKey(cacheKey, featureId, index - 1)
breakdownItem._index = index - 1
end
balanceData.breakdown = breakdown
end
return balanceData
end
-- ============================================================================
-- LOAD BALANCES WITH MERGING
-- ============================================================================
-- Load entity-level balances (entity + customer merged)
-- Used for entity-level sync mode
-- Parameters: cacheKey (customer cache key), orgId, env, customerId, entityId
-- Returns: merged balances table (entity + customer) or nil
local function loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
-- Build versioned entity cache key using shared utility
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
-- Get entity base JSON
local entityBaseJson = redis.call("GET", entityCacheKey)
if not entityBaseJson then
return nil
end
local entityBase = cjson.decode(entityBaseJson)
local entityBalanceFeatureIds = entityBase._balanceFeatureIds or {}
-- Load entity balances
local entityBalances = {}
for _, featureId in ipairs(entityBalanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(entityCacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
-- If balance key is missing, return nil (partial eviction detected)
if #balanceHash == 0 then
return nil
end
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers using helper function
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil
local rollovers = fetchRollovers(entityCacheKey, featureId, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil
local breakdown = fetchBreakdown(entityCacheKey, featureId, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
entityBalances[featureId] = balanceData
end
-- Load customer balances (raw, no entity aggregation)
local customerCacheKey = cacheKey
local customerBaseJson = redis.call("GET", customerCacheKey)
local customerBalances = {}
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
local customerBalanceFeatureIds = customerBase._balanceFeatureIds or {}
for _, featureId in ipairs(customerBalanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(customerCacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash > 0 then
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil
local rollovers = fetchRollovers(customerCacheKey, featureId, rolloverCount) or {}
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil
local breakdown = fetchBreakdown(customerCacheKey, featureId, breakdownCount) or {}
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
customerBalances[featureId] = balanceData
end
end
end
-- Merge customer and entity balances (entity + customer)
local mergedBalances = {}
-- First, add all customer balances (inherited)
for featureId, customerBalance in pairs(customerBalances) do
mergedBalances[featureId] = customerBalance
end
-- Then, merge or add entity balances
for featureId, entityBalance in pairs(entityBalances) do
local customerBalance = customerBalances[featureId]
if customerBalance then
-- Both customer and entity have this balance - merge balances
if not entityBalance.unlimited and not customerBalance.unlimited then
mergeFeatureBalances(entityBalance, customerBalance)
end
mergedBalances[featureId] = entityBalance
else
-- Only entity has this balance - use entity's balance
mergedBalances[featureId] = entityBalance
end
end
return mergedBalances
end
-- Load customer balances with merged entity balances
-- Parameters: cacheKey, orgId, env, customerId, entityId (optional)
-- If entityId is "__CUSTOMER_ONLY__": returns ONLY customer balances (no merging)
-- If entityId is provided (string): returns entity-level merged balances (entity + customer)
-- If entityId is nil: returns customer-level merged balances (customer + all entities)
-- Returns: merged balances table or nil
local function loadBalances(cacheKey, orgId, env, customerId, entityId)
-- Special case: Customer-only mode (no entity merging)
if entityId == "__CUSTOMER_ONLY__" then
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local base = cjson.decode(baseJson)
local balanceFeatureIds = base._balanceFeatureIds or {}
-- Load only customer's own balances without entity merging
local customerBalances = {}
for _, featureId in ipairs(balanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash == 0 then
return nil -- Partial eviction detected
end
-- Parse balance hash
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers
local rollovers = fetchRollovers(cacheKey, featureId, balanceData._rollover_count or 0)
if rollovers == nil then
return nil -- Partial eviction
end
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown
local breakdown = fetchBreakdown(cacheKey, featureId, balanceData._breakdown_count or 0)
if breakdown == nil then
return nil -- Partial eviction
end
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
-- Remove metadata fields
balanceData._breakdown_count = nil
balanceData._rollover_count = nil
customerBalances[featureId] = balanceData
end
return customerBalances
end
-- If entityId is provided, load entity-level balances (entity + customer merged)
if entityId then
return loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
end
-- Otherwise, load customer-level balances (customer + all entities merged)
-- Get base customer JSON
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local balanceFeatureIds = baseCustomer._balanceFeatureIds or {}
local entityIds = baseCustomer._entityIds or {}
-- Build balances object
local balances = {}
for _, featureId in ipairs(balanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
-- If balance key is missing, return nil (partial eviction detected)
if #balanceHash == 0 then
return nil
end
-- Parse balance hash using helper function
local balanceData = parseBalanceHash(balanceHash)
-- Fetch rollovers using helper function
local rolloverCount = balanceData._rollover_count or 0
balanceData._rollover_count = nil -- Remove from final output
local rollovers = fetchRollovers(cacheKey, featureId, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
balanceData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = balanceData._breakdown_count or 0
balanceData._breakdown_count = nil -- Remove from final output
local breakdown = fetchBreakdown(cacheKey, featureId, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
balanceData.breakdown = breakdown
end
balances[featureId] = balanceData
end
-- ============================================================================
-- FETCH AND MERGE ENTITY BALANCES
-- ============================================================================
-- Fetch all entity balances and aggregate balances
local entityBalanceData = {} -- {[entityId][featureId] = balanceData}
local entityBaseData = {} -- {[entityId] = entityBase} - Store entity base for product access
for _, entityId in ipairs(entityIds) do
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
local entityBase = cjson.decode(entityBaseJson)
entityBaseData[entityId] = entityBase -- Store entity base for product access
local entityBalanceFeatureIds = entityBase._balanceFeatureIds or {}
entityBalanceData[entityId] = {}
for _, featureId in ipairs(entityBalanceFeatureIds) do
local balanceKey = buildBalanceCacheKey(entityCacheKey, featureId)
local balanceHash = redis.call("HGETALL", balanceKey)
if #balanceHash > 0 then
-- Parse entity balance using helper function
local entityBalance = parseBalanceHash(balanceHash)
-- Fetch breakdown items for this entity balance using helper function
local breakdownCount = entityBalance._breakdown_count or 0
entityBalance._breakdown_count = nil
entityBalance.breakdown = fetchBreakdown(entityCacheKey, featureId, breakdownCount) or {}
-- Fetch rollover items for this entity balance using helper function
local rolloverCount = entityBalance._rollover_count or 0
entityBalance._rollover_count = nil
entityBalance.rollovers = fetchRollovers(entityCacheKey, featureId, rolloverCount) or {}
entityBalanceData[entityId][featureId] = entityBalance
end
end
end
end
-- ============================================================================
-- MERGE ENTITY BALANCES INTO CUSTOMER BALANCES
-- ============================================================================
for featureId, customerBalance in pairs(balances) do
-- Skip if unlimited
if not customerBalance.unlimited then
-- Merge each entity's balances into customer balance
for entityId, entityBalances in pairs(entityBalanceData) do
local entityBalance = entityBalances[featureId]
if entityBalance then
mergeFeatureBalances(customerBalance, entityBalance)
end
end
end
end
-- Add entity-only balances (balances that exist in entities but not in customer)
for entityId, entityBalances in pairs(entityBalanceData) do
for featureId, entityBalance in pairs(entityBalances) do
if not balances[featureId] then
-- This balance doesn't exist in customer, add it with zero values
balances[featureId] = {
feature_id = featureId,
feature = entityBalance.feature,
unlimited = entityBalance.unlimited,
granted_balance = 0,
purchased_balance = 0,
current_balance = 0,
usage = 0,
max_purchase = entityBalance.max_purchase or 0,
overage_allowed = entityBalance.overage_allowed,
reset = entityBalance.reset,
breakdown = {}
}
end
end
end
-- Aggregate balances for entity-only balances using mergeFeatureBalances
for featureId, customerBalance in pairs(balances) do
-- Only process if this was an entity-only balance (all balances are still 0 from initialization)
if customerBalance.granted_balance == 0 and customerBalance.purchased_balance == 0 and customerBalance.current_balance == 0 and customerBalance.usage == 0 then
for entityId, entityBalances in pairs(entityBalanceData) do
local entityBalance = entityBalances[featureId]
if entityBalance then
mergeFeatureBalances(customerBalance, entityBalance)
end
end
end
end
-- Clean up empty rollovers arrays before returning
for featureId, balance in pairs(balances) do
if balance.rollovers and #balance.rollovers == 0 then
balance.rollovers = nil
end
end
-- Return merged balances
return balances
end