405 lines
12 KiB
Lua
405 lines
12 KiB
Lua
-- ============================================================================
|
|
-- LOCK UNWIND HELPERS
|
|
-- Step-by-step helpers for reconciling a lock receipt to a final value
|
|
-- ============================================================================
|
|
|
|
-- ============================================================================
|
|
-- HELPER: Normalize a receipt identifier field to nil
|
|
-- ============================================================================
|
|
local function normalize_lock_item_id(value)
|
|
if is_nil(value) then
|
|
return nil
|
|
end
|
|
|
|
return value
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- HELPER: Normalize delta signs for one mutation item
|
|
-- ============================================================================
|
|
local function get_lock_item_delta_signs(params)
|
|
local item = params.item or {}
|
|
|
|
return {
|
|
value_sign = safe_number(item.value_delta) >= 0 and 1 or -1,
|
|
balance_sign = safe_number(item.balance_delta) >= 0 and 1 or -1,
|
|
adjustment_sign = safe_number(item.adjustment_delta) >= 0 and 1 or -1,
|
|
usage_sign = safe_number(item.usage_delta) >= 0 and 1 or -1,
|
|
}
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- HELPER: Calculate how much of one item should be unwound in this iteration
|
|
-- ============================================================================
|
|
local function calculate_unwind_iteration_value(params)
|
|
local item = params.item or {}
|
|
local remaining_unwind_value = safe_number(params.remaining_unwind_value)
|
|
local item_value_magnitude = math.abs(safe_number(item.value_delta))
|
|
|
|
return math.min(item_value_magnitude, remaining_unwind_value)
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- STEP 1: Calculate the current signed lock value from receipt items
|
|
-- ============================================================================
|
|
local function calculate_lock_value(params)
|
|
local items = safe_table(params.items)
|
|
local lock_value = 0
|
|
|
|
for _, item in ipairs(items) do
|
|
lock_value = lock_value + safe_number(item.value_delta)
|
|
end
|
|
|
|
return lock_value
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- STEP 2: Calculate how much value should be unwound from the current lock
|
|
-- ============================================================================
|
|
local function calculate_unwind_value(params)
|
|
local lock_value = safe_number(params.lock_value)
|
|
local final_value = safe_number(params.final_value)
|
|
local lock_magnitude = math.abs(lock_value)
|
|
local final_magnitude = math.abs(final_value)
|
|
|
|
if lock_value == 0 then
|
|
return {
|
|
unwind_value = 0,
|
|
}
|
|
end
|
|
|
|
if final_value == 0 then
|
|
return {
|
|
unwind_value = lock_magnitude,
|
|
}
|
|
end
|
|
|
|
local lock_sign = lock_value > 0 and 1 or -1
|
|
local final_sign = final_value > 0 and 1 or -1
|
|
|
|
if lock_sign ~= final_sign then
|
|
return {
|
|
unwind_value = lock_magnitude,
|
|
}
|
|
end
|
|
|
|
if final_magnitude >= lock_magnitude then
|
|
return {
|
|
unwind_value = 0,
|
|
}
|
|
end
|
|
|
|
return {
|
|
unwind_value = lock_magnitude - final_magnitude,
|
|
}
|
|
end
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- STEP 3: Unwind one lock receipt item iteration
|
|
--
|
|
-- params:
|
|
-- context: table
|
|
-- item: mutation log item
|
|
-- remaining_unwind_value: positive magnitude
|
|
--
|
|
-- Returns:
|
|
-- {
|
|
-- applied = boolean,
|
|
-- unwind_iteration_value = number,
|
|
-- remaining_unwind_value = number,
|
|
-- error = string | nil,
|
|
-- }
|
|
-- ============================================================================
|
|
local function unwind_lock_item_iteration(params)
|
|
local context = params.context
|
|
local item = params.item or {}
|
|
local remaining_unwind_value = safe_number(params.remaining_unwind_value)
|
|
|
|
if remaining_unwind_value <= 0 then
|
|
return {
|
|
applied = false,
|
|
unwind_iteration_value = 0,
|
|
remaining_unwind_value = 0,
|
|
error = nil,
|
|
}
|
|
end
|
|
|
|
local unwind_iteration_value = calculate_unwind_iteration_value({
|
|
item = item,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
})
|
|
|
|
if unwind_iteration_value <= 0 then
|
|
return {
|
|
applied = false,
|
|
unwind_iteration_value = 0,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
error = nil,
|
|
}
|
|
end
|
|
|
|
local signs = get_lock_item_delta_signs({
|
|
item = item,
|
|
})
|
|
|
|
-- Calculate the amount of credits to unwind
|
|
local credits_to_unwind =
|
|
unwind_iteration_value * safe_number(item.credit_cost or 1)
|
|
local inverse_balance_delta = -signs.balance_sign * credits_to_unwind
|
|
local inverse_adjustment_delta = safe_number(item.adjustment_delta) ~= 0
|
|
and (-signs.adjustment_sign * credits_to_unwind)
|
|
or 0 --[[ default to 0 if adjustment_delta is not set ]]
|
|
local inverse_usage_delta = safe_number(item.usage_delta) ~= 0
|
|
and (-signs.usage_sign * credits_to_unwind)
|
|
or 0 --[[ default to 0 if usage_delta is not set ]]
|
|
local inverse_value_delta = -signs.value_sign * unwind_iteration_value
|
|
local entity_id = normalize_lock_item_id(item.entity_id)
|
|
|
|
if item.target_type == 'customer_entitlement' then
|
|
local customer_entitlement_id =
|
|
normalize_lock_item_id(item.customer_entitlement_id)
|
|
local ent_data = context.customer_entitlements[customer_entitlement_id]
|
|
if not ent_data then
|
|
return {
|
|
applied = false,
|
|
unwind_iteration_value = 0,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
error = 'LOCK_CUSTOMER_ENTITLEMENT_NOT_FOUND',
|
|
}
|
|
end
|
|
|
|
local path = ent_data.base_path
|
|
if entity_id then
|
|
path = build_entity_path(path, entity_id)
|
|
end
|
|
|
|
queue_customer_entitlement_mutation({
|
|
context = context,
|
|
path = path,
|
|
customer_entitlement_id = customer_entitlement_id,
|
|
entity_id = entity_id,
|
|
credit_cost = safe_number(item.credit_cost or 1),
|
|
balance_delta = inverse_balance_delta,
|
|
adjustment_delta = inverse_adjustment_delta,
|
|
value_delta = inverse_value_delta,
|
|
})
|
|
|
|
update_in_memory_customer_entitlement_mutation({
|
|
target = entity_id and ent_data.entities or ent_data,
|
|
entity_id = entity_id,
|
|
balance_delta = inverse_balance_delta,
|
|
adjustment_delta = inverse_adjustment_delta,
|
|
})
|
|
|
|
return {
|
|
applied = true,
|
|
unwind_iteration_value = unwind_iteration_value,
|
|
remaining_unwind_value = remaining_unwind_value - unwind_iteration_value,
|
|
error = nil,
|
|
}
|
|
end
|
|
|
|
if item.target_type == 'rollover' then
|
|
local rollover_id = normalize_lock_item_id(item.rollover_id)
|
|
local rollover_data = context.rollovers[rollover_id]
|
|
if not rollover_data then
|
|
return {
|
|
applied = false,
|
|
unwind_iteration_value = 0,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
error = 'LOCK_ROLLOVER_NOT_FOUND',
|
|
}
|
|
end
|
|
|
|
local path = rollover_data.base_path
|
|
if entity_id then
|
|
path = build_entity_path(path, entity_id)
|
|
end
|
|
|
|
queue_rollover_mutation({
|
|
context = context,
|
|
path = path,
|
|
rollover_id = rollover_id,
|
|
entity_id = entity_id,
|
|
credit_cost = safe_number(item.credit_cost or 1),
|
|
balance_delta = inverse_balance_delta,
|
|
usage_delta = inverse_usage_delta,
|
|
value_delta = inverse_value_delta,
|
|
})
|
|
|
|
update_in_memory_rollover_mutation({
|
|
target = entity_id and rollover_data.entities or rollover_data,
|
|
entity_id = entity_id,
|
|
balance_delta = inverse_balance_delta,
|
|
usage_delta = inverse_usage_delta,
|
|
})
|
|
|
|
return {
|
|
applied = true,
|
|
unwind_iteration_value = unwind_iteration_value,
|
|
remaining_unwind_value = remaining_unwind_value - unwind_iteration_value,
|
|
error = nil,
|
|
}
|
|
end
|
|
|
|
return {
|
|
applied = false,
|
|
unwind_iteration_value = 0,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
error = 'INVALID_LOCK_ITEM_TARGET_TYPE',
|
|
}
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- STEP 4: Iterate through receipt items backwards and unwind them
|
|
--
|
|
-- params:
|
|
-- context: table
|
|
-- items: ordered receipt items
|
|
-- unwind_value: positive magnitude
|
|
--
|
|
-- Returns:
|
|
-- {
|
|
-- applied = boolean,
|
|
-- remaining_unwind_value = number,
|
|
-- iterations = array,
|
|
-- error = string | nil,
|
|
-- }
|
|
-- ============================================================================
|
|
local function unwind_lock_items(params)
|
|
local context = params.context
|
|
local items = safe_table(params.items)
|
|
local remaining_unwind_value = safe_number(params.unwind_value)
|
|
local iterations = {}
|
|
|
|
if remaining_unwind_value <= 0 then
|
|
return {
|
|
applied = false,
|
|
remaining_unwind_value = 0,
|
|
iterations = iterations,
|
|
error = nil,
|
|
}
|
|
end
|
|
|
|
for index = #items, 1, -1 do
|
|
if remaining_unwind_value <= 0 then
|
|
break
|
|
end
|
|
|
|
local item = items[index]
|
|
local result = unwind_lock_item_iteration({
|
|
context = context,
|
|
item = item,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
})
|
|
|
|
if not is_nil(result.error) then
|
|
return {
|
|
applied = #iterations > 0,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
iterations = iterations,
|
|
error = result.error,
|
|
}
|
|
end
|
|
|
|
if result.applied then
|
|
table.insert(iterations, {
|
|
item = item,
|
|
unwind_iteration_value = result.unwind_iteration_value,
|
|
})
|
|
end
|
|
|
|
remaining_unwind_value = result.remaining_unwind_value
|
|
end
|
|
|
|
return {
|
|
applied = #iterations > 0,
|
|
remaining_unwind_value = remaining_unwind_value,
|
|
iterations = iterations,
|
|
error = nil,
|
|
}
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- STEP 5: Collect modified IDs from unwind iterations
|
|
-- ============================================================================
|
|
local function collect_unwind_modified_ids(params)
|
|
local iterations = safe_table(params.iterations)
|
|
local modified_customer_entitlement_ids = {}
|
|
local modified_rollover_ids = {}
|
|
local seen_customer_entitlements = {}
|
|
local seen_rollovers = {}
|
|
|
|
for _, iteration in ipairs(iterations) do
|
|
local item = iteration.item or {}
|
|
|
|
if not is_nil(item.customer_entitlement_id)
|
|
and not seen_customer_entitlements[item.customer_entitlement_id]
|
|
then
|
|
seen_customer_entitlements[item.customer_entitlement_id] = true
|
|
table.insert(modified_customer_entitlement_ids, item.customer_entitlement_id)
|
|
end
|
|
|
|
if not is_nil(item.rollover_id)
|
|
and not seen_rollovers[item.rollover_id]
|
|
then
|
|
seen_rollovers[item.rollover_id] = true
|
|
table.insert(modified_rollover_ids, item.rollover_id)
|
|
end
|
|
end
|
|
|
|
return {
|
|
modified_customer_entitlement_ids = modified_customer_entitlement_ids,
|
|
modified_rollover_ids = modified_rollover_ids,
|
|
}
|
|
end
|
|
|
|
-- ============================================================================
|
|
-- STEP 6: Unwind a lock receipt against an initialized context
|
|
-- ============================================================================
|
|
local function unwind_lock_on_context(params)
|
|
local context = params.context
|
|
local lock_receipt_key = params.lock_receipt_key
|
|
local unwind_value = params.unwind_value or 0
|
|
local empty_result = {
|
|
modified_customer_entitlement_ids = cjson.decode('[]'),
|
|
modified_rollover_ids = cjson.decode('[]'),
|
|
mutation_logs = cjson.decode('[]'),
|
|
}
|
|
|
|
local receipt = load_lock_receipt(lock_receipt_key)
|
|
|
|
local pending_error = require_pending_receipt(receipt)
|
|
if not is_nil(pending_error) then
|
|
empty_result.error = pending_error
|
|
return empty_result
|
|
end
|
|
|
|
local items = receipt.items or cjson.decode('[]')
|
|
local unwind_items_result = unwind_lock_items({
|
|
context = context,
|
|
items = items,
|
|
unwind_value = unwind_value,
|
|
})
|
|
|
|
if not is_nil(unwind_items_result.error) then
|
|
empty_result.error = unwind_items_result.error
|
|
return empty_result
|
|
end
|
|
|
|
local modified_ids = collect_unwind_modified_ids({
|
|
iterations = unwind_items_result.iterations,
|
|
})
|
|
|
|
return {
|
|
error = cjson.null,
|
|
unwind_value = unwind_value,
|
|
remaining_unwind_value = unwind_items_result.remaining_unwind_value,
|
|
modified_customer_entitlement_ids = modified_ids.modified_customer_entitlement_ids,
|
|
modified_rollover_ids = modified_ids.modified_rollover_ids,
|
|
mutation_logs = context.mutation_logs,
|
|
}
|
|
end
|