diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua index eccd5fb31..2ddc7949c 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua @@ -5,16 +5,16 @@ --[[ init_context(params) - + Initializes context object with current balances for all customer_entitlements and builds a rollover index for fast lookups. Reads from Redis once upfront to avoid multiple reads during passes. - + params: cache_key: string customer_entitlement_ids: array of customer_entitlement IDs full_customer: decoded FullCustomer object - + Returns: context table with: customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } } rollovers: { [rollover_id]: { base_path, cus_ent_id, balance, usage, entities } } @@ -25,7 +25,7 @@ ]] local function init_context(params) local logs = {} - + local context = { customer_entitlements = {}, rollovers = {}, @@ -38,14 +38,14 @@ local function init_context(params) end }, } - + for _, ent_id in ipairs(params.customer_entitlement_ids or {}) do local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(params.full_customer, ent_id) - + if cus_ent then local base_path - local is_loose = (cp_idx == nil) -- Loose entitlement if no customer_product index - + local is_loose = (cp_idx == nil) -- Loose entitlement if no customer_product index + if is_loose then -- Loose entitlement: path is $.extra_customer_entitlements[idx] local ece_idx_0 = ce_idx - 1 @@ -56,11 +56,11 @@ local function init_context(params) local ce_idx_0 = ce_idx - 1 base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' end - + local entitlement = cus_ent.entitlement local has_entity_scope = not is_nil(entitlement) - and not is_nil(entitlement.entity_feature_id) - + and not is_nil(entitlement.entity_feature_id) + local ent_data = { base_path = base_path, has_entity_scope = has_entity_scope, @@ -68,17 +68,17 @@ local function init_context(params) unlimited = cus_ent.unlimited, is_loose = is_loose, } - + if has_entity_scope then - ent_data.balance = 0 -- Not used for entity-scoped + ent_data.balance = 0 -- Not used for entity-scoped ent_data.entities = read_current_entities(params.cache_key, base_path) else ent_data.balance = read_current_balance(params.cache_key, base_path) ent_data.entities = nil end - + context.customer_entitlements[ent_id] = ent_data - + -- Build rollover index for this customer_entitlement local cus_ent_rollovers = cus_ent.rollovers if cus_ent_rollovers and type(cus_ent_rollovers) == 'table' then @@ -86,10 +86,10 @@ local function init_context(params) if rollover and rollover.id then local r_idx_0 = r_idx - 1 local rollover_path = base_path .. '.rollovers[' .. r_idx_0 .. ']' - + -- Read fresh rollover data from Redis local rollover_data = read_rollover_data(params.cache_key, rollover_path) - + if rollover_data then context.rollovers[rollover.id] = { base_path = rollover_path, @@ -104,7 +104,7 @@ local function init_context(params) end end end - + return context end @@ -276,6 +276,7 @@ local function queue_rollover_update(params) path = params.path, rollover_id = params.rollover_id, entity_id = params.entity_id, + credit_cost = params.credit_cost, balance_delta = -deduct_amount, usage_delta = deduct_amount, value_delta = params.value_delta or 0, @@ -298,7 +299,7 @@ end --[[ apply_pending_writes(cache_key, context) - + Applies all queued writes to Redis. Called only after validation passes. ]] diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua index c73f86063..27dbcdf9c 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua @@ -5,11 +5,11 @@ --[[ calculate_rollover_change(balance, amount) - + Calculates how much to deduct from a rollover balance (simple floor at 0). - balance: current rollover balance (in credits) - amount: amount to deduct (in credits) - + Returns: amount to deduct (in credits, floor at 0) ]] local function calculate_rollover_change(balance, amount) @@ -18,27 +18,27 @@ end --[[ deduct_from_rollovers(params) - + Deducts from rollover balances before main entitlements. Mirrors SQL logic in server/src/internal/balances/utils/sql/deductFromRollovers.sql - + NOTE: Unlike deduct_from_main_balance which has a single credit_cost for the whole operation, rollovers can have different credit_costs (each rollover may come from a different entitlement with different credit systems). So we must convert per-rollover rather than once upfront. - + Handles three scenarios: 1. Entity-scoped with target_entity_id: Deduct from specific entity in rollover 2. Entity-scoped without target_entity_id: Deduct from all entities in rollover 3. Top-level balance: Deduct from rollover.balance - + params: context: table (context object with rollovers indexed) rollovers: {id: string, credit_cost: number}[] (rollovers with credit_cost) amount: number (amount to deduct, in feature units) target_entity_id: string | nil has_entity_scope: boolean - + Returns: deducted: number (total amount deducted from rollovers, in FEATURE units) ]] @@ -49,15 +49,15 @@ local function deduct_from_rollovers(params) local target_entity_id = params.target_entity_id local has_entity_scope = params.has_entity_scope local logger = context.logger - + -- Early return if no rollovers or no amount if not rollovers or #rollovers == 0 or amount <= 0 then return 0 end - - local remaining = amount -- in feature units - local deducted = 0 -- in feature units - + + local remaining = amount -- in feature units + local deducted = 0 -- in feature units + logger.log("=== ROLLOVER DEDUCTION START ===") local ids_str = "" for i, r in ipairs(rollovers) do @@ -65,25 +65,28 @@ local function deduct_from_rollovers(params) ids_str = ids_str .. r.id .. "(cost=" .. tostring(r.credit_cost or 1) .. ")" end logger.log(" rollovers: %s", ids_str) - logger.log(" amount: %s, has_entity_scope: %s, target_entity_id: %s", + logger.log(" amount: %s, has_entity_scope: %s, target_entity_id: %s", tostring(amount), tostring(has_entity_scope), tostring(target_entity_id or "nil")) - + -- Loop through rollovers in order (already sorted by expires_at) for _, rollover_obj in ipairs(rollovers) do if remaining <= 0 then break end - + local rollover_id = rollover_obj.id - local credit_cost = rollover_obj.credit_cost or 1 - + local credit_cost = rollover_obj.credit_cost + if is_nil(credit_cost) or credit_cost == 0 then + credit_cost = 1 + end + local rollover_data = context.rollovers[rollover_id] if not rollover_data then logger.log(" Rollover %s not found in context", rollover_id) else local base_path = rollover_data.base_path - + -- Convert remaining (feature units) to credits for this rollover local remaining_credits = remaining * credit_cost - + -- ======================================================================== -- CASE 1: Entity-scoped with specific target entity -- ======================================================================== @@ -91,15 +94,15 @@ local function deduct_from_rollovers(params) local entities = rollover_data.entities or {} local entity_obj = entities[target_entity_id] local balance = entity_obj and safe_number(entity_obj.balance) or 0 - + local to_change = calculate_rollover_change(balance, remaining_credits) - - logger.log(" Rollover %s entity %s: balance=%s, credit_cost=%s, to_change=%s", + + logger.log(" Rollover %s entity %s: balance=%s, credit_cost=%s, to_change=%s", rollover_id, target_entity_id, balance, credit_cost, to_change) - + if to_change > 0 then local entity_path = base_path .. '["entities"]["' .. target_entity_id .. '"]' - + queue_rollover_update({ context = context, path = entity_path, @@ -109,43 +112,43 @@ local function deduct_from_rollovers(params) credit_cost = credit_cost, value_delta = to_change / credit_cost, }) - + update_in_memory_rollover({ target = entities, entity_id = target_entity_id, deduct_amount = to_change, }) - + -- Convert credits deducted back to features local features = to_change / credit_cost deducted = deducted + features remaining = remaining - features end - - -- ======================================================================== - -- CASE 2: Entity-scoped without target (all entities) - -- ======================================================================== + + -- ======================================================================== + -- CASE 2: Entity-scoped without target (all entities) + -- ======================================================================== elseif has_entity_scope then local entities = rollover_data.entities or {} local entity_keys = sorted_keys(entities) - + for _, entity_key in ipairs(entity_keys) do if remaining <= 0 then break end - + -- Recalculate remaining_credits (remaining may have changed) remaining_credits = remaining * credit_cost - + local entity_obj = entities[entity_key] local balance = entity_obj and safe_number(entity_obj.balance) or 0 - + local to_change = calculate_rollover_change(balance, remaining_credits) - - logger.log(" Rollover %s entity %s: balance=%s, credit_cost=%s, to_change=%s", + + logger.log(" Rollover %s entity %s: balance=%s, credit_cost=%s, to_change=%s", rollover_id, entity_key, balance, credit_cost, to_change) - + if to_change > 0 then local entity_path = base_path .. '["entities"]["' .. entity_key .. '"]' - + queue_rollover_update({ context = context, path = entity_path, @@ -155,30 +158,30 @@ local function deduct_from_rollovers(params) credit_cost = credit_cost, value_delta = to_change / credit_cost, }) - + update_in_memory_rollover({ target = entities, entity_id = entity_key, deduct_amount = to_change, }) - + local features = to_change / credit_cost deducted = deducted + features remaining = remaining - features end end - - -- ======================================================================== - -- CASE 3: Top-level balance (no entity scope) - -- ======================================================================== + + -- ======================================================================== + -- CASE 3: Top-level balance (no entity scope) + -- ======================================================================== else local balance = safe_number(rollover_data.balance) - + local to_change = calculate_rollover_change(balance, remaining_credits) - - logger.log(" Rollover %s top-level: balance=%s, credit_cost=%s, to_change=%s", + + logger.log(" Rollover %s top-level: balance=%s, credit_cost=%s, to_change=%s", rollover_id, balance, credit_cost, to_change) - + if to_change > 0 then queue_rollover_update({ context = context, @@ -189,13 +192,13 @@ local function deduct_from_rollovers(params) credit_cost = credit_cost, value_delta = to_change / credit_cost, }) - + update_in_memory_rollover({ target = rollover_data, entity_id = nil, deduct_amount = to_change, }) - + local features = to_change / credit_cost deducted = deducted + features remaining = remaining - features @@ -203,8 +206,8 @@ local function deduct_from_rollovers(params) end end end - + logger.log("=== ROLLOVER DEDUCTION END === deducted=%s", deducted) - + return deducted end diff --git a/server/src/internal/api/check/runCheckWithTrack.ts b/server/src/internal/api/check/runCheckWithTrack.ts index c8192006a..8c3901d22 100644 --- a/server/src/internal/api/check/runCheckWithTrack.ts +++ b/server/src/internal/api/check/runCheckWithTrack.ts @@ -105,24 +105,28 @@ export const runCheckWithTrack = async ({ // Schedule lock expiration if it exists if (body.lock?.expires_at && allowed) { - const scheduleName = buildLockScheduleName({ - orgId: ctx.org.id, - env: ctx.env, - hashedKey: body.lock.hashed_key, - }); - await workflows.triggerExpireLockReceipt( - { + try { + const scheduleName = buildLockScheduleName({ orgId: ctx.org.id, env: ctx.env, - customerId: body.customer_id, - lockKey: body.lock.key, hashedKey: body.lock.hashed_key, - }, - { - scheduleAt: new Date(body.lock.expires_at), - scheduleName, - }, - ); + }); + await workflows.triggerExpireLockReceipt( + { + orgId: ctx.org.id, + env: ctx.env, + customerId: body.customer_id, + lockKey: body.lock.key, + hashedKey: body.lock.hashed_key, + }, + { + scheduleAt: new Date(body.lock.expires_at), + scheduleName, + }, + ); + } catch (error) { + ctx.logger.error(`Failed to schedule lock expiration: ${error}`); + } } const checkResponse = CheckResponseV3Schema.parse({ diff --git a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts index 5211fba37..56b845994 100644 --- a/server/src/internal/balances/finalizeLock/runFinalizeLock.ts +++ b/server/src/internal/balances/finalizeLock/runFinalizeLock.ts @@ -24,12 +24,16 @@ export const runFinalizeLock = async ({ receiptRegion: receipt.region, }); - // Cancel any pending EventBridge expiry schedule for this lock - await cancelLockExpiry({ - orgId: ctx.org.id, - env: ctx.env, - hashedKey: Bun.hash(params.lock_key).toString(), - }); + try { + // Cancel any pending EventBridge expiry schedule for this lock + await cancelLockExpiry({ + orgId: ctx.org.id, + env: ctx.env, + hashedKey: Bun.hash(params.lock_key).toString(), + }); + } catch (error) { + ctx.logger.error(`Failed to cancel lock expiry: ${error}`); + } // No-op deduction: finalValue == lockValue means nothing changed, just delete the receipt if (new Decimal(finalValue).equals(lockValue)) {