feat: deduct from redis new api schema

This commit is contained in:
John Yeo
2025-11-11 16:01:14 +00:00
669 changed files with 40353 additions and 13730 deletions

View 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
})

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

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

View 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

View 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

View 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

View 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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -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

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

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

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

View File

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

View 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();

View 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

View 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

View 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

View 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

View 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