Files
cfw-autumn/server/src/_luaScriptsV2/fullSubjectDeduction/lock/unwindLockV2.lua
2026-06-11 15:28:59 +01:00

450 lines
15 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
-- Entitlement no longer exists (e.g. product upgraded mid-flight).
-- Skip this item and leave remaining_unwind_value unchanged so the
-- caller can compensate against current live entitlements.
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = remaining_unwind_value,
error = nil,
}
end
queue_customer_entitlement_mutation({
context = context,
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
-- Rollover no longer exists (e.g. expired or removed mid-flight).
-- Skip this item and leave remaining_unwind_value unchanged.
return {
applied = false,
unwind_iteration_value = 0,
remaining_unwind_value = remaining_unwind_value,
error = nil,
}
end
queue_rollover_mutation({
context = context,
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,
})
context.logger.log(
"[unwind_lock_items] index=%d cus_ent_id=%s applied=%s unwound=%s remaining=%s error=%s",
index,
tostring(item.customer_entitlement_id or item.rollover_id or "?"),
tostring(result.applied),
tostring(result.unwind_iteration_value),
tostring(result.remaining_unwind_value),
tostring(result.error or "nil")
)
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)
if is_nil(receipt) then
context.logger.log("[unwind_lock] receipt not found")
empty_result.error = 'RESERVATION_NOT_FOUND'
return empty_result
end
local items = receipt.items or cjson.decode('[]')
context.logger.log("[unwind_lock] unwinding %d items, unwind_value=%s", #items, tostring(unwind_value))
-- Compute lock_sign from the sum of value_deltas across all receipt items.
-- unwind_value is always a positive magnitude; the caller needs lock_sign to
-- know the direction of the original deduction so it can compensate for any
-- items that were skipped (entitlement/rollover no longer exists).
local lock_value_sum = 0
for _, item in ipairs(items) do
lock_value_sum = lock_value_sum + safe_number(item.value_delta)
end
local lock_sign = lock_value_sum >= 0 and 1 or -1
local unwind_items_result = unwind_lock_items({
context = context,
items = items,
unwind_value = unwind_value,
})
if not is_nil(unwind_items_result.error) then
context.logger.log("[unwind_lock] unwind error: %s", unwind_items_result.error)
empty_result.error = unwind_items_result.error
return empty_result
end
local skipped_unwind = unwind_items_result.remaining_unwind_value
-- remaining_signed_unwind_value: the signed amount that could not be unwound
-- because the target entitlement/rollover no longer exists.
-- Callers can add this directly to additional_value to compensate:
-- effective_additional = additional_value + remaining_signed_unwind_value
-- A positive lock (deduction) that couldn't be restored → negative signed value
-- (a refund against current entitlements).
-- A negative lock (credit) that couldn't be taken back → positive signed value
-- (a deduction against current entitlements).
local remaining_signed_unwind_value = -lock_sign * skipped_unwind
if skipped_unwind > 0 then
context.logger.log(
"[unwind_lock] skipped_unwind=%s, lock_sign=%d, remaining_signed_unwind_value=%s",
tostring(skipped_unwind), lock_sign, tostring(remaining_signed_unwind_value)
)
end
local modified_ids = collect_unwind_modified_ids({
iterations = unwind_items_result.iterations,
})
context.logger.log(
"[unwind_lock] done: applied=%s, remaining=%s, cus_ents=%d, rollovers=%d",
tostring(unwind_items_result.applied),
tostring(unwind_items_result.remaining_unwind_value),
#modified_ids.modified_customer_entitlement_ids,
#modified_ids.modified_rollover_ids
)
return {
error = cjson.null,
unwind_value = unwind_value,
remaining_signed_unwind_value = remaining_signed_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,
-- Per-item applied amounts (tracked units + credit_cost), so callers can
-- mirror the unwind onto usage-window counters.
iterations = unwind_items_result.iterations,
}
end