365 lines
12 KiB
Lua
365 lines
12 KiB
Lua
-- ============================================================================
|
|
-- RUN DEDUCTION ON CONTEXT
|
|
-- Shared deduction core for operating against an initialized in-memory context.
|
|
-- ============================================================================
|
|
|
|
--[[
|
|
round_to_precision(num, decimals)
|
|
|
|
Rounds a number to avoid floating point drift in remaining amounts.
|
|
]]
|
|
local function round_to_precision(num, decimals)
|
|
local mult = 10 ^ (decimals or 10)
|
|
return math.floor(num * mult + 0.5) / mult
|
|
end
|
|
|
|
--[[
|
|
process_deduction_pass(params)
|
|
|
|
Runs one main-balance deduction pass over all customer_entitlement deductions.
|
|
|
|
Returns:
|
|
{
|
|
updates = table,
|
|
remaining_amount = number,
|
|
}
|
|
]]
|
|
local function process_deduction_pass(params)
|
|
local context = params.context
|
|
local customer_entitlement_deductions =
|
|
params.customer_entitlement_deductions or {}
|
|
local target_entity_id = params.target_entity_id
|
|
local spend_limit_by_feature_id = params.spend_limit_by_feature_id
|
|
local usage_based_cus_ent_ids_by_feature_id = params.usage_based_cus_ent_ids_by_feature_id
|
|
local alter_granted_balance = params.alter_granted_balance or false
|
|
local overage_behavior_is_allow = params.overage_behavior_is_allow or false
|
|
local pass_number = params.pass_number
|
|
local skip_if_not_usage_allowed = params.skip_if_not_usage_allowed
|
|
local updates = params.updates or {}
|
|
local remaining_amount = params.remaining_amount or 0
|
|
local pass_name = "PASS" .. pass_number
|
|
local logger = context.logger
|
|
|
|
logger.log("=== %s START ===", pass_name)
|
|
|
|
for _, ent_obj in ipairs(customer_entitlement_deductions) do
|
|
if remaining_amount == 0 then
|
|
break
|
|
end
|
|
|
|
local ent_id = ent_obj.customer_entitlement_id
|
|
local credit_cost = ent_obj.credit_cost
|
|
local ent_feature_id = ent_obj.feature_id
|
|
if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then
|
|
credit_cost = 1
|
|
end
|
|
|
|
local available_overage = nil
|
|
if pass_number == 2
|
|
and remaining_amount > 0
|
|
and not overage_behavior_is_allow
|
|
and not is_nil(ent_feature_id)
|
|
then
|
|
local spend_limit = nil
|
|
if not is_nil(spend_limit_by_feature_id) then
|
|
spend_limit = spend_limit_by_feature_id[ent_feature_id]
|
|
end
|
|
|
|
local usage_based_cus_ent_ids = nil
|
|
if not is_nil(usage_based_cus_ent_ids_by_feature_id) then
|
|
usage_based_cus_ent_ids = usage_based_cus_ent_ids_by_feature_id[ent_feature_id]
|
|
end
|
|
|
|
available_overage = get_available_overage_from_spend_limit({
|
|
context = context,
|
|
spend_limit = spend_limit,
|
|
usage_based_cus_ent_ids = usage_based_cus_ent_ids,
|
|
target_entity_id = target_entity_id,
|
|
})
|
|
end
|
|
|
|
local usage_allowed = ent_obj.usage_allowed
|
|
if usage_allowed == cjson.null then
|
|
usage_allowed = false
|
|
end
|
|
usage_allowed = usage_allowed or overage_behavior_is_allow
|
|
|
|
local should_process = not skip_if_not_usage_allowed or usage_allowed
|
|
local skip_reason = "usage_allowed=false"
|
|
if not context.customer_entitlements[ent_id] then
|
|
should_process = false
|
|
skip_reason = "not in context"
|
|
end
|
|
|
|
-- Usage-window gate, mirroring the spend-limit overage gate above: cap
|
|
-- this ent's deductible amount by the remaining window headroom (metered
|
|
-- limits cap every ent in tracked units; balance limits cap ents of the
|
|
-- capped feature, converted via THIS ent's credit_cost). A fully blocked
|
|
-- ent is skipped rather than breaking the loop -- a balance-dim cap only
|
|
-- binds its own feature's pools, so other ents may be unconstrained.
|
|
local ent_amount = remaining_amount
|
|
if should_process and remaining_amount > 0 then
|
|
local available_from_usage_windows = get_available_from_usage_windows({
|
|
context = context,
|
|
ent_feature_id = ent_feature_id,
|
|
credit_cost = credit_cost,
|
|
})
|
|
if not is_nil(available_from_usage_windows)
|
|
and available_from_usage_windows < ent_amount then
|
|
ent_amount = available_from_usage_windows
|
|
end
|
|
if ent_amount == 0 then
|
|
should_process = false
|
|
skip_reason = "usage window headroom exhausted"
|
|
end
|
|
end
|
|
|
|
if not should_process then
|
|
logger.log("%s skipping %s - %s", pass_name, ent_id, skip_reason)
|
|
else
|
|
local deducted = deduct_from_main_balance({
|
|
context = context,
|
|
ent_id = ent_id,
|
|
target_entity_id = target_entity_id,
|
|
amount = ent_amount,
|
|
credit_cost = credit_cost,
|
|
pass_number = pass_number,
|
|
available_overage = available_overage,
|
|
min_balance = ent_obj.min_balance,
|
|
max_balance = ent_obj.max_balance,
|
|
alter_granted_balance = alter_granted_balance,
|
|
overage_behavior_is_allow = overage_behavior_is_allow,
|
|
log_prefix = pass_name,
|
|
})
|
|
|
|
local deducted_units = deducted / credit_cost
|
|
remaining_amount = remaining_amount - deducted_units
|
|
|
|
-- Settle the gate: record what this ent actually drained against every
|
|
-- applicable window limit so the next ent sees the reduced headroom.
|
|
consume_usage_window_headroom({
|
|
context = context,
|
|
ent_feature_id = ent_feature_id,
|
|
credit_cost = credit_cost,
|
|
units = deducted_units,
|
|
})
|
|
|
|
if deducted ~= 0 then
|
|
if not updates[ent_id] then
|
|
updates[ent_id] = { deducted = 0, additional_deducted = 0 }
|
|
end
|
|
updates[ent_id].deducted = (updates[ent_id].deducted or 0) + deducted
|
|
end
|
|
|
|
logger.log("%s ent %s deducted=%s remaining=%s", pass_name, ent_id, deducted, remaining_amount)
|
|
end
|
|
end
|
|
|
|
logger.log("=== %s END === remaining=%s", pass_name, remaining_amount)
|
|
|
|
return {
|
|
updates = updates,
|
|
remaining_amount = remaining_amount,
|
|
}
|
|
end
|
|
|
|
--[[
|
|
process_rollover_deduction(params)
|
|
|
|
Runs rollover deduction before the main balance passes.
|
|
]]
|
|
local function process_rollover_deduction(params)
|
|
local context = params.context
|
|
local customer_entitlement_deductions =
|
|
params.customer_entitlement_deductions or {}
|
|
local rollovers = params.rollovers
|
|
local target_entity_id = params.target_entity_id
|
|
local remaining_amount = params.remaining_amount or 0
|
|
local logger = context.logger
|
|
|
|
if is_nil(rollovers) or #rollovers == 0 or remaining_amount <= 0 then
|
|
return 0
|
|
end
|
|
|
|
-- Metered window limits count tracked units regardless of funding source,
|
|
-- so they gate the rollover phase too. Balance limits do not (ent_feature_id
|
|
-- = nil): rollover drains stay outside credit-pool caps, matching how spend
|
|
-- limits ignore them.
|
|
local rollover_amount = remaining_amount
|
|
local available_from_usage_windows = get_available_from_usage_windows({
|
|
context = context,
|
|
ent_feature_id = nil,
|
|
credit_cost = 1,
|
|
})
|
|
if not is_nil(available_from_usage_windows)
|
|
and available_from_usage_windows < rollover_amount then
|
|
rollover_amount = available_from_usage_windows
|
|
end
|
|
|
|
if rollover_amount <= 0 then
|
|
logger.log("Rollover deduction skipped - usage window headroom exhausted")
|
|
return 0
|
|
end
|
|
|
|
local first_ent = customer_entitlement_deductions[1]
|
|
local has_entity_scope = false
|
|
if first_ent then
|
|
has_entity_scope = first_ent.entity_feature_id ~= nil and first_ent.entity_feature_id ~= cjson.null
|
|
end
|
|
|
|
local rollover_deducted = deduct_from_rollovers({
|
|
context = context,
|
|
rollovers = rollovers,
|
|
amount = rollover_amount,
|
|
target_entity_id = target_entity_id,
|
|
has_entity_scope = has_entity_scope,
|
|
})
|
|
|
|
consume_usage_window_headroom({
|
|
context = context,
|
|
ent_feature_id = nil,
|
|
credit_cost = 1,
|
|
units = rollover_deducted,
|
|
})
|
|
|
|
logger.log("Rollover deduction: deducted=%s, remaining=%s", rollover_deducted, remaining_amount - rollover_deducted)
|
|
|
|
return rollover_deducted
|
|
end
|
|
|
|
--[[
|
|
run_deduction_on_context(params)
|
|
|
|
Executes rollover deduction and the two-pass main balance deduction against an
|
|
existing context, then builds final updates from that context.
|
|
|
|
params:
|
|
context: initialized context
|
|
customer_entitlement_deductions: deduction inputs
|
|
rollovers: rollover inputs | nil
|
|
amount_to_deduct: number | nil
|
|
target_balance: number | nil
|
|
target_entity_id: string | nil
|
|
alter_granted_balance: boolean
|
|
overage_behaviour: string
|
|
|
|
Returns:
|
|
{
|
|
updates: table,
|
|
rollover_updates: table,
|
|
remaining_amount: number,
|
|
}
|
|
]]
|
|
local function run_deduction_on_context(params)
|
|
local context = params.context
|
|
local customer_entitlement_deductions =
|
|
params.customer_entitlement_deductions or {}
|
|
local rollovers = params.rollovers
|
|
local target_entity_id = params.target_entity_id
|
|
local spend_limit_by_feature_id = params.spend_limit_by_feature_id
|
|
local usage_based_cus_ent_ids_by_feature_id = params.usage_based_cus_ent_ids_by_feature_id
|
|
local alter_granted_balance = params.alter_granted_balance or false
|
|
local overage_behaviour = params.overage_behaviour or 'cap'
|
|
local overage_behavior_is_allow = alter_granted_balance or overage_behaviour == 'allow'
|
|
local updates = {}
|
|
|
|
local remaining_amount
|
|
if not is_nil(params.target_balance) then
|
|
local current_total = get_total_balance({
|
|
context = context,
|
|
sorted_entitlements = customer_entitlement_deductions,
|
|
target_entity_id = target_entity_id,
|
|
})
|
|
remaining_amount = current_total - params.target_balance
|
|
else
|
|
remaining_amount = params.amount_to_deduct or 0
|
|
end
|
|
|
|
local is_refund = remaining_amount < 0
|
|
|
|
if not alter_granted_balance then
|
|
local rollover_deducted = process_rollover_deduction({
|
|
context = context,
|
|
customer_entitlement_deductions = customer_entitlement_deductions,
|
|
rollovers = rollovers,
|
|
target_entity_id = target_entity_id,
|
|
remaining_amount = remaining_amount,
|
|
})
|
|
remaining_amount = remaining_amount - rollover_deducted
|
|
end
|
|
|
|
local pass_one_result = process_deduction_pass({
|
|
context = context,
|
|
customer_entitlement_deductions = customer_entitlement_deductions,
|
|
target_entity_id = target_entity_id,
|
|
spend_limit_by_feature_id = spend_limit_by_feature_id,
|
|
usage_based_cus_ent_ids_by_feature_id = usage_based_cus_ent_ids_by_feature_id,
|
|
alter_granted_balance = alter_granted_balance,
|
|
overage_behavior_is_allow = overage_behavior_is_allow,
|
|
pass_number = 1,
|
|
skip_if_not_usage_allowed = false,
|
|
updates = updates,
|
|
remaining_amount = remaining_amount,
|
|
})
|
|
updates = pass_one_result.updates
|
|
remaining_amount = pass_one_result.remaining_amount
|
|
|
|
if remaining_amount ~= 0 then
|
|
local pass_two_result = process_deduction_pass({
|
|
context = context,
|
|
customer_entitlement_deductions = customer_entitlement_deductions,
|
|
target_entity_id = target_entity_id,
|
|
spend_limit_by_feature_id = spend_limit_by_feature_id,
|
|
usage_based_cus_ent_ids_by_feature_id = usage_based_cus_ent_ids_by_feature_id,
|
|
alter_granted_balance = alter_granted_balance,
|
|
overage_behavior_is_allow = overage_behavior_is_allow,
|
|
pass_number = 2,
|
|
skip_if_not_usage_allowed = not is_refund,
|
|
updates = updates,
|
|
remaining_amount = remaining_amount,
|
|
})
|
|
updates = pass_two_result.updates
|
|
remaining_amount = pass_two_result.remaining_amount
|
|
end
|
|
|
|
remaining_amount = round_to_precision(remaining_amount, 10)
|
|
|
|
for ent_id, update in pairs(updates) do
|
|
local ent_data = context.customer_entitlements[ent_id]
|
|
if ent_data then
|
|
if ent_data.has_entity_scope then
|
|
update.entities = ent_data.entities
|
|
update.balance = 0
|
|
else
|
|
update.balance = ent_data.balance
|
|
end
|
|
|
|
update.adjustment = ent_data.adjustment or 0
|
|
update.additional_balance = 0
|
|
end
|
|
end
|
|
|
|
local rollover_updates = {}
|
|
if not is_nil(rollovers) and #rollovers > 0 then
|
|
for rollover_id, rollover_data in pairs(context.rollovers) do
|
|
for _, rollover in ipairs(rollovers) do
|
|
if rollover.id == rollover_id then
|
|
rollover_updates[rollover_id] = {
|
|
cus_ent_id = rollover_data.cus_ent_id,
|
|
balance = rollover_data.balance,
|
|
usage = rollover_data.usage,
|
|
entities = rollover_data.entities,
|
|
}
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return {
|
|
updates = updates,
|
|
rollover_updates = rollover_updates,
|
|
remaining_amount = remaining_amount,
|
|
}
|
|
end
|