feat: deduct from redis new api schema
This commit is contained in:
390
server/src/_luaScripts/archives/backupBatchDeduction.lua
Normal file
390
server/src/_luaScripts/archives/backupBatchDeduction.lua
Normal file
@@ -0,0 +1,390 @@
|
||||
-- batchDeduction.lua (BACKUP - Original code-generated version)
|
||||
-- Atomically processes a batch of deductions for a specific target feature
|
||||
-- Supports credit system features as alternative payment sources
|
||||
-- KEYS[1]: cache key (e.g., "org_id:env:customer:customer_id")
|
||||
-- KEYS[2]: target feature ID
|
||||
-- ARGV[1]: JSON array of deduction amounts [10, 20, -5, 30, ...] (negative = additions)
|
||||
-- ARGV[2]: overage_behavior ("reject" or "cap")
|
||||
|
||||
local cacheKey = KEYS[1]
|
||||
local targetFeatureId = KEYS[2]
|
||||
local amountsJson = ARGV[1]
|
||||
local overageBehavior = ARGV[2] or "cap"
|
||||
|
||||
-- Parse amounts
|
||||
local amounts = cjson.decode(amountsJson)
|
||||
|
||||
-- Base keys
|
||||
local baseKey = cacheKey
|
||||
|
||||
-- Check if customer exists
|
||||
local baseExists = redis.call("EXISTS", baseKey)
|
||||
if baseExists == 0 then
|
||||
return cjson.encode({
|
||||
success = false,
|
||||
error = "CUSTOMER_NOT_FOUND",
|
||||
successCount = 0
|
||||
})
|
||||
end
|
||||
|
||||
-- Load base customer to get all feature IDs
|
||||
local baseJson = redis.call("GET", baseKey)
|
||||
local baseCustomer = cjson.decode(baseJson)
|
||||
local allFeatureIds = baseCustomer._featureIds or {}
|
||||
|
||||
-- Helper function: Load a complete feature with rollovers and breakdowns
|
||||
local function loadFeature(featureId)
|
||||
local featureKey = cacheKey .. ":features:" .. featureId
|
||||
local featureHash = redis.call("HGETALL", featureKey)
|
||||
|
||||
if #featureHash == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Parse feature fields
|
||||
local feature = { id = featureId }
|
||||
for i = 1, #featureHash, 2 do
|
||||
local key = featureHash[i]
|
||||
local value = featureHash[i + 1]
|
||||
|
||||
if key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "_breakdown_count" or key == "_rollover_count" then
|
||||
feature[key] = tonumber(value)
|
||||
elseif key == "unlimited" or key == "overage_allowed" then
|
||||
feature[key] = (value == "true")
|
||||
elseif key == "credit_schema" then
|
||||
if value ~= "null" and value ~= "" then
|
||||
feature[key] = cjson.decode(value)
|
||||
else
|
||||
feature[key] = nil
|
||||
end
|
||||
elseif value == "null" then
|
||||
feature[key] = cjson.null
|
||||
else
|
||||
feature[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Load rollovers
|
||||
local rolloverCount = feature._rollover_count or 0
|
||||
feature.rollovers = {}
|
||||
for i = 0, rolloverCount - 1 do
|
||||
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. i
|
||||
local rolloverHash = redis.call("HGETALL", rolloverKey)
|
||||
|
||||
if #rolloverHash > 0 then
|
||||
local rollover = { _index = i, _key = rolloverKey }
|
||||
for j = 1, #rolloverHash, 2 do
|
||||
local key = rolloverHash[j]
|
||||
local value = rolloverHash[j + 1]
|
||||
|
||||
if key == "balance" or key == "expires_at" then
|
||||
rollover[key] = tonumber(value)
|
||||
else
|
||||
rollover[key] = value
|
||||
end
|
||||
end
|
||||
table.insert(feature.rollovers, rollover)
|
||||
end
|
||||
end
|
||||
|
||||
-- Load breakdowns
|
||||
local breakdownCount = feature._breakdown_count or 0
|
||||
feature.breakdowns = {}
|
||||
for i = 0, breakdownCount - 1 do
|
||||
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. i
|
||||
local breakdownHash = redis.call("HGETALL", breakdownKey)
|
||||
|
||||
if #breakdownHash > 0 then
|
||||
local breakdown = { _index = i, _key = breakdownKey }
|
||||
for j = 1, #breakdownHash, 2 do
|
||||
local key = breakdownHash[j]
|
||||
local value = breakdownHash[j + 1]
|
||||
|
||||
if key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" then
|
||||
breakdown[key] = tonumber(value)
|
||||
else
|
||||
breakdown[key] = value
|
||||
end
|
||||
end
|
||||
table.insert(feature.breakdowns, breakdown)
|
||||
end
|
||||
end
|
||||
|
||||
return feature
|
||||
end
|
||||
|
||||
-- Helper function: Calculate credit cost
|
||||
local function getCreditCost(feature, targetFeatureId)
|
||||
if not feature.credit_schema or type(feature.credit_schema) ~= "table" then
|
||||
return 1
|
||||
end
|
||||
|
||||
for _, schemaItem in ipairs(feature.credit_schema) do
|
||||
if schemaItem.feature_id == targetFeatureId then
|
||||
local creditAmount = schemaItem.credit_cost or schemaItem.credit_amount or 1
|
||||
local featureAmount = schemaItem.feature_amount or 1
|
||||
return creditAmount / featureAmount
|
||||
end
|
||||
end
|
||||
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Load all features and categorize
|
||||
local regularFeatures = {}
|
||||
local creditFeatures = {}
|
||||
|
||||
for _, featureId in ipairs(allFeatureIds) do
|
||||
local feature = loadFeature(featureId)
|
||||
|
||||
if feature and not feature.unlimited then
|
||||
local creditCost = getCreditCost(feature, targetFeatureId)
|
||||
|
||||
if featureId == targetFeatureId or creditCost == 1 then
|
||||
table.insert(regularFeatures, { feature = feature, creditCost = 1 })
|
||||
else
|
||||
table.insert(creditFeatures, { feature = feature, creditCost = creditCost })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Combine regular and credit features for deduction order
|
||||
local allFeatures = {}
|
||||
for _, item in ipairs(regularFeatures) do
|
||||
table.insert(allFeatures, item)
|
||||
end
|
||||
for _, item in ipairs(creditFeatures) do
|
||||
table.insert(allFeatures, item)
|
||||
end
|
||||
|
||||
if #allFeatures == 0 then
|
||||
return cjson.encode({
|
||||
success = false,
|
||||
error = "NO_VALID_FEATURES",
|
||||
successCount = 0
|
||||
})
|
||||
end
|
||||
|
||||
-- Separate additions (negative) from deductions (positive)
|
||||
local additions = {}
|
||||
local deductions = {}
|
||||
|
||||
for i, amount in ipairs(amounts) do
|
||||
if amount < 0 then
|
||||
table.insert(additions, { index = i, amount = amount })
|
||||
else
|
||||
table.insert(deductions, { index = i, amount = amount })
|
||||
end
|
||||
end
|
||||
|
||||
-- Reorder: additions first, then deductions
|
||||
local orderedRequests = {}
|
||||
for _, item in ipairs(additions) do
|
||||
table.insert(orderedRequests, item)
|
||||
end
|
||||
for _, item in ipairs(deductions) do
|
||||
table.insert(orderedRequests, item)
|
||||
end
|
||||
|
||||
-- Track accumulated changes per Redis key
|
||||
local keyDeltas = {} -- { [key] = { balance = delta, usage = delta } }
|
||||
|
||||
-- Helper: Add delta to key
|
||||
local function addDelta(key, field, delta)
|
||||
if not keyDeltas[key] then
|
||||
keyDeltas[key] = {}
|
||||
end
|
||||
keyDeltas[key][field] = (keyDeltas[key][field] or 0) + delta
|
||||
end
|
||||
|
||||
-- Helper: Calculate available balance from all features
|
||||
local function calculateTotalAvailable()
|
||||
local available = 0
|
||||
|
||||
for _, featureItem in ipairs(allFeatures) do
|
||||
local feature = featureItem.feature
|
||||
|
||||
-- Rollovers
|
||||
for _, rollover in ipairs(feature.rollovers or {}) do
|
||||
if rollover.balance and rollover.balance > 0 then
|
||||
available = available + rollover.balance
|
||||
end
|
||||
end
|
||||
|
||||
-- Breakdowns
|
||||
for _, breakdown in ipairs(feature.breakdowns or {}) do
|
||||
if breakdown.balance and breakdown.balance > 0 then
|
||||
available = available + breakdown.balance
|
||||
end
|
||||
end
|
||||
|
||||
-- Top-level balance (if no breakdowns)
|
||||
if #(feature.breakdowns or {}) == 0 and feature.balance and feature.balance > 0 then
|
||||
available = feature.balance
|
||||
end
|
||||
|
||||
-- Overage
|
||||
if feature.overage_allowed and feature.usage_limit then
|
||||
local remainingOverage = feature.usage_limit - (feature.usage or 0)
|
||||
if remainingOverage > 0 then
|
||||
available = available + remainingOverage
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return available
|
||||
end
|
||||
|
||||
-- Helper: Apply a single deduction amount across all features
|
||||
local function applyDeduction(amount, featureItem)
|
||||
local feature = featureItem.feature
|
||||
local creditCost = featureItem.creditCost
|
||||
local remaining = amount
|
||||
local featureKey = cacheKey .. ":features:" .. feature.id
|
||||
|
||||
-- Deduct from rollovers
|
||||
for _, rollover in ipairs(feature.rollovers or {}) do
|
||||
if remaining <= 0 then break end
|
||||
|
||||
local rolloverBalance = rollover.balance or 0
|
||||
if rolloverBalance > 0 then
|
||||
local toDeduct = math.min(remaining, rolloverBalance)
|
||||
local actualDeduction = toDeduct * creditCost
|
||||
|
||||
addDelta(rollover._key, "balance", -actualDeduction)
|
||||
|
||||
rollover.balance = rolloverBalance - actualDeduction
|
||||
remaining = remaining - toDeduct
|
||||
end
|
||||
end
|
||||
|
||||
-- Deduct from breakdowns
|
||||
if #(feature.breakdowns or {}) > 0 then
|
||||
for _, breakdown in ipairs(feature.breakdowns) do
|
||||
if remaining <= 0 then break end
|
||||
|
||||
local breakdownBalance = breakdown.balance or 0
|
||||
if breakdownBalance > 0 then
|
||||
local toDeduct = math.min(remaining, breakdownBalance)
|
||||
local actualDeduction = toDeduct * creditCost
|
||||
|
||||
addDelta(breakdown._key, "balance", -actualDeduction)
|
||||
addDelta(breakdown._key, "usage", actualDeduction)
|
||||
addDelta(featureKey, "balance", -actualDeduction)
|
||||
addDelta(featureKey, "usage", actualDeduction)
|
||||
|
||||
breakdown.balance = breakdownBalance - actualDeduction
|
||||
feature.balance = (feature.balance or 0) - actualDeduction
|
||||
feature.usage = (feature.usage or 0) + actualDeduction
|
||||
remaining = remaining - toDeduct
|
||||
end
|
||||
end
|
||||
else
|
||||
-- No breakdowns, deduct from top-level
|
||||
local topLevelBalance = feature.balance or 0
|
||||
if topLevelBalance > 0 then
|
||||
local toDeduct = math.min(remaining, topLevelBalance)
|
||||
local actualDeduction = toDeduct * creditCost
|
||||
|
||||
addDelta(featureKey, "balance", -actualDeduction)
|
||||
addDelta(featureKey, "usage", actualDeduction)
|
||||
|
||||
feature.balance = topLevelBalance - actualDeduction
|
||||
feature.usage = (feature.usage or 0) + actualDeduction
|
||||
remaining = remaining - toDeduct
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle overage
|
||||
if remaining > 0 and feature.overage_allowed and feature.usage_limit then
|
||||
local currentUsage = feature.usage or 0
|
||||
local remainingOverage = feature.usage_limit - currentUsage
|
||||
|
||||
if remainingOverage > 0 then
|
||||
local overageDeduct = math.min(remaining, remainingOverage)
|
||||
local actualOverageDeduction = overageDeduct * creditCost
|
||||
|
||||
addDelta(featureKey, "usage", actualOverageDeduction)
|
||||
|
||||
feature.usage = currentUsage + actualOverageDeduction
|
||||
remaining = remaining - overageDeduct
|
||||
end
|
||||
end
|
||||
|
||||
return remaining == 0
|
||||
end
|
||||
|
||||
-- Process all requests independently
|
||||
local successCount = 0
|
||||
local processedRequests = {} -- Track which original indices succeeded
|
||||
|
||||
for _, request in ipairs(orderedRequests) do
|
||||
local amount = request.amount
|
||||
local originalIndex = request.index
|
||||
local succeeded = false
|
||||
|
||||
-- Additions (negative amounts) always succeed
|
||||
if amount < 0 then
|
||||
-- Apply addition across features (reverse deduction)
|
||||
for _, featureItem in ipairs(allFeatures) do
|
||||
applyDeduction(amount, featureItem)
|
||||
break -- Only apply to first feature for additions
|
||||
end
|
||||
succeeded = true
|
||||
else
|
||||
-- Deductions: check availability
|
||||
local available = calculateTotalAvailable()
|
||||
|
||||
if available >= amount then
|
||||
-- Sufficient balance, apply full deduction
|
||||
local remaining = amount
|
||||
for _, featureItem in ipairs(allFeatures) do
|
||||
if remaining <= 0 then break end
|
||||
if applyDeduction(remaining, featureItem) then
|
||||
remaining = 0
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if remaining == 0 then
|
||||
succeeded = true
|
||||
end
|
||||
elseif overageBehavior == "cap" then
|
||||
-- Cap behavior: deduct what's available (even if 0) and succeed
|
||||
if available > 0 then
|
||||
local remaining = available
|
||||
for _, featureItem in ipairs(allFeatures) do
|
||||
if remaining <= 0 then break end
|
||||
if applyDeduction(remaining, featureItem) then
|
||||
remaining = 0
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
succeeded = true -- Always succeed with cap behavior
|
||||
end
|
||||
-- else: insufficient and reject → don't deduct, don't mark success
|
||||
end
|
||||
|
||||
processedRequests[originalIndex] = succeeded
|
||||
if succeeded then
|
||||
successCount = successCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Execute accumulated changes (ONE HINCRBYFLOAT per key per field)
|
||||
for key, deltas in pairs(keyDeltas) do
|
||||
for field, delta in pairs(deltas) do
|
||||
if delta ~= 0 then
|
||||
redis.call("HINCRBYFLOAT", key, field, delta)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Always return success=true (batch executed), individual requests resolved by successCount
|
||||
return cjson.encode({
|
||||
success = true,
|
||||
successCount = successCount,
|
||||
error = successCount < #amounts and "INSUFFICIENT_BALANCE" or nil
|
||||
})
|
||||
|
||||
494
server/src/_luaScripts/archives/getCustomer.backup.lua
Normal file
494
server/src/_luaScripts/archives/getCustomer.backup.lua
Normal file
@@ -0,0 +1,494 @@
|
||||
-- getCustomer.lua
|
||||
-- Atomically retrieves a customer object from Redis, reconstructing from base JSON and feature HSETs
|
||||
-- Merges master customer features with entity features
|
||||
-- KEYS[1]: cache key (e.g., "org_id:env:customer:customer_id")
|
||||
-- ARGV[1]: org_id (for building entity cache keys)
|
||||
-- ARGV[2]: env (for building entity cache keys)
|
||||
-- ARGV[3]: customer_id (for building entity cache keys)
|
||||
|
||||
-- Helper function to safely convert values to numbers for arithmetic
|
||||
-- Returns the value if it's a number, otherwise returns 0
|
||||
local function toNum(value)
|
||||
return type(value) == "number" and value or 0
|
||||
end
|
||||
|
||||
-- Helper function to merge products array by product ID and normalized status
|
||||
-- Groups products by key (product_id:normalized_status) and merges quantities
|
||||
local function mergeProducts(productsArray)
|
||||
if not productsArray or #productsArray == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
-- Helper function to get product key for grouping
|
||||
local function getProductKey(product)
|
||||
local status = product.status
|
||||
-- Normalize status: "active" or "past_due" -> "active", otherwise use actual status
|
||||
if status == "active" or status == "past_due" then
|
||||
status = "active"
|
||||
end
|
||||
return product.id .. ":" .. status
|
||||
end
|
||||
|
||||
local record = {}
|
||||
|
||||
for _, curr in ipairs(productsArray) do
|
||||
local key = getProductKey(curr)
|
||||
local latest = record[key]
|
||||
|
||||
local currStartedAt = curr.started_at
|
||||
|
||||
-- Start with latest (or current if no latest exists), then override specific fields
|
||||
local mergedProduct = {}
|
||||
if latest then
|
||||
-- Copy all fields from latest first
|
||||
for k, v in pairs(latest) do
|
||||
mergedProduct[k] = v
|
||||
end
|
||||
else
|
||||
-- Copy all fields from current
|
||||
for k, v in pairs(curr) do
|
||||
mergedProduct[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply merge logic for specific fields
|
||||
if latest then
|
||||
-- version: max(latest.version or 1, current.version or 1)
|
||||
local latestVersion = latest.version or 1
|
||||
local currVersion = curr.version or 1
|
||||
mergedProduct.version = math.max(latestVersion, currVersion)
|
||||
|
||||
-- canceled_at: current.canceled_at if exists, else latest.canceled_at, else null
|
||||
if curr.canceled_at and curr.canceled_at ~= cjson.null and curr.canceled_at ~= nil then
|
||||
mergedProduct.canceled_at = curr.canceled_at
|
||||
elseif latest.canceled_at and latest.canceled_at ~= cjson.null and latest.canceled_at ~= nil then
|
||||
mergedProduct.canceled_at = latest.canceled_at
|
||||
else
|
||||
mergedProduct.canceled_at = cjson.null
|
||||
end
|
||||
|
||||
-- started_at: latest.started_at ? min(latest.started_at, current.started_at) : current.started_at
|
||||
if latest.started_at then
|
||||
mergedProduct.started_at = math.min(latest.started_at, currStartedAt)
|
||||
else
|
||||
mergedProduct.started_at = currStartedAt
|
||||
end
|
||||
|
||||
-- quantity: (latest.quantity or 0) + (current.quantity or 0)
|
||||
local latestQuantity = latest.quantity or 0
|
||||
local currQuantity = curr.quantity or 0
|
||||
mergedProduct.quantity = latestQuantity + currQuantity
|
||||
else
|
||||
-- First product in group, ensure defaults
|
||||
mergedProduct.version = curr.version or 1
|
||||
mergedProduct.canceled_at = curr.canceled_at or cjson.null
|
||||
mergedProduct.started_at = currStartedAt
|
||||
mergedProduct.quantity = curr.quantity or 0
|
||||
end
|
||||
|
||||
record[key] = mergedProduct
|
||||
end
|
||||
|
||||
-- Convert record back to array
|
||||
local mergedProducts = {}
|
||||
for _, product in pairs(record) do
|
||||
table.insert(mergedProducts, product)
|
||||
end
|
||||
|
||||
return mergedProducts
|
||||
end
|
||||
|
||||
local cacheKey = KEYS[1]
|
||||
local baseKey = cacheKey
|
||||
local orgId = ARGV[1]
|
||||
local env = ARGV[2]
|
||||
local customerId = ARGV[3]
|
||||
|
||||
-- Get base customer JSON
|
||||
local baseJson = redis.call("GET", baseKey)
|
||||
if not baseJson then
|
||||
return nil
|
||||
end
|
||||
|
||||
local baseCustomer = cjson.decode(baseJson)
|
||||
local featureIds = baseCustomer._featureIds or {}
|
||||
local entityIds = baseCustomer._entityIds or {}
|
||||
|
||||
-- Build features object
|
||||
local features = {}
|
||||
|
||||
for _, featureId in ipairs(featureIds) do
|
||||
local featureKey = cacheKey .. ":features:" .. featureId
|
||||
local featureHash = redis.call("HGETALL", featureKey)
|
||||
|
||||
-- If feature key is missing, return nil (partial eviction detected)
|
||||
if #featureHash == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Convert HGETALL result (flat array) to table
|
||||
local featureData = {}
|
||||
for i = 1, #featureHash, 2 do
|
||||
local key = featureHash[i]
|
||||
local value = featureHash[i + 1]
|
||||
|
||||
-- Check for null first before parsing
|
||||
if value == "null" then
|
||||
featureData[key] = cjson.null
|
||||
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
|
||||
featureData[key] = tonumber(value)
|
||||
elseif key == "unlimited" or key == "overage_allowed" then
|
||||
featureData[key] = (value == "true")
|
||||
elseif key == "credit_schema" then
|
||||
-- Parse credit_schema JSON array
|
||||
if value ~= "" then
|
||||
featureData[key] = cjson.decode(value)
|
||||
else
|
||||
featureData[key] = cjson.null
|
||||
end
|
||||
else
|
||||
featureData[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Get rollover count
|
||||
local rolloverCount = featureData._rollover_count or 0
|
||||
featureData._rollover_count = nil -- Remove from final output
|
||||
|
||||
-- Fetch rollover items
|
||||
local rollovers = {}
|
||||
for i = 0, rolloverCount - 1 do
|
||||
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. 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
|
||||
|
||||
if #rollovers > 0 then
|
||||
featureData.rollovers = rollovers
|
||||
end
|
||||
|
||||
-- Get breakdown count
|
||||
local breakdownCount = featureData._breakdown_count or 0
|
||||
featureData._breakdown_count = nil -- Remove from final output
|
||||
|
||||
-- Fetch breakdown items
|
||||
local breakdown = {}
|
||||
for i = 0, breakdownCount - 1 do
|
||||
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. 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 key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
|
||||
breakdownData[key] = tonumber(value)
|
||||
elseif key == "overage_allowed" then
|
||||
breakdownData[key] = (value == "true")
|
||||
else
|
||||
breakdownData[key] = value
|
||||
end
|
||||
end
|
||||
table.insert(breakdown, breakdownData)
|
||||
end
|
||||
|
||||
if #breakdown > 0 then
|
||||
featureData.breakdown = breakdown
|
||||
end
|
||||
|
||||
features[featureId] = featureData
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- FETCH AND MERGE ENTITY FEATURES
|
||||
-- ============================================================================
|
||||
|
||||
-- Fetch all entity features and aggregate balances
|
||||
local entityFeatureData = {} -- {[entityId][featureId] = featureData}
|
||||
local entityBaseData = {} -- {[entityId] = entityBase} - Store entity base for product access
|
||||
|
||||
for _, entityId in ipairs(entityIds) do
|
||||
local entityCacheKey = "{" .. orgId .. "}:" .. env .. ":customer:" .. customerId .. ":entity:" .. 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 entityFeatureIds = entityBase._featureIds or {}
|
||||
entityFeatureData[entityId] = {}
|
||||
|
||||
for _, featureId in ipairs(entityFeatureIds) do
|
||||
local entityFeatureKey = entityCacheKey .. ":features:" .. featureId
|
||||
local entityFeatureHash = redis.call("HGETALL", entityFeatureKey)
|
||||
|
||||
if #entityFeatureHash > 0 then
|
||||
-- Parse entity feature
|
||||
local entityFeature = {}
|
||||
for i = 1, #entityFeatureHash, 2 do
|
||||
local key = entityFeatureHash[i]
|
||||
local value = entityFeatureHash[i + 1]
|
||||
|
||||
if value == "null" then
|
||||
entityFeature[key] = cjson.null
|
||||
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
|
||||
entityFeature[key] = tonumber(value)
|
||||
elseif key == "unlimited" or key == "overage_allowed" then
|
||||
entityFeature[key] = (value == "true")
|
||||
else
|
||||
entityFeature[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Fetch breakdown items for this entity feature
|
||||
local breakdownCount = entityFeature._breakdown_count or 0
|
||||
entityFeature._breakdown_count = nil
|
||||
entityFeature.breakdowns = {}
|
||||
|
||||
for i = 0, breakdownCount - 1 do
|
||||
local breakdownKey = entityFeatureKey .. ":breakdown:" .. i
|
||||
local breakdownHash = redis.call("HGETALL", breakdownKey)
|
||||
|
||||
if #breakdownHash > 0 then
|
||||
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 key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
|
||||
breakdownData[key] = tonumber(value)
|
||||
elseif key == "overage_allowed" then
|
||||
breakdownData[key] = (value == "true")
|
||||
else
|
||||
breakdownData[key] = value
|
||||
end
|
||||
end
|
||||
table.insert(entityFeature.breakdowns, breakdownData)
|
||||
end
|
||||
end
|
||||
|
||||
-- Fetch rollover items for this entity feature
|
||||
local rolloverCount = entityFeature._rollover_count or 0
|
||||
entityFeature._rollover_count = nil
|
||||
entityFeature.rollovers = {}
|
||||
|
||||
for i = 0, rolloverCount - 1 do
|
||||
local rolloverKey = entityFeatureKey .. ":rollover:" .. i
|
||||
local rolloverHash = redis.call("HGETALL", rolloverKey)
|
||||
|
||||
if #rolloverHash > 0 then
|
||||
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(entityFeature.rollovers, rolloverData)
|
||||
end
|
||||
end
|
||||
|
||||
entityFeatureData[entityId][featureId] = entityFeature
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- MERGE ENTITY PRODUCTS INTO CUSTOMER PRODUCTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Collect all products: start with customer's products, then add all entity products
|
||||
local allProducts = {}
|
||||
if baseCustomer.products then
|
||||
for _, product in ipairs(baseCustomer.products) do
|
||||
table.insert(allProducts, product)
|
||||
end
|
||||
end
|
||||
|
||||
-- Add products from each entity
|
||||
for _, entityId in ipairs(entityIds) do
|
||||
local entityBase = entityBaseData[entityId]
|
||||
if entityBase and entityBase.products then
|
||||
for _, product in ipairs(entityBase.products) do
|
||||
table.insert(allProducts, product)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Merge products by product ID and normalized status
|
||||
baseCustomer.products = mergeProducts(allProducts)
|
||||
|
||||
-- ============================================================================
|
||||
-- MERGE ENTITY BALANCES INTO CUSTOMER FEATURES
|
||||
-- ============================================================================
|
||||
|
||||
for featureId, customerFeature in pairs(features) do
|
||||
-- Skip if unlimited
|
||||
if not customerFeature.unlimited then
|
||||
-- Aggregate entity balances for this feature
|
||||
local entityTotalBalance = 0
|
||||
local entityTotalUsage = 0
|
||||
local entityTotalIncludedUsage = 0
|
||||
local entityTotalUsageLimit = 0
|
||||
|
||||
for entityId, entityFeatures in pairs(entityFeatureData) do
|
||||
local entityFeature = entityFeatures[featureId]
|
||||
if entityFeature then
|
||||
entityTotalBalance = entityTotalBalance + toNum(entityFeature.balance)
|
||||
entityTotalUsage = entityTotalUsage + toNum(entityFeature.usage)
|
||||
entityTotalIncludedUsage = entityTotalIncludedUsage + toNum(entityFeature.included_usage)
|
||||
entityTotalUsageLimit = entityTotalUsageLimit + toNum(entityFeature.usage_limit)
|
||||
end
|
||||
end
|
||||
|
||||
-- Merge top-level balance and usage
|
||||
customerFeature.balance = toNum(customerFeature.balance) + entityTotalBalance
|
||||
customerFeature.usage = toNum(customerFeature.usage) + entityTotalUsage
|
||||
customerFeature.included_usage = toNum(customerFeature.included_usage) + entityTotalIncludedUsage
|
||||
customerFeature.usage_limit = toNum(customerFeature.usage_limit) + entityTotalUsageLimit
|
||||
|
||||
-- Merge breakdown balances and usage
|
||||
if customerFeature.breakdown and #customerFeature.breakdown > 0 then
|
||||
for i, breakdown in ipairs(customerFeature.breakdown) do
|
||||
local entityBreakdownBalance = 0
|
||||
local entityBreakdownUsage = 0
|
||||
local entityBreakdownIncludedUsage = 0
|
||||
local entityBreakdownUsageLimit = 0
|
||||
|
||||
for entityId, entityFeatures in pairs(entityFeatureData) do
|
||||
local entityFeature = entityFeatures[featureId]
|
||||
if entityFeature and entityFeature.breakdowns and entityFeature.breakdowns[i] then
|
||||
entityBreakdownBalance = entityBreakdownBalance + toNum(entityFeature.breakdowns[i].balance)
|
||||
entityBreakdownUsage = entityBreakdownUsage + toNum(entityFeature.breakdowns[i].usage)
|
||||
entityBreakdownIncludedUsage = entityBreakdownIncludedUsage + toNum(entityFeature.breakdowns[i].included_usage)
|
||||
entityBreakdownUsageLimit = entityBreakdownUsageLimit + toNum(entityFeature.breakdowns[i].usage_limit)
|
||||
end
|
||||
end
|
||||
|
||||
breakdown.balance = toNum(breakdown.balance) + entityBreakdownBalance
|
||||
breakdown.usage = toNum(breakdown.usage) + entityBreakdownUsage
|
||||
breakdown.included_usage = toNum(breakdown.included_usage) + entityBreakdownIncludedUsage
|
||||
breakdown.usage_limit = toNum(breakdown.usage_limit) + entityBreakdownUsageLimit
|
||||
end
|
||||
end
|
||||
|
||||
-- Merge rollover balances
|
||||
if customerFeature.rollovers and #customerFeature.rollovers > 0 then
|
||||
for i, rollover in ipairs(customerFeature.rollovers) do
|
||||
local entityRolloverBalance = 0
|
||||
|
||||
for entityId, entityFeatures in pairs(entityFeatureData) do
|
||||
local entityFeature = entityFeatures[featureId]
|
||||
if entityFeature and entityFeature.rollovers and entityFeature.rollovers[i] then
|
||||
entityRolloverBalance = entityRolloverBalance + toNum(entityFeature.rollovers[i].balance)
|
||||
end
|
||||
end
|
||||
|
||||
rollover.balance = toNum(rollover.balance) + entityRolloverBalance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Add entity-only features (features that exist in entities but not in customer)
|
||||
for entityId, entityFeatures in pairs(entityFeatureData) do
|
||||
for featureId, entityFeature in pairs(entityFeatures) do
|
||||
if not features[featureId] then
|
||||
-- This feature doesn't exist in customer, add it
|
||||
-- Initialize with zero balance, then we'll aggregate all entity balances
|
||||
features[featureId] = {
|
||||
id = entityFeature.id,
|
||||
type = entityFeature.type,
|
||||
name = entityFeature.name,
|
||||
interval = entityFeature.interval,
|
||||
interval_count = entityFeature.interval_count,
|
||||
unlimited = entityFeature.unlimited,
|
||||
balance = 0,
|
||||
usage = 0,
|
||||
included_usage = 0,
|
||||
next_reset_at = cjson.null,
|
||||
overage_allowed = entityFeature.overage_allowed,
|
||||
usage_limit = entityFeature.usage_limit,
|
||||
credit_schema = entityFeature.credit_schema
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Now aggregate balances for entity-only features
|
||||
for featureId, customerFeature in pairs(features) do
|
||||
-- Only process if this was an entity-only feature (balance is still 0 from initialization)
|
||||
if customerFeature.balance == 0 and customerFeature.usage == 0 then
|
||||
local entityTotalBalance = 0
|
||||
local entityTotalUsage = 0
|
||||
local entityTotalIncludedUsage = 0
|
||||
local entityTotalUsageLimit = 0
|
||||
local minNextResetAt = nil
|
||||
|
||||
for entityId, entityFeatures in pairs(entityFeatureData) do
|
||||
local entityFeature = entityFeatures[featureId]
|
||||
if entityFeature then
|
||||
entityTotalBalance = entityTotalBalance + toNum(entityFeature.balance)
|
||||
entityTotalUsage = entityTotalUsage + toNum(entityFeature.usage)
|
||||
entityTotalIncludedUsage = entityTotalIncludedUsage + toNum(entityFeature.included_usage)
|
||||
entityTotalUsageLimit = entityTotalUsageLimit + toNum(entityFeature.usage_limit)
|
||||
|
||||
-- Find minimum next_reset_at across all entities
|
||||
if type(entityFeature.next_reset_at) == "number" then
|
||||
if not minNextResetAt or entityFeature.next_reset_at < minNextResetAt then
|
||||
minNextResetAt = entityFeature.next_reset_at
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
customerFeature.balance = entityTotalBalance
|
||||
customerFeature.usage = entityTotalUsage
|
||||
customerFeature.included_usage = entityTotalIncludedUsage
|
||||
customerFeature.usage_limit = entityTotalUsageLimit
|
||||
customerFeature.next_reset_at = minNextResetAt or cjson.null
|
||||
end
|
||||
end
|
||||
|
||||
-- Build final customer object
|
||||
baseCustomer._featureIds = nil -- Remove tracking field
|
||||
baseCustomer._entityIds = nil -- Remove tracking field
|
||||
baseCustomer.features = features
|
||||
|
||||
return cjson.encode(baseCustomer)
|
||||
|
||||
393
server/src/_luaScripts/archives/getEntity.backup.lua
Normal file
393
server/src/_luaScripts/archives/getEntity.backup.lua
Normal file
@@ -0,0 +1,393 @@
|
||||
-- getEntity.lua
|
||||
-- Atomically retrieves an entity object from Redis, reconstructing from base JSON and feature HSETs
|
||||
-- Merges entity features with customer features
|
||||
-- KEYS[1]: cache key (e.g., "{org_id}:env:customer:customer_id:entity:entity_id")
|
||||
-- ARGV[1]: org_id (for building customer cache keys)
|
||||
-- ARGV[2]: env (for building customer cache keys)
|
||||
|
||||
-- Helper function to safely convert values to numbers for arithmetic
|
||||
-- Returns the value if it's a number, otherwise returns 0
|
||||
local function toNum(value)
|
||||
return type(value) == "number" and value or 0
|
||||
end
|
||||
|
||||
-- Helper function to get product key for grouping (product_id:normalized_status)
|
||||
local function getProductKey(product)
|
||||
local status = product.status
|
||||
-- Normalize status: "active" or "past_due" -> "active", otherwise use actual status
|
||||
if status == "active" or status == "past_due" then
|
||||
status = "active"
|
||||
end
|
||||
return product.id .. ":" .. status
|
||||
end
|
||||
|
||||
-- Helper function to merge customer products into entity products
|
||||
-- Adds customer products that don't already exist in entity products (by product key)
|
||||
local function mergeCustomerProductsIntoEntity(entityProducts, customerProducts)
|
||||
if not customerProducts or #customerProducts == 0 then
|
||||
return entityProducts or {}
|
||||
end
|
||||
|
||||
if not entityProducts then
|
||||
entityProducts = {}
|
||||
end
|
||||
|
||||
-- Build a set of existing product keys in entity products
|
||||
local existingKeys = {}
|
||||
for _, product in ipairs(entityProducts) do
|
||||
local key = getProductKey(product)
|
||||
existingKeys[key] = true
|
||||
end
|
||||
|
||||
-- Add customer products that don't exist in entity products
|
||||
local mergedProducts = {}
|
||||
|
||||
-- First, add all entity products
|
||||
for _, product in ipairs(entityProducts) do
|
||||
table.insert(mergedProducts, product)
|
||||
end
|
||||
|
||||
-- Then, add customer products that don't exist
|
||||
for _, customerProduct in ipairs(customerProducts) do
|
||||
local key = getProductKey(customerProduct)
|
||||
if not existingKeys[key] then
|
||||
table.insert(mergedProducts, customerProduct)
|
||||
end
|
||||
end
|
||||
|
||||
return mergedProducts
|
||||
end
|
||||
|
||||
local cacheKey = KEYS[1]
|
||||
local baseKey = cacheKey
|
||||
local orgId = ARGV[1]
|
||||
local env = ARGV[2]
|
||||
|
||||
-- Get base entity JSON
|
||||
local baseJson = redis.call("GET", baseKey)
|
||||
if not baseJson then
|
||||
return nil
|
||||
end
|
||||
|
||||
local baseEntity = cjson.decode(baseJson)
|
||||
local entityFeatureIds = baseEntity._featureIds or {}
|
||||
|
||||
-- ============================================================================
|
||||
-- FETCH ENTITY FEATURES
|
||||
-- ============================================================================
|
||||
local entityFeatures = {}
|
||||
|
||||
for _, featureId in ipairs(entityFeatureIds) do
|
||||
local featureKey = cacheKey .. ":features:" .. featureId
|
||||
local featureHash = redis.call("HGETALL", featureKey)
|
||||
|
||||
-- If feature key is missing, return nil (partial eviction detected)
|
||||
if #featureHash == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Convert HGETALL result (flat array) to table
|
||||
local featureData = {}
|
||||
for i = 1, #featureHash, 2 do
|
||||
local key = featureHash[i]
|
||||
local value = featureHash[i + 1]
|
||||
|
||||
-- Check for null first before parsing
|
||||
if value == "null" then
|
||||
featureData[key] = cjson.null
|
||||
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
|
||||
featureData[key] = tonumber(value)
|
||||
elseif key == "unlimited" or key == "overage_allowed" then
|
||||
featureData[key] = (value == "true")
|
||||
elseif key == "credit_schema" then
|
||||
-- Parse credit_schema JSON array
|
||||
if value ~= "" then
|
||||
featureData[key] = cjson.decode(value)
|
||||
else
|
||||
featureData[key] = cjson.null
|
||||
end
|
||||
else
|
||||
featureData[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Get rollover count
|
||||
local rolloverCount = featureData._rollover_count or 0
|
||||
featureData._rollover_count = nil -- Remove from final output
|
||||
|
||||
-- Fetch rollover items
|
||||
local rollovers = {}
|
||||
for i = 0, rolloverCount - 1 do
|
||||
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. 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
|
||||
|
||||
if #rollovers > 0 then
|
||||
featureData.rollovers = rollovers
|
||||
end
|
||||
|
||||
-- Get breakdown count
|
||||
local breakdownCount = featureData._breakdown_count or 0
|
||||
featureData._breakdown_count = nil -- Remove from final output
|
||||
|
||||
-- Fetch breakdown items
|
||||
local breakdown = {}
|
||||
for i = 0, breakdownCount - 1 do
|
||||
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. 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 key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
|
||||
breakdownData[key] = tonumber(value)
|
||||
elseif key == "overage_allowed" then
|
||||
breakdownData[key] = (value == "true")
|
||||
else
|
||||
breakdownData[key] = value
|
||||
end
|
||||
end
|
||||
table.insert(breakdown, breakdownData)
|
||||
end
|
||||
|
||||
if #breakdown > 0 then
|
||||
featureData.breakdown = breakdown
|
||||
end
|
||||
|
||||
entityFeatures[featureId] = featureData
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- FETCH CUSTOMER MASTER FEATURES (no entity aggregation)
|
||||
-- ============================================================================
|
||||
local customerFeatures = {}
|
||||
local customerBase = nil -- Store customer base for product access
|
||||
local customerId = baseEntity.customer_id
|
||||
|
||||
if customerId then
|
||||
local customerCacheKey = "{" .. orgId .. "}:" .. env .. ":customer:" .. customerId
|
||||
local customerBaseJson = redis.call("GET", customerCacheKey)
|
||||
|
||||
if customerBaseJson then
|
||||
customerBase = cjson.decode(customerBaseJson)
|
||||
local customerFeatureIds = customerBase._featureIds or {}
|
||||
|
||||
for _, featureId in ipairs(customerFeatureIds) do
|
||||
local customerFeatureKey = customerCacheKey .. ":features:" .. featureId
|
||||
local customerFeatureHash = redis.call("HGETALL", customerFeatureKey)
|
||||
|
||||
if #customerFeatureHash > 0 then
|
||||
-- Parse customer feature
|
||||
local customerFeature = {}
|
||||
for i = 1, #customerFeatureHash, 2 do
|
||||
local key = customerFeatureHash[i]
|
||||
local value = customerFeatureHash[i + 1]
|
||||
|
||||
if value == "null" then
|
||||
customerFeature[key] = cjson.null
|
||||
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
|
||||
customerFeature[key] = tonumber(value)
|
||||
elseif key == "unlimited" or key == "overage_allowed" then
|
||||
customerFeature[key] = (value == "true")
|
||||
elseif key == "credit_schema" then
|
||||
if value ~= "" then
|
||||
customerFeature[key] = cjson.decode(value)
|
||||
else
|
||||
customerFeature[key] = cjson.null
|
||||
end
|
||||
else
|
||||
customerFeature[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Fetch rollover items
|
||||
local rolloverCount = customerFeature._rollover_count or 0
|
||||
customerFeature._rollover_count = nil
|
||||
local rollovers = {}
|
||||
|
||||
for i = 0, rolloverCount - 1 do
|
||||
local rolloverKey = customerFeatureKey .. ":rollover:" .. i
|
||||
local rolloverHash = redis.call("HGETALL", rolloverKey)
|
||||
|
||||
if #rolloverHash > 0 then
|
||||
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
|
||||
end
|
||||
|
||||
if #rollovers > 0 then
|
||||
customerFeature.rollovers = rollovers
|
||||
end
|
||||
|
||||
-- Fetch breakdown items
|
||||
local breakdownCount = customerFeature._breakdown_count or 0
|
||||
customerFeature._breakdown_count = nil
|
||||
local breakdown = {}
|
||||
|
||||
for i = 0, breakdownCount - 1 do
|
||||
local breakdownKey = customerFeatureKey .. ":breakdown:" .. i
|
||||
local breakdownHash = redis.call("HGETALL", breakdownKey)
|
||||
|
||||
if #breakdownHash > 0 then
|
||||
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 key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
|
||||
breakdownData[key] = tonumber(value)
|
||||
elseif key == "overage_allowed" then
|
||||
breakdownData[key] = (value == "true")
|
||||
else
|
||||
breakdownData[key] = value
|
||||
end
|
||||
end
|
||||
table.insert(breakdown, breakdownData)
|
||||
end
|
||||
end
|
||||
|
||||
if #breakdown > 0 then
|
||||
customerFeature.breakdown = breakdown
|
||||
end
|
||||
|
||||
customerFeatures[featureId] = customerFeature
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- MERGE CUSTOMER AND ENTITY FEATURES
|
||||
-- ============================================================================
|
||||
local mergedFeatures = {}
|
||||
|
||||
-- First, add all customer features (inherited)
|
||||
for featureId, customerFeature in pairs(customerFeatures) do
|
||||
mergedFeatures[featureId] = customerFeature
|
||||
end
|
||||
|
||||
-- Then, merge or add entity features
|
||||
for featureId, entityFeature in pairs(entityFeatures) do
|
||||
local customerFeature = customerFeatures[featureId]
|
||||
|
||||
if customerFeature then
|
||||
-- Both customer and entity have this feature - merge balances
|
||||
if not entityFeature.unlimited and not customerFeature.unlimited then
|
||||
entityFeature.balance = toNum(entityFeature.balance) + toNum(customerFeature.balance)
|
||||
entityFeature.usage = toNum(entityFeature.usage) + toNum(customerFeature.usage)
|
||||
entityFeature.included_usage = toNum(entityFeature.included_usage) + toNum(customerFeature.included_usage)
|
||||
entityFeature.usage_limit = toNum(entityFeature.usage_limit) + toNum(customerFeature.usage_limit)
|
||||
|
||||
-- Use minimum next_reset_at (earliest reset time)
|
||||
if type(entityFeature.next_reset_at) == "number" and type(customerFeature.next_reset_at) == "number" then
|
||||
if customerFeature.next_reset_at < entityFeature.next_reset_at then
|
||||
entityFeature.next_reset_at = customerFeature.next_reset_at
|
||||
end
|
||||
elseif type(customerFeature.next_reset_at) == "number" then
|
||||
entityFeature.next_reset_at = customerFeature.next_reset_at
|
||||
end
|
||||
|
||||
-- Merge breakdown balances
|
||||
if entityFeature.breakdown and customerFeature.breakdown then
|
||||
for i, entityBreakdown in ipairs(entityFeature.breakdown) do
|
||||
local customerBreakdown = customerFeature.breakdown[i]
|
||||
if customerBreakdown then
|
||||
entityBreakdown.balance = toNum(entityBreakdown.balance) + toNum(customerBreakdown.balance)
|
||||
entityBreakdown.usage = toNum(entityBreakdown.usage) + toNum(customerBreakdown.usage)
|
||||
entityBreakdown.included_usage = toNum(entityBreakdown.included_usage) + toNum(customerBreakdown.included_usage)
|
||||
entityBreakdown.usage_limit = toNum(entityBreakdown.usage_limit) + toNum(customerBreakdown.usage_limit)
|
||||
|
||||
-- Use minimum next_reset_at for breakdown
|
||||
if type(entityBreakdown.next_reset_at) == "number" and type(customerBreakdown.next_reset_at) == "number" then
|
||||
if customerBreakdown.next_reset_at < entityBreakdown.next_reset_at then
|
||||
entityBreakdown.next_reset_at = customerBreakdown.next_reset_at
|
||||
end
|
||||
elseif type(customerBreakdown.next_reset_at) == "number" then
|
||||
entityBreakdown.next_reset_at = customerBreakdown.next_reset_at
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Merge rollover balances
|
||||
if entityFeature.rollovers and customerFeature.rollovers then
|
||||
for i, entityRollover in ipairs(entityFeature.rollovers) do
|
||||
local customerRollover = customerFeature.rollovers[i]
|
||||
if customerRollover then
|
||||
entityRollover.balance = toNum(entityRollover.balance) + toNum(customerRollover.balance)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
mergedFeatures[featureId] = entityFeature
|
||||
else
|
||||
-- Only entity has this feature - use entity's feature
|
||||
mergedFeatures[featureId] = entityFeature
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- MERGE CUSTOMER PRODUCTS INTO ENTITY PRODUCTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Get entity products (start with entity's own products)
|
||||
local entityProducts = baseEntity.products or {}
|
||||
|
||||
-- Get customer products if customer base exists
|
||||
local customerProducts = nil
|
||||
if customerBase and customerBase.products then
|
||||
customerProducts = customerBase.products
|
||||
end
|
||||
|
||||
-- Merge customer products into entity products (only add if not exists)
|
||||
baseEntity.products = mergeCustomerProductsIntoEntity(entityProducts, customerProducts)
|
||||
|
||||
-- Build final entity object
|
||||
baseEntity._featureIds = nil -- Remove tracking field
|
||||
baseEntity.features = mergedFeatures
|
||||
|
||||
return cjson.encode(baseEntity)
|
||||
|
||||
21
server/src/_luaScripts/cacheConfig.ts
Normal file
21
server/src/_luaScripts/cacheConfig.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiVersion } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Cache configuration constants
|
||||
* These values are injected into Lua scripts at load time for optimal performance
|
||||
*/
|
||||
|
||||
/**
|
||||
* Customer cache version (applies to both customer and entity caches)
|
||||
* Increment this (change to a newer ApiVersion) when customer/entity cache structure changes
|
||||
* Old caches will be orphaned and expire after CACHE_TTL_SECONDS
|
||||
*
|
||||
* Format: customer:{version}:{customerId} or customer:{version}:{customerId}:entity:{entityId}
|
||||
*/
|
||||
export const CACHE_CUSTOMER_VERSION = ApiVersion.V1_2;
|
||||
|
||||
/**
|
||||
* Cache time-to-live in seconds (7 days)
|
||||
* All customer and entity caches will expire after this duration
|
||||
*/
|
||||
export const CACHE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days = 604800 seconds
|
||||
37
server/src/_luaScripts/cacheKeyUtils.lua
Normal file
37
server/src/_luaScripts/cacheKeyUtils.lua
Normal file
@@ -0,0 +1,37 @@
|
||||
-- cacheKeyUtils.lua
|
||||
-- Shared cache key builders for customer and entity caches
|
||||
-- Version placeholder {CUSTOMER_VERSION} is replaced at load time
|
||||
|
||||
-- Cache TTL constant (replaced at load time)
|
||||
local CACHE_TTL_SECONDS = {TTL_SECONDS}
|
||||
|
||||
-- Build customer cache key with version
|
||||
-- Returns: {orgId}:env:customer:{version}:customerId
|
||||
local function buildCustomerCacheKey(orgId, env, customerId)
|
||||
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId
|
||||
end
|
||||
|
||||
-- Build entity cache key with version
|
||||
-- Returns: {orgId}:env:customer:{version}:customerId:entity:entityId
|
||||
local function buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId .. ":entity:" .. entityId
|
||||
end
|
||||
|
||||
-- Build balance cache key
|
||||
-- Returns: {cacheKey}:balances:{featureId}
|
||||
local function buildBalanceCacheKey(cacheKey, featureId)
|
||||
return cacheKey .. ":balances:" .. featureId
|
||||
end
|
||||
|
||||
-- Build rollover cache key
|
||||
-- Returns: {cacheKey}:balances:{featureId}:rollover:{index}
|
||||
local function buildRolloverCacheKey(cacheKey, featureId, index)
|
||||
return cacheKey .. ":balances:" .. featureId .. ":rollover:" .. index
|
||||
end
|
||||
|
||||
-- Build breakdown cache key
|
||||
-- Returns: {cacheKey}:balances:{featureId}:breakdown:{index}
|
||||
local function buildBreakdownCacheKey(cacheKey, featureId, index)
|
||||
return cacheKey .. ":balances:" .. featureId .. ":breakdown:" .. index
|
||||
end
|
||||
|
||||
46
server/src/_luaScripts/cusLuaScripts/checkCacheExists.lua
Normal file
46
server/src/_luaScripts/cusLuaScripts/checkCacheExists.lua
Normal file
@@ -0,0 +1,46 @@
|
||||
-- checkCacheExists.lua
|
||||
-- Shared function to check if complete customer/entity cache exists
|
||||
-- Validates base key + all features + all breakdowns + all rollovers
|
||||
|
||||
local function checkCacheExists(cacheKey)
|
||||
local baseJson = redis.call("GET", cacheKey)
|
||||
if not baseJson then
|
||||
return false
|
||||
end
|
||||
|
||||
local base = cjson.decode(baseJson)
|
||||
local featureIds = base._featureIds or {}
|
||||
|
||||
for _, featureId in ipairs(featureIds) do
|
||||
local featureKey = cacheKey .. ":features:" .. featureId
|
||||
local featureHash = redis.call("HGETALL", featureKey)
|
||||
if #featureHash == 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Parse feature to get counts
|
||||
local featureData = {}
|
||||
for i = 1, #featureHash, 2 do
|
||||
featureData[featureHash[i]] = featureHash[i + 1]
|
||||
end
|
||||
|
||||
-- Check all breakdowns exist
|
||||
local breakdownCount = tonumber(featureData._breakdown_count or 0)
|
||||
for i = 0, breakdownCount - 1 do
|
||||
if redis.call("EXISTS", featureKey .. ":breakdown:" .. i) == 0 then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Check all rollovers exist
|
||||
local rolloverCount = tonumber(featureData._rollover_count or 0)
|
||||
for i = 0, rolloverCount - 1 do
|
||||
if redis.call("EXISTS", featureKey .. ":rollover:" .. i) == 0 then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
46
server/src/_luaScripts/cusLuaScripts/deleteCustomer.lua
Normal file
46
server/src/_luaScripts/cusLuaScripts/deleteCustomer.lua
Normal file
@@ -0,0 +1,46 @@
|
||||
-- deleteCustomer.lua
|
||||
-- Atomically deletes a customer and all its associated entity caches
|
||||
-- ARGV[1]: org_id
|
||||
-- ARGV[2]: env
|
||||
-- ARGV[3]: customer_id
|
||||
-- Returns: number of keys deleted
|
||||
|
||||
local orgId = ARGV[1]
|
||||
local env = ARGV[2]
|
||||
local customerId = ARGV[3]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
local basePattern = cacheKey .. "*"
|
||||
local keysToDelete = {}
|
||||
|
||||
-- Scan for all keys matching the pattern
|
||||
-- This includes the customer base key and ALL entity keys under it
|
||||
local cursor = "0"
|
||||
repeat
|
||||
local result = redis.call("SCAN", cursor, "MATCH", basePattern, "COUNT", 100)
|
||||
cursor = result[1]
|
||||
local keys = result[2]
|
||||
|
||||
for _, key in ipairs(keys) do
|
||||
table.insert(keysToDelete, key)
|
||||
end
|
||||
until cursor == "0"
|
||||
|
||||
-- Delete all keys in one atomic operation
|
||||
local deletedCount = 0
|
||||
if #keysToDelete > 0 then
|
||||
-- Redis DEL can handle multiple keys, but has argument limits
|
||||
-- So we batch delete in chunks of 1000
|
||||
local chunkSize = 1000
|
||||
for i = 1, #keysToDelete, chunkSize do
|
||||
local chunk = {}
|
||||
for j = i, math.min(i + chunkSize - 1, #keysToDelete) do
|
||||
table.insert(chunk, keysToDelete[j])
|
||||
end
|
||||
deletedCount = deletedCount + redis.call("DEL", unpack(chunk))
|
||||
end
|
||||
end
|
||||
|
||||
return deletedCount
|
||||
|
||||
21
server/src/_luaScripts/cusLuaScripts/getCustomer.lua
Normal file
21
server/src/_luaScripts/cusLuaScripts/getCustomer.lua
Normal file
@@ -0,0 +1,21 @@
|
||||
-- getCustomer.lua
|
||||
-- Atomically retrieves a customer object from Redis, reconstructing from base JSON and balance HSETs
|
||||
-- Merges master customer balances with entity balances (unless skipEntityMerge is true)
|
||||
-- ARGV[1]: org_id
|
||||
-- ARGV[2]: env
|
||||
-- ARGV[3]: customer_id
|
||||
-- ARGV[4]: skipEntityMerge (optional, "true" to skip merging with entities)
|
||||
|
||||
local orgId = ARGV[1]
|
||||
local env = ARGV[2]
|
||||
local customerId = ARGV[3]
|
||||
local skipEntityMerge = ARGV[4] == "true"
|
||||
|
||||
-- Get customer object using shared utility function
|
||||
local customer = getCustomerObject(orgId, env, customerId, skipEntityMerge)
|
||||
|
||||
if not customer then
|
||||
return nil
|
||||
end
|
||||
|
||||
return cjson.encode(customer)
|
||||
75
server/src/_luaScripts/cusLuaScripts/setCustomer.lua
Normal file
75
server/src/_luaScripts/cusLuaScripts/setCustomer.lua
Normal file
@@ -0,0 +1,75 @@
|
||||
-- setCustomer.lua
|
||||
-- Atomically stores a customer object with base data as JSON and balances/breakdowns as HSETs
|
||||
-- Uses new ApiCustomer schema with balances (replacing features) and subscriptions (replacing products)
|
||||
-- ARGV[1]: serialized customer data JSON string
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
|
||||
local customerDataJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
|
||||
-- Check if complete cache already exists
|
||||
if checkCacheExists(cacheKey) then
|
||||
return "CACHE_EXISTS"
|
||||
end
|
||||
|
||||
-- Decode the customer data
|
||||
local customerData = cjson.decode(customerDataJson)
|
||||
|
||||
-- Extract balance IDs (feature_ids) for tracking
|
||||
local balanceFeatureIds = {}
|
||||
if customerData.balances then
|
||||
for featureId, _ in pairs(customerData.balances) do
|
||||
table.insert(balanceFeatureIds, featureId)
|
||||
end
|
||||
end
|
||||
|
||||
-- Extract entity IDs from entities array
|
||||
local entityIds = {}
|
||||
if customerData.entities then
|
||||
for _, entity in ipairs(customerData.entities) do
|
||||
if entity.id then
|
||||
table.insert(entityIds, entity.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Store balance feature IDs and entity IDs in the base data for retrieval
|
||||
customerData._balanceFeatureIds = balanceFeatureIds
|
||||
customerData._entityIds = entityIds
|
||||
|
||||
-- Build base customer object (everything except balances)
|
||||
local baseCustomer = {
|
||||
id = customerData.id,
|
||||
autumn_id = customerData.autumn_id,
|
||||
created_at = customerData.created_at,
|
||||
name = customerData.name,
|
||||
email = customerData.email,
|
||||
fingerprint = customerData.fingerprint,
|
||||
stripe_id = customerData.stripe_id,
|
||||
env = customerData.env,
|
||||
metadata = customerData.metadata,
|
||||
subscriptions = customerData.subscriptions,
|
||||
invoices = customerData.invoices,
|
||||
legacyData = customerData.legacyData,
|
||||
entities = customerData.entities,
|
||||
_balanceFeatureIds = balanceFeatureIds,
|
||||
_entityIds = entityIds
|
||||
}
|
||||
|
||||
-- Store base customer as JSON with TTL
|
||||
local baseKey = cacheKey
|
||||
redis.call("SET", baseKey, cjson.encode(baseCustomer))
|
||||
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
|
||||
|
||||
-- Store balances using shared utility function
|
||||
storeBalances(cacheKey, customerData.balances)
|
||||
|
||||
return "OK"
|
||||
|
||||
46
server/src/_luaScripts/cusLuaScripts/setCustomerDetails.lua
Normal file
46
server/src/_luaScripts/cusLuaScripts/setCustomerDetails.lua
Normal file
@@ -0,0 +1,46 @@
|
||||
-- setCustomerDetails.lua
|
||||
-- Updates only the customer detail fields (name, email, etc.) in the customer cache
|
||||
-- ARGV[1]: serialized customer details JSON string (object with name, email, etc.)
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
|
||||
local detailsJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
local baseKey = cacheKey
|
||||
|
||||
-- Get base customer JSON
|
||||
local baseJson = redis.call("GET", baseKey)
|
||||
if not baseJson then
|
||||
return "NOT_FOUND" -- Customer doesn't exist, return early
|
||||
end
|
||||
|
||||
-- Decode the base customer and new details
|
||||
local baseCustomer = cjson.decode(baseJson)
|
||||
local details = cjson.decode(detailsJson)
|
||||
|
||||
-- Update detail fields if they are provided
|
||||
if details.name ~= nil then
|
||||
baseCustomer.name = details.name
|
||||
end
|
||||
if details.email ~= nil then
|
||||
baseCustomer.email = details.email
|
||||
end
|
||||
if details.fingerprint ~= nil then
|
||||
baseCustomer.fingerprint = details.fingerprint
|
||||
end
|
||||
if details.metadata ~= nil then
|
||||
baseCustomer.metadata = details.metadata
|
||||
end
|
||||
|
||||
-- Store updated base customer as JSON and extend TTL
|
||||
redis.call("SET", baseKey, cjson.encode(baseCustomer))
|
||||
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
|
||||
|
||||
return "OK"
|
||||
|
||||
35
server/src/_luaScripts/cusLuaScripts/setCustomerProducts.lua
Normal file
35
server/src/_luaScripts/cusLuaScripts/setCustomerProducts.lua
Normal file
@@ -0,0 +1,35 @@
|
||||
-- setCustomerProducts.lua
|
||||
-- Updates only the products array in the customer cache
|
||||
-- ARGV[1]: serialized products array JSON string
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
|
||||
local productsJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
local baseKey = cacheKey
|
||||
|
||||
-- Get base customer JSON
|
||||
local baseJson = redis.call("GET", baseKey)
|
||||
if not baseJson then
|
||||
return "OK" -- Customer doesn't exist, return early
|
||||
end
|
||||
|
||||
-- Decode the base customer and products
|
||||
local baseCustomer = cjson.decode(baseJson)
|
||||
local products = cjson.decode(productsJson)
|
||||
|
||||
-- Update only the products array
|
||||
baseCustomer.products = products
|
||||
|
||||
-- Store updated base customer as JSON and extend TTL
|
||||
redis.call("SET", baseKey, cjson.encode(baseCustomer))
|
||||
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
|
||||
|
||||
return "OK"
|
||||
|
||||
1180
server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua
Normal file
1180
server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
-- checkEntityCacheExists.lua
|
||||
-- Shared function to check if complete entity cache exists
|
||||
-- Validates base key + all features + all breakdowns + all rollovers
|
||||
|
||||
local function checkCacheExists(cacheKey)
|
||||
local baseJson = redis.call("GET", cacheKey)
|
||||
if not baseJson then
|
||||
return false
|
||||
end
|
||||
|
||||
local base = cjson.decode(baseJson)
|
||||
local featureIds = base._featureIds or {}
|
||||
|
||||
for _, featureId in ipairs(featureIds) do
|
||||
local featureKey = cacheKey .. ":features:" .. featureId
|
||||
local featureHash = redis.call("HGETALL", featureKey)
|
||||
if #featureHash == 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Parse feature to get counts
|
||||
local featureData = {}
|
||||
for i = 1, #featureHash, 2 do
|
||||
featureData[featureHash[i]] = featureHash[i + 1]
|
||||
end
|
||||
|
||||
-- Check all breakdowns exist
|
||||
local breakdownCount = tonumber(featureData._breakdown_count or 0)
|
||||
for i = 0, breakdownCount - 1 do
|
||||
if redis.call("EXISTS", featureKey .. ":breakdown:" .. i) == 0 then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Check all rollovers exist
|
||||
local rolloverCount = tonumber(featureData._rollover_count or 0)
|
||||
for i = 0, rolloverCount - 1 do
|
||||
if redis.call("EXISTS", featureKey .. ":rollover:" .. i) == 0 then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
24
server/src/_luaScripts/entityLuaScripts/getEntity.lua
Normal file
24
server/src/_luaScripts/entityLuaScripts/getEntity.lua
Normal file
@@ -0,0 +1,24 @@
|
||||
-- getEntity.lua
|
||||
-- Atomically retrieves an entity object from Redis, reconstructing from base JSON and balance HSETs
|
||||
-- Merges entity balances with customer balances (unless skipCustomerMerge is true)
|
||||
-- ARGV[1]: org_id
|
||||
-- ARGV[2]: env
|
||||
-- ARGV[3]: customerId
|
||||
-- ARGV[4]: entityId
|
||||
-- ARGV[5]: skipCustomerMerge (optional, "true" to skip merging with customer)
|
||||
|
||||
local orgId = ARGV[1]
|
||||
local env = ARGV[2]
|
||||
local customerId = ARGV[3]
|
||||
local entityId = ARGV[4]
|
||||
local skipCustomerMerge = ARGV[5] == "true"
|
||||
|
||||
-- Get entity object using shared utility function
|
||||
local entity = getEntityObject(orgId, env, customerId, entityId, skipCustomerMerge)
|
||||
|
||||
if not entity then
|
||||
return nil
|
||||
end
|
||||
|
||||
return cjson.encode(entity)
|
||||
|
||||
134
server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua
Normal file
134
server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua
Normal file
@@ -0,0 +1,134 @@
|
||||
-- setEntitiesBatch.lua
|
||||
-- Atomically stores multiple entity objects in a single call
|
||||
-- ARGV[1]: JSON array of entity data objects: [{entityId: "...", entityData: {...}}, ...]
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
|
||||
local entitiesJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
|
||||
-- Decode the entities array
|
||||
local entities = cjson.decode(entitiesJson)
|
||||
|
||||
-- Helper function to convert values to strings, handling cjson.null
|
||||
local function toString(value)
|
||||
if value == cjson.null or value == nil then
|
||||
return "null"
|
||||
end
|
||||
return tostring(value)
|
||||
end
|
||||
|
||||
-- Process each entity
|
||||
for _, entityWrapper in ipairs(entities) do
|
||||
local entityId = entityWrapper.entityId
|
||||
local entityData = entityWrapper.entityData
|
||||
|
||||
-- Build versioned cache key for this entity using shared utility
|
||||
local customerId = entityData.customer_id
|
||||
local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
|
||||
-- Extract feature IDs for tracking
|
||||
local featureIds = {}
|
||||
if entityData.features then
|
||||
for featureId, _ in pairs(entityData.features) do
|
||||
table.insert(featureIds, featureId)
|
||||
end
|
||||
end
|
||||
|
||||
-- Build base entity object (everything except features)
|
||||
local baseEntity = {
|
||||
id = entityData.id,
|
||||
autumn_id = entityData.autumn_id,
|
||||
name = entityData.name,
|
||||
customer_id = entityData.customer_id,
|
||||
created_at = entityData.created_at,
|
||||
env = entityData.env,
|
||||
products = entityData.products,
|
||||
_featureIds = featureIds
|
||||
}
|
||||
|
||||
-- Store base entity as JSON with TTL
|
||||
redis.call("SET", cacheKey, cjson.encode(baseEntity))
|
||||
redis.call("EXPIRE", cacheKey, CACHE_TTL_SECONDS)
|
||||
|
||||
-- Store each feature as HSET
|
||||
if entityData.features then
|
||||
for featureId, featureData in pairs(entityData.features) do
|
||||
local featureKey = cacheKey .. ":features:" .. featureId
|
||||
|
||||
-- Store breakdown count for reconstruction
|
||||
local breakdownCount = 0
|
||||
if featureData.breakdown then
|
||||
breakdownCount = #featureData.breakdown
|
||||
end
|
||||
|
||||
-- Store rollover count for reconstruction
|
||||
local rolloverCount = 0
|
||||
if featureData.rollovers then
|
||||
rolloverCount = #featureData.rollovers
|
||||
end
|
||||
|
||||
-- Serialize credit_schema as JSON string
|
||||
local creditSchemaJson = "null"
|
||||
if featureData.credit_schema and #featureData.credit_schema > 0 then
|
||||
creditSchemaJson = cjson.encode(featureData.credit_schema)
|
||||
end
|
||||
|
||||
-- Store all top-level feature fields in a single HSET call with TTL
|
||||
redis.call("HSET", featureKey,
|
||||
"id", toString(featureData.id),
|
||||
"type", toString(featureData.type),
|
||||
"name", toString(featureData.name),
|
||||
"interval", toString(featureData.interval),
|
||||
"interval_count", toString(featureData.interval_count),
|
||||
"unlimited", toString(featureData.unlimited),
|
||||
"balance", toString(featureData.balance),
|
||||
"usage", toString(featureData.usage),
|
||||
"included_usage", toString(featureData.included_usage),
|
||||
"next_reset_at", toString(featureData.next_reset_at),
|
||||
"overage_allowed", toString(featureData.overage_allowed),
|
||||
"usage_limit", toString(featureData.usage_limit),
|
||||
"credit_schema", creditSchemaJson,
|
||||
"_breakdown_count", toString(breakdownCount),
|
||||
"_rollover_count", toString(rolloverCount)
|
||||
)
|
||||
redis.call("EXPIRE", featureKey, CACHE_TTL_SECONDS)
|
||||
|
||||
-- Store each rollover item as separate HSET with TTL (single call per rollover)
|
||||
if featureData.rollovers then
|
||||
for index, rolloverItem in ipairs(featureData.rollovers) do
|
||||
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. (index - 1)
|
||||
|
||||
redis.call("HSET", rolloverKey,
|
||||
"balance", toString(rolloverItem.balance),
|
||||
"expires_at", toString(rolloverItem.expires_at)
|
||||
)
|
||||
redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS)
|
||||
end
|
||||
end
|
||||
|
||||
-- Store each breakdown item as separate HSET with TTL (single call per breakdown)
|
||||
if featureData.breakdown then
|
||||
for index, breakdownItem in ipairs(featureData.breakdown) do
|
||||
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. (index - 1)
|
||||
|
||||
redis.call("HSET", breakdownKey,
|
||||
"interval", toString(breakdownItem.interval),
|
||||
"interval_count", toString(breakdownItem.interval_count),
|
||||
"balance", toString(breakdownItem.balance),
|
||||
"usage", toString(breakdownItem.usage),
|
||||
"included_usage", toString(breakdownItem.included_usage),
|
||||
"next_reset_at", toString(breakdownItem.next_reset_at),
|
||||
"usage_limit", toString(breakdownItem.usage_limit),
|
||||
"overage_allowed", toString(breakdownItem.overage_allowed)
|
||||
)
|
||||
redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return "OK"
|
||||
|
||||
59
server/src/_luaScripts/entityLuaScripts/setEntity.lua
Normal file
59
server/src/_luaScripts/entityLuaScripts/setEntity.lua
Normal file
@@ -0,0 +1,59 @@
|
||||
-- setEntity.lua
|
||||
-- Atomically stores an entity object with base data as JSON and balances/breakdowns as HSETs
|
||||
-- Uses new ApiEntity schema with balances (replacing features) and subscriptions (replacing products)
|
||||
-- ARGV[1]: serialized entity data JSON string
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
-- ARGV[5]: entity_id
|
||||
|
||||
local entityDataJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
local entityId = ARGV[5]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
|
||||
-- Check if complete cache already exists
|
||||
if checkCacheExists(cacheKey) then
|
||||
return "CACHE_EXISTS"
|
||||
end
|
||||
|
||||
-- Decode the entity data
|
||||
local entityData = cjson.decode(entityDataJson)
|
||||
|
||||
-- Extract balance IDs (feature_ids) for tracking
|
||||
local balanceFeatureIds = {}
|
||||
if entityData.balances then
|
||||
for featureId, _ in pairs(entityData.balances) do
|
||||
table.insert(balanceFeatureIds, featureId)
|
||||
end
|
||||
end
|
||||
|
||||
-- Store balance feature IDs in the base data for retrieval
|
||||
entityData._balanceFeatureIds = balanceFeatureIds
|
||||
|
||||
-- Build base entity object (everything except balances)
|
||||
local baseEntity = {
|
||||
id = entityData.id,
|
||||
autumn_id = entityData.autumn_id,
|
||||
name = entityData.name,
|
||||
customer_id = entityData.customer_id,
|
||||
created_at = entityData.created_at,
|
||||
env = entityData.env,
|
||||
subscriptions = entityData.subscriptions,
|
||||
_balanceFeatureIds = balanceFeatureIds
|
||||
}
|
||||
|
||||
-- Store base entity as JSON with TTL
|
||||
local baseKey = cacheKey
|
||||
redis.call("SET", baseKey, cjson.encode(baseEntity))
|
||||
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
|
||||
|
||||
-- Store balances using shared utility function
|
||||
storeBalances(cacheKey, entityData.balances)
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- setEntityProducts.lua
|
||||
-- Updates only the products array in the entity cache
|
||||
-- ARGV[1]: serialized products array JSON string
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
-- ARGV[5]: entity_id
|
||||
|
||||
local productsJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
local entityId = ARGV[5]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
local baseKey = cacheKey
|
||||
|
||||
-- Get base entity JSON
|
||||
local baseJson = redis.call("GET", baseKey)
|
||||
if not baseJson then
|
||||
return "OK" -- Entity doesn't exist, return early
|
||||
end
|
||||
|
||||
-- Decode the base entity and products
|
||||
local baseEntity = cjson.decode(baseJson)
|
||||
local products = cjson.decode(productsJson)
|
||||
|
||||
-- Update only the products array
|
||||
baseEntity.products = products
|
||||
|
||||
-- Store updated base entity as JSON and extend TTL
|
||||
redis.call("SET", baseKey, cjson.encode(baseEntity))
|
||||
redis.call("EXPIRE", baseKey, CACHE_TTL_SECONDS)
|
||||
|
||||
return "OK"
|
||||
|
||||
146
server/src/_luaScripts/luaScripts.ts
Normal file
146
server/src/_luaScripts/luaScripts.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { CACHE_CUSTOMER_VERSION, CACHE_TTL_SECONDS } from "./cacheConfig.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// ============================================================================
|
||||
// SHARED LUA FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
// Load cache key utilities and inject version constants
|
||||
const CACHE_KEY_UTILS_RAW = readFileSync(
|
||||
join(__dirname, "luaUtils/cacheKeyUtils.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Inject cache version and TTL constants into cache key utils
|
||||
const CACHE_KEY_UTILS = CACHE_KEY_UTILS_RAW.replace(
|
||||
/{CUSTOMER_VERSION}/g,
|
||||
CACHE_CUSTOMER_VERSION,
|
||||
).replace("{TTL_SECONDS}", CACHE_TTL_SECONDS.toString());
|
||||
|
||||
// Load balance storage utilities
|
||||
const CACHE_BALANCE_UTILS = readFileSync(
|
||||
join(__dirname, "luaUtils/storeBalances.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Load shared balance loading function (used by customer, entity, and deduction scripts)
|
||||
const LOAD_BALANCES = readFileSync(
|
||||
join(__dirname, "luaUtils/loadBalances.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Load shared subscription utilities (used by customer and entity scripts)
|
||||
const SUBSCRIPTION_UTILS = readFileSync(
|
||||
join(__dirname, "luaUtils/apiSubscriptionUtils.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Load shared customer/entity getter utilities
|
||||
const GET_CUSTOMER_ENTITY_UTILS = readFileSync(
|
||||
join(__dirname, "luaUtils/getCustomerEntityUtils.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOMER SCRIPTS
|
||||
// ============================================================================
|
||||
|
||||
// Load shared validation function
|
||||
const CHECK_CACHE_EXISTS = readFileSync(
|
||||
join(__dirname, "cusLuaScripts/checkCacheExists.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Prepend cache key utils, loadBalances, subscription utils, and getter utils to GET_CUSTOMER_SCRIPT
|
||||
const getCustomerScript = readFileSync(
|
||||
join(__dirname, "cusLuaScripts/getCustomer.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const GET_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${LOAD_BALANCES}\n${SUBSCRIPTION_UTILS}\n${GET_CUSTOMER_ENTITY_UTILS}\n${getCustomerScript}`;
|
||||
|
||||
// Prepend cache key utils and validation function to SET_CUSTOMER_SCRIPT
|
||||
const setCustomerScript = readFileSync(
|
||||
join(__dirname, "cusLuaScripts/setCustomer.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const SET_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${CHECK_CACHE_EXISTS}\n${setCustomerScript}`;
|
||||
|
||||
// Prepend cache key utils to SET_CUSTOMER_PRODUCTS_SCRIPT
|
||||
const setCustomerProductsScript = readFileSync(
|
||||
join(__dirname, "cusLuaScripts/setCustomerProducts.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const SET_CUSTOMER_PRODUCTS_SCRIPT = `${CACHE_KEY_UTILS}\n${setCustomerProductsScript}`;
|
||||
|
||||
// Prepend cache key utils to SET_CUSTOMER_DETAILS_SCRIPT
|
||||
const setCustomerDetailsScript = readFileSync(
|
||||
join(__dirname, "cusLuaScripts/setCustomerDetails.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const SET_CUSTOMER_DETAILS_SCRIPT = `${CACHE_KEY_UTILS}\n${setCustomerDetailsScript}`;
|
||||
|
||||
// Prepend cache key utils to DELETE_CUSTOMER_SCRIPT
|
||||
const deleteCustomerScript = readFileSync(
|
||||
join(__dirname, "cusLuaScripts/deleteCustomer.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const DELETE_CUSTOMER_SCRIPT = `${CACHE_KEY_UTILS}\n${deleteCustomerScript}`;
|
||||
|
||||
// ============================================================================
|
||||
// ENTITY SCRIPTS
|
||||
// ============================================================================
|
||||
|
||||
// Load shared validation function
|
||||
const CHECK_ENTITY_CACHE_EXISTS = readFileSync(
|
||||
join(__dirname, "entityLuaScripts/checkEntityCacheExists.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Prepend cache key utils, loadBalances, subscription utils, and getter utils to GET_ENTITY_SCRIPT
|
||||
const getEntityScript = readFileSync(
|
||||
join(__dirname, "entityLuaScripts/getEntity.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const GET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${LOAD_BALANCES}\n${SUBSCRIPTION_UTILS}\n${GET_CUSTOMER_ENTITY_UTILS}\n${getEntityScript}`;
|
||||
|
||||
// Prepend cache key utils and validation function to SET_ENTITY_SCRIPT
|
||||
const setEntityScript = readFileSync(
|
||||
join(__dirname, "entityLuaScripts/setEntity.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const SET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${CHECK_ENTITY_CACHE_EXISTS}\n${setEntityScript}`;
|
||||
|
||||
// Prepend cache key utils to SET_ENTITIES_BATCH_SCRIPT
|
||||
const setEntitiesBatchScript = readFileSync(
|
||||
join(__dirname, "entityLuaScripts/setEntitiesBatch.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const SET_ENTITIES_BATCH_SCRIPT = `${CACHE_KEY_UTILS}\n${setEntitiesBatchScript}`;
|
||||
|
||||
// Prepend cache key utils to SET_ENTITY_PRODUCTS_SCRIPT
|
||||
const setEntityProductsScript = readFileSync(
|
||||
join(__dirname, "entityLuaScripts/setEntityProducts.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
export const SET_ENTITY_PRODUCTS_SCRIPT = `${CACHE_KEY_UTILS}\n${setEntityProductsScript}`;
|
||||
|
||||
// ============================================================================
|
||||
// DEDUCTION SCRIPTS
|
||||
// ============================================================================
|
||||
|
||||
// Load batchDeduction script
|
||||
const batchDeduction = readFileSync(
|
||||
join(__dirname, "deductionLuaScripts/batchDeduction.lua"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
export function getBatchDeductionScript(): string {
|
||||
return `${CACHE_KEY_UTILS}\n${LOAD_BALANCES}\n${SUBSCRIPTION_UTILS}\n${GET_CUSTOMER_ENTITY_UTILS}\n${batchDeduction}`;
|
||||
}
|
||||
|
||||
export const BATCH_DEDUCTION_SCRIPT = getBatchDeductionScript();
|
||||
134
server/src/_luaScripts/luaUtils/apiSubscriptionUtils.lua
Normal file
134
server/src/_luaScripts/luaUtils/apiSubscriptionUtils.lua
Normal file
@@ -0,0 +1,134 @@
|
||||
-- apiSubscriptionUtils.lua
|
||||
-- Shared utility functions for subscription merging and manipulation
|
||||
|
||||
-- Helper function to get subscription key for grouping (plan_id:normalized_status)
|
||||
-- Normalizes status: "active" or past_due=true -> "active", otherwise uses actual status
|
||||
local function getSubscriptionKey(subscription)
|
||||
local status = subscription.status
|
||||
-- Normalize status: "active" or past_due=true -> "active", otherwise use actual status
|
||||
if status == "active" or (subscription.past_due == true) then
|
||||
status = "active"
|
||||
end
|
||||
return subscription.plan_id .. ":" .. status
|
||||
end
|
||||
|
||||
-- Helper function to merge subscriptions array by plan ID and normalized status
|
||||
-- Groups subscriptions by key (plan_id:normalized_status) and merges quantities
|
||||
-- Used by getCustomer.lua to merge customer + entity subscriptions
|
||||
-- Parameters: subscriptionsArray - array of subscriptions to merge
|
||||
-- Returns: array of merged subscriptions
|
||||
local function mergeSubscriptions(subscriptionsArray)
|
||||
if not subscriptionsArray or #subscriptionsArray == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
local record = {}
|
||||
|
||||
for _, curr in ipairs(subscriptionsArray) do
|
||||
local key = getSubscriptionKey(curr)
|
||||
local latest = record[key]
|
||||
|
||||
local currStartedAt = curr.started_at
|
||||
|
||||
-- Start with latest (or current if no latest exists), then override specific fields
|
||||
local mergedSubscription = {}
|
||||
if latest then
|
||||
-- Copy all fields from latest first
|
||||
for k, v in pairs(latest) do
|
||||
mergedSubscription[k] = v
|
||||
end
|
||||
else
|
||||
-- Copy all fields from current
|
||||
for k, v in pairs(curr) do
|
||||
mergedSubscription[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply merge logic for specific fields
|
||||
if latest then
|
||||
-- canceled_at: current.canceled_at if exists, else latest.canceled_at, else null
|
||||
if curr.canceled_at and curr.canceled_at ~= cjson.null and curr.canceled_at ~= nil then
|
||||
mergedSubscription.canceled_at = curr.canceled_at
|
||||
elseif latest.canceled_at and latest.canceled_at ~= cjson.null and latest.canceled_at ~= nil then
|
||||
mergedSubscription.canceled_at = latest.canceled_at
|
||||
else
|
||||
mergedSubscription.canceled_at = cjson.null
|
||||
end
|
||||
|
||||
-- started_at: latest.started_at ? min(latest.started_at, current.started_at) : current.started_at
|
||||
if latest.started_at then
|
||||
mergedSubscription.started_at = math.min(latest.started_at, currStartedAt)
|
||||
else
|
||||
mergedSubscription.started_at = currStartedAt
|
||||
end
|
||||
|
||||
-- quantity: (latest.quantity or 0) + (current.quantity or 0)
|
||||
local latestQuantity = latest.quantity or 0
|
||||
local currQuantity = curr.quantity or 0
|
||||
mergedSubscription.quantity = latestQuantity + currQuantity
|
||||
|
||||
-- past_due: true if either is true
|
||||
mergedSubscription.past_due = (latest.past_due == true) or (curr.past_due == true)
|
||||
else
|
||||
-- First subscription in group, ensure defaults
|
||||
mergedSubscription.canceled_at = curr.canceled_at or cjson.null
|
||||
mergedSubscription.started_at = currStartedAt
|
||||
mergedSubscription.quantity = curr.quantity or 0
|
||||
mergedSubscription.past_due = curr.past_due or false
|
||||
end
|
||||
|
||||
record[key] = mergedSubscription
|
||||
end
|
||||
|
||||
-- Convert record back to array
|
||||
local mergedSubscriptions = {}
|
||||
for _, subscription in pairs(record) do
|
||||
table.insert(mergedSubscriptions, subscription)
|
||||
end
|
||||
|
||||
return mergedSubscriptions
|
||||
end
|
||||
|
||||
-- Helper function to merge customer subscriptions into entity subscriptions
|
||||
-- Adds customer subscriptions that don't already exist in entity subscriptions (by subscription key)
|
||||
-- Does NOT merge quantities - only adds missing subscriptions
|
||||
-- Used by getEntity.lua to add customer subscriptions to entity subscriptions
|
||||
-- Parameters:
|
||||
-- entitySubscriptions - array of entity subscriptions (base)
|
||||
-- customerSubscriptions - array of customer subscriptions to add
|
||||
-- Returns: array of merged subscriptions (entity subscriptions + customer subscriptions that don't exist)
|
||||
local function mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions)
|
||||
if not customerSubscriptions or #customerSubscriptions == 0 then
|
||||
return entitySubscriptions or {}
|
||||
end
|
||||
|
||||
if not entitySubscriptions then
|
||||
entitySubscriptions = {}
|
||||
end
|
||||
|
||||
-- Build a set of existing subscription keys in entity subscriptions
|
||||
local existingKeys = {}
|
||||
for _, subscription in ipairs(entitySubscriptions) do
|
||||
local key = getSubscriptionKey(subscription)
|
||||
existingKeys[key] = true
|
||||
end
|
||||
|
||||
-- Add customer subscriptions that don't exist in entity subscriptions
|
||||
local mergedSubscriptions = {}
|
||||
|
||||
-- First, add all entity subscriptions
|
||||
for _, subscription in ipairs(entitySubscriptions) do
|
||||
table.insert(mergedSubscriptions, subscription)
|
||||
end
|
||||
|
||||
-- Then, add customer subscriptions that don't exist
|
||||
for _, customerSubscription in ipairs(customerSubscriptions) do
|
||||
local key = getSubscriptionKey(customerSubscription)
|
||||
if not existingKeys[key] then
|
||||
table.insert(mergedSubscriptions, customerSubscription)
|
||||
end
|
||||
end
|
||||
|
||||
return mergedSubscriptions
|
||||
end
|
||||
|
||||
37
server/src/_luaScripts/luaUtils/cacheKeyUtils.lua
Normal file
37
server/src/_luaScripts/luaUtils/cacheKeyUtils.lua
Normal file
@@ -0,0 +1,37 @@
|
||||
-- cacheKeyUtils.lua
|
||||
-- Shared cache key builders for customer and entity caches
|
||||
-- Version placeholder {CUSTOMER_VERSION} is replaced at load time
|
||||
|
||||
-- Cache TTL constant (replaced at load time)
|
||||
local CACHE_TTL_SECONDS = {TTL_SECONDS}
|
||||
|
||||
-- Build customer cache key with version
|
||||
-- Returns: {orgId}:env:customer:{version}:customerId
|
||||
local function buildCustomerCacheKey(orgId, env, customerId)
|
||||
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId
|
||||
end
|
||||
|
||||
-- Build entity cache key with version
|
||||
-- Returns: {orgId}:env:customer:{version}:customerId:entity:entityId
|
||||
local function buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
return "{" .. orgId .. "}:" .. env .. ":customer:{CUSTOMER_VERSION}:" .. customerId .. ":entity:" .. entityId
|
||||
end
|
||||
|
||||
-- Build balance cache key
|
||||
-- Returns: {cacheKey}:balances:{featureId}
|
||||
local function buildBalanceCacheKey(cacheKey, featureId)
|
||||
return cacheKey .. ":balances:" .. featureId
|
||||
end
|
||||
|
||||
-- Build rollover cache key
|
||||
-- Returns: {cacheKey}:balances:{featureId}:rollover:{index}
|
||||
local function buildRolloverCacheKey(cacheKey, featureId, index)
|
||||
return cacheKey .. ":balances:" .. featureId .. ":rollover:" .. index
|
||||
end
|
||||
|
||||
-- Build breakdown cache key
|
||||
-- Returns: {cacheKey}:balances:{featureId}:breakdown:{index}
|
||||
local function buildBreakdownCacheKey(cacheKey, featureId, index)
|
||||
return cacheKey .. ":balances:" .. featureId .. ":breakdown:" .. index
|
||||
end
|
||||
|
||||
159
server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua
Normal file
159
server/src/_luaScripts/luaUtils/getCustomerEntityUtils.lua
Normal file
@@ -0,0 +1,159 @@
|
||||
-- ============================================================================
|
||||
-- GET CUSTOMER/ENTITY UTILITY FUNCTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Get customer object with merged balances and subscriptions
|
||||
-- Parameters:
|
||||
-- orgId: Organization ID
|
||||
-- env: Environment
|
||||
-- customerId: Customer ID
|
||||
-- skipEntityMerge: If true, only load customer's own balances (no entity merging)
|
||||
-- Returns: customer object table (not JSON encoded), or nil if not found
|
||||
local function getCustomerObject(orgId, env, customerId, skipEntityMerge)
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
|
||||
-- Load balances based on merge mode
|
||||
-- If skipEntityMerge is true, only load customer's own balances (no entity merging)
|
||||
-- If skipEntityMerge is false, load merged balances (customer + entities)
|
||||
local balances
|
||||
if skipEntityMerge then
|
||||
-- Load only customer's own balances without entity merging
|
||||
balances = loadBalances(cacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
|
||||
else
|
||||
-- Load merged balances (customer + entities)
|
||||
balances = loadBalances(cacheKey, orgId, env, customerId)
|
||||
end
|
||||
|
||||
if not balances then
|
||||
return nil -- Customer not in cache or partial eviction detected
|
||||
end
|
||||
|
||||
-- Get base customer JSON for subscriptions and metadata
|
||||
local baseJson = redis.call("GET", cacheKey)
|
||||
if not baseJson then
|
||||
return nil
|
||||
end
|
||||
|
||||
local baseCustomer = cjson.decode(baseJson)
|
||||
local entityIds = baseCustomer._entityIds or {}
|
||||
|
||||
-- ============================================================================
|
||||
-- MERGE ENTITY SUBSCRIPTIONS INTO CUSTOMER SUBSCRIPTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Build entity base data map for subscription access
|
||||
local entityBaseData = {}
|
||||
for _, entityId in ipairs(entityIds) do
|
||||
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
local entityBaseJson = redis.call("GET", entityCacheKey)
|
||||
|
||||
if entityBaseJson then
|
||||
entityBaseData[entityId] = cjson.decode(entityBaseJson)
|
||||
end
|
||||
end
|
||||
|
||||
-- Collect all subscriptions: start with customer's subscriptions, then add all entity subscriptions
|
||||
local allSubscriptions = {}
|
||||
if baseCustomer.subscriptions then
|
||||
for _, subscription in ipairs(baseCustomer.subscriptions) do
|
||||
table.insert(allSubscriptions, subscription)
|
||||
end
|
||||
end
|
||||
|
||||
-- Add subscriptions from each entity
|
||||
for _, entityId in ipairs(entityIds) do
|
||||
local entityBase = entityBaseData[entityId]
|
||||
if entityBase and entityBase.subscriptions then
|
||||
for _, subscription in ipairs(entityBase.subscriptions) do
|
||||
table.insert(allSubscriptions, subscription)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Merge subscriptions by plan ID and normalized status
|
||||
baseCustomer.subscriptions = mergeSubscriptions(allSubscriptions)
|
||||
|
||||
-- Build final customer object
|
||||
baseCustomer._balanceFeatureIds = nil -- Remove tracking field
|
||||
baseCustomer._entityIds = nil -- Remove tracking field
|
||||
baseCustomer.balances = balances
|
||||
|
||||
return baseCustomer
|
||||
end
|
||||
|
||||
-- Get entity object with merged balances and subscriptions
|
||||
-- Parameters:
|
||||
-- orgId: Organization ID
|
||||
-- env: Environment
|
||||
-- customerId: Customer ID
|
||||
-- entityId: Entity ID
|
||||
-- skipCustomerMerge: If true, only load entity's own balances (no customer merging)
|
||||
-- Returns: entity object table (not JSON encoded), or nil if not found
|
||||
local function getEntityObject(orgId, env, customerId, entityId, skipCustomerMerge)
|
||||
-- Build versioned entity cache key using shared utility
|
||||
local entityCacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
|
||||
-- Get base entity JSON
|
||||
local baseJson = redis.call("GET", entityCacheKey)
|
||||
if not baseJson then
|
||||
return nil
|
||||
end
|
||||
|
||||
local baseEntity = cjson.decode(baseJson)
|
||||
|
||||
-- Build customer cache key for balance loading
|
||||
local customerCacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
|
||||
-- ============================================================================
|
||||
-- LOAD BALANCES USING loadBalances
|
||||
-- ============================================================================
|
||||
local mergedBalances
|
||||
|
||||
if skipCustomerMerge then
|
||||
-- Load only entity's own balances (no customer merging)
|
||||
-- We'll use loadBalances with "__CUSTOMER_ONLY__" mode on the entity cache key
|
||||
-- This is a bit of a hack but works with the current structure
|
||||
mergedBalances = loadBalances(entityCacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
|
||||
else
|
||||
-- Load entity-level merged balances (entity + customer)
|
||||
-- loadBalances handles this when entityId is provided
|
||||
mergedBalances = loadBalances(customerCacheKey, orgId, env, customerId, entityId)
|
||||
end
|
||||
|
||||
-- If balances loading failed (partial eviction), return nil
|
||||
if not mergedBalances then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- MERGE CUSTOMER SUBSCRIPTIONS INTO ENTITY SUBSCRIPTIONS
|
||||
-- Skip if skipCustomerMerge is true
|
||||
-- ============================================================================
|
||||
|
||||
-- Get entity subscriptions (start with entity's own subscriptions)
|
||||
local entitySubscriptions = baseEntity.subscriptions or {}
|
||||
|
||||
if not skipCustomerMerge then
|
||||
-- Get customer subscriptions
|
||||
local customerSubscriptions = nil
|
||||
local customerBaseJson = redis.call("GET", customerCacheKey)
|
||||
if customerBaseJson then
|
||||
local customerBase = cjson.decode(customerBaseJson)
|
||||
customerSubscriptions = customerBase.subscriptions
|
||||
end
|
||||
|
||||
-- Merge customer subscriptions into entity subscriptions (only add if not exists)
|
||||
baseEntity.subscriptions = mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions)
|
||||
else
|
||||
-- No merging - just use entity's own subscriptions
|
||||
baseEntity.subscriptions = entitySubscriptions
|
||||
end
|
||||
|
||||
-- Build final entity object
|
||||
baseEntity._balanceFeatureIds = nil -- Remove tracking field
|
||||
baseEntity.balances = mergedBalances
|
||||
|
||||
return baseEntity
|
||||
end
|
||||
|
||||
677
server/src/_luaScripts/luaUtils/loadBalances.lua
Normal file
677
server/src/_luaScripts/luaUtils/loadBalances.lua
Normal file
@@ -0,0 +1,677 @@
|
||||
-- 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 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 reset.interval, not by index
|
||||
-- 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 sourceInterval = sourceBreakdown.reset and sourceBreakdown.reset.interval
|
||||
local foundMatch = false
|
||||
|
||||
-- Try to find matching breakdown by reset.interval
|
||||
if targetBalance.breakdown then
|
||||
for _, targetBreakdown in ipairs(targetBalance.breakdown) do
|
||||
local targetInterval = targetBreakdown.reset and targetBreakdown.reset.interval
|
||||
if sourceInterval and targetInterval and sourceInterval == targetInterval 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 targetBalance.rollovers and sourceBalance.rollovers 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
|
||||
|
||||
-- ============================================================================
|
||||
-- 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 = {},
|
||||
rollovers = {}
|
||||
}
|
||||
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
|
||||
|
||||
-- Return merged balances
|
||||
return balances
|
||||
end
|
||||
107
server/src/_luaScripts/luaUtils/storeBalances.lua
Normal file
107
server/src/_luaScripts/luaUtils/storeBalances.lua
Normal file
@@ -0,0 +1,107 @@
|
||||
-- cacheBalanceUtils.lua
|
||||
-- Shared utility functions for storing balances to Redis cache
|
||||
-- Used by setCustomer.lua and setEntity.lua (after migration)
|
||||
|
||||
-- Helper function to convert values to strings, handling cjson.null
|
||||
local function toString(value)
|
||||
if value == cjson.null or value == nil then
|
||||
return "null"
|
||||
end
|
||||
return tostring(value)
|
||||
end
|
||||
|
||||
-- Helper function to serialize reset object as JSON
|
||||
local function serializeReset(reset)
|
||||
if reset == nil or reset == cjson.null then
|
||||
return "null"
|
||||
end
|
||||
return cjson.encode(reset)
|
||||
end
|
||||
|
||||
-- Store balances to Redis cache
|
||||
-- Parameters:
|
||||
-- cacheKey: Base cache key (e.g., customer or entity cache key)
|
||||
-- balances: Table containing balance data (record of featureId -> balanceData)
|
||||
-- Returns: nothing (void function)
|
||||
local function storeBalances(cacheKey, balances)
|
||||
if not balances then
|
||||
return
|
||||
end
|
||||
|
||||
for featureId, balanceData in pairs(balances) do
|
||||
local balanceKey = buildBalanceCacheKey(cacheKey, featureId)
|
||||
|
||||
-- Store breakdown count for reconstruction
|
||||
local breakdownCount = 0
|
||||
if balanceData.breakdown then
|
||||
breakdownCount = #balanceData.breakdown
|
||||
end
|
||||
|
||||
-- Store rollover count for reconstruction
|
||||
local rolloverCount = 0
|
||||
if balanceData.rollovers then
|
||||
rolloverCount = #balanceData.rollovers
|
||||
end
|
||||
|
||||
-- Serialize feature object as JSON string (optional field)
|
||||
local featureJson = "null"
|
||||
if balanceData.feature then
|
||||
featureJson = cjson.encode(balanceData.feature)
|
||||
end
|
||||
|
||||
-- Serialize reset object as JSON string (optional field)
|
||||
local resetJson = serializeReset(balanceData.reset)
|
||||
|
||||
-- Store all top-level balance fields in a single HSET call with TTL
|
||||
redis.call("HSET", balanceKey,
|
||||
"feature_id", toString(balanceData.feature_id),
|
||||
"feature", featureJson,
|
||||
"unlimited", toString(balanceData.unlimited),
|
||||
"granted_balance", toString(balanceData.granted_balance),
|
||||
"purchased_balance", toString(balanceData.purchased_balance),
|
||||
"current_balance", toString(balanceData.current_balance),
|
||||
"usage", toString(balanceData.usage),
|
||||
"max_purchase", toString(balanceData.max_purchase),
|
||||
"overage_allowed", toString(balanceData.overage_allowed),
|
||||
"reset", resetJson,
|
||||
"_breakdown_count", toString(breakdownCount),
|
||||
"_rollover_count", toString(rolloverCount)
|
||||
)
|
||||
redis.call("EXPIRE", balanceKey, CACHE_TTL_SECONDS)
|
||||
|
||||
-- Store each rollover item as separate HSET with TTL (single call per rollover)
|
||||
if balanceData.rollovers then
|
||||
for index, rolloverItem in ipairs(balanceData.rollovers) do
|
||||
local rolloverKey = buildRolloverCacheKey(cacheKey, featureId, index - 1)
|
||||
|
||||
redis.call("HSET", rolloverKey,
|
||||
"balance", toString(rolloverItem.balance),
|
||||
"expires_at", toString(rolloverItem.expires_at)
|
||||
)
|
||||
redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS)
|
||||
end
|
||||
end
|
||||
|
||||
-- Store each breakdown item as separate HSET with TTL (single call per breakdown)
|
||||
if balanceData.breakdown then
|
||||
for index, breakdownItem in ipairs(balanceData.breakdown) do
|
||||
local breakdownKey = buildBreakdownCacheKey(cacheKey, featureId, index - 1)
|
||||
|
||||
-- Serialize breakdown reset object as JSON
|
||||
local breakdownResetJson = serializeReset(breakdownItem.reset)
|
||||
|
||||
redis.call("HSET", breakdownKey,
|
||||
"granted_balance", toString(breakdownItem.granted_balance),
|
||||
"purchased_balance", toString(breakdownItem.purchased_balance),
|
||||
"current_balance", toString(breakdownItem.current_balance),
|
||||
"usage", toString(breakdownItem.usage),
|
||||
"max_purchase", toString(breakdownItem.max_purchase),
|
||||
"overage_allowed", toString(breakdownItem.overage_allowed),
|
||||
"reset", breakdownResetJson
|
||||
)
|
||||
redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user