diff --git a/ai b/ai index 0e52f71fb..bca809a30 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 0e52f71fbd69e7a4a58b63863a8c4929bfd9ebf8 +Subproject commit bca809a3078696361300cc65dc201fc077f91915 diff --git a/scripts/migrations/migrate-functions.ts b/scripts/migrations/migrate-functions.ts index e4011c13b..302d6575b 100644 --- a/scripts/migrations/migrate-functions.ts +++ b/scripts/migrations/migrate-functions.ts @@ -3,6 +3,17 @@ import inquirer from "inquirer"; loadLocalEnv(); +// Dev worktrees (scripts/dw): overlay server/.env.local -- the same override +// `bun dw run` gets via Bun's automatic .env.local loading -- so functions +// land on the worktree's Neon branch instead of the canonical dev DB. Never +// applied for prod targets (migrate-functions:prod): infisical injects the +// prod DATABASE_URL before this script starts, and prod URLs carry the +// us-east-2 marker (same convention as assertNotProductionDb). +if (!process.env.DATABASE_URL?.includes("us-east-2")) { + process.env.ENV_FILE = ".env.local"; + loadLocalEnv({ force: true }); +} + export const migrateFunctions = async () => { // Dynamic import to ensure env is loaded first const { initializeDatabaseFunctions } = await import( diff --git a/server/src/_luaScriptsV2/fullSubject/rollUsageWindows/rollUsageWindows.lua b/server/src/_luaScriptsV2/fullSubject/rollUsageWindows/rollUsageWindows.lua new file mode 100644 index 000000000..d52a72ac3 --- /dev/null +++ b/server/src/_luaScriptsV2/fullSubject/rollUsageWindows/rollUsageWindows.lua @@ -0,0 +1,80 @@ +--[[ + Lua Script: Roll usage-window counters in a per-feature hash + + Atomically patches rows in the reserved '_usage_windows' field: the lazy + roll (post-getFullSubject) zeroes counts whose window closed and advances + bounds/anchor to the current derivation. Atomicity matters because a + concurrent deduction may be writing the same field. + + Fail-open: a missing/malformed field, or a row absent for a scope, is left + untouched (the write path creates rows; the roll only maintains them). + + KEYS[1] = balance hash key + ARGV[1] = JSON params: + { + now: number, + ttl_seconds: number, + rolls: [{ + internal_entity_id: string | null, -- scope selector + zero_usage: boolean, -- stored window closed: count dies + window_start_at: number, + window_end_at: number, + anchor_customer_entitlement_id: string | null, + }] + } + + Returns JSON: { rolled: number } +]] + +local params = cjson.decode(ARGV[1]) +local now = safe_number(params.now) +local ttl_seconds = safe_number(params.ttl_seconds) + +local USAGE_WINDOWS_FIELD = '_usage_windows' + +local raw = redis.call('HGET', KEYS[1], USAGE_WINDOWS_FIELD) +if is_nil(raw) then + return cjson.encode({ rolled = 0 }) +end + +local ok, windows = pcall(cjson.decode, raw) +if not ok or type(windows) ~= 'table' then + return cjson.encode({ rolled = 0 }) +end + +local rolled = 0 +for _, roll in ipairs(params.rolls or {}) do + local roll_entity = roll.internal_entity_id + for _, window in ipairs(windows) do + if type(window) == 'table' then + local window_entity = window.internal_entity_id + local entities_match = + (is_nil(roll_entity) and is_nil(window_entity)) + or roll_entity == window_entity + if entities_match then + if roll.zero_usage then + window.usage = 0 + end + window.window_start_at = roll.window_start_at + window.window_end_at = roll.window_end_at + window.anchor_customer_entitlement_id = + roll.anchor_customer_entitlement_id + window.updated_at = now + rolled = rolled + 1 + end + end + end +end + +if rolled == 0 then + return cjson.encode({ rolled = 0 }) +end + +local encoded = #windows > 0 and cjson.encode(windows) or '[]' +redis.call('HSET', KEYS[1], USAGE_WINDOWS_FIELD, encoded) + +if ttl_seconds > 0 and redis.call('TTL', KEYS[1]) < 0 then + redis.call('EXPIRE', KEYS[1], ttl_seconds) +end + +return cjson.encode({ rolled = rolled }) diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/contextUtilsV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/contextUtilsV2.lua index 25b75b3e0..b36ce837c 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/contextUtilsV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/contextUtilsV2.lua @@ -24,17 +24,24 @@ local function init_context(params) env = params.env, customer_id = params.customer_id, customer_entitlement_deductions = params.customer_entitlement_deductions, - anchor_entitlements = params.anchor_entitlements, balance_keys_by_feature_id = params.balance_keys_by_feature_id, }) local context = { customer_entitlements = {}, rollovers = {}, + -- Customer-scoped windowed-cap counters, loaded below alongside the other + -- subject state: { [feature_id] = { balance_key, windows, dirty } }. + usage_windows = read_usage_windows({ + usage_window_limits = params.usage_window_limits, + balance_keys_by_feature_id = params.balance_keys_by_feature_id, + now = params.usage_window_now, + }), org_id = params.org_id, env = params.env, customer_id = params.customer_id, mutation_logs = {}, + usage_window_mutations = {}, pending_writes = {}, pending_write_ids = {}, missing_customer_entitlement_ids = @@ -97,23 +104,6 @@ local function init_context(params) end end - -- Register usage-window anchor cus_ents that are not in the deduction set so - -- their counter can be read/mutated and persisted (HSET) on apply. - for customer_entitlement_id, balance_entry in pairs(read_result.balances_by_id) do - if balance_entry.anchor_only - and is_nil(context.customer_entitlements[customer_entitlement_id]) - then - context.customer_entitlements[customer_entitlement_id] = { - base_path = customer_entitlement_id, - balance_key = balance_entry.balance_key, - subject_balance = balance_entry.subject_balance, - customer_entitlement_id = customer_entitlement_id, - feature_id = balance_entry.feature_id, - is_anchor_only = true, - } - end - end - return context end diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromSubjectBalances.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromSubjectBalances.lua index e36a1c362..5943f64c4 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromSubjectBalances.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromSubjectBalances.lua @@ -48,11 +48,30 @@ idempotency_ttl_ms: number | null } + Usage windows (customer-scoped windowed caps): + CONFIG IN: params.usage_window_limits[] -- the resolved caps (limit, + bounds, dimension) from fullSubjectToUsageWindowLimits. + COUNTERS OUT: usage_windows_by_feature_id -- the post-deduction COUNTER + ROWS (DbUsageWindow: usage amounts, mirrors the + usage_windows table), NOT the config. + Counters live in the capped feature's balance hash under the reserved + '_usage_windows' field (so each capped feature's hash key must be in + KEYS[], via usageWindowFeatureIds in the TS key builder); they are loaded + into context.usage_windows by init_context and follow the same in-memory + mutate -> flush lifecycle as entitlement balances. Enforcement is woven + into the deduction passes like spend limits: each ent's deductible amount + is gated by window headroom (with credit conversions), and a window-capped + leftover flows through the standard overage_behaviour handling ('cap' + applies the partial deduction, 'reject' returns INSUFFICIENT_BALANCE). A + missing field loads as an empty counter set (fail open). + Returns JSON: { updates: { [cus_ent_id]: { balance, additional_balance, adjustment, entities, deducted, additional_deducted } }, rollover_updates: { [rollover_id]: { balance, usage, entities } }, modified_customer_entitlement_ids: string[], + usage_windows_by_feature_id: { [feature_id]: DbUsageWindow[] } | null, + usage_window_mutations: { usage_window_id, feature_id, internal_entity_id, window_start_at, usage_delta }[], remaining: number, error: string | null, feature_id: string | null @@ -113,29 +132,9 @@ local unwind_value = params.unwind_value local lock_receipt_key = lock_receipt_key_from_keys local usage_window_limits = params.usage_window_limits local usage_window_now = params.usage_window_now +local usage_window_ttl_seconds = params.usage_window_ttl_seconds local is_consumption = params.is_consumption --- Distinct usage-window anchor cus_ents to force-load into context (they own --- the counters and may not be in the deduction set). -local anchor_entitlements = {} -if not is_nil(usage_window_limits) then - local seen_anchor_ids = {} - for _, usage_window_limit in ipairs(usage_window_limits) do - local anchor_id = usage_window_limit.anchor_customer_entitlement_id - local anchor_feature_id = usage_window_limit.anchor_feature_id - if not is_nil(anchor_id) - and not is_nil(anchor_feature_id) - and not seen_anchor_ids[anchor_id] - then - seen_anchor_ids[anchor_id] = true - table.insert(anchor_entitlements, { - customer_entitlement_id = anchor_id, - feature_id = anchor_feature_id, - }) - end - end -end - if not is_nil(idempotency_key) then if redis.call('EXISTS', idempotency_key) == 1 then return cjson.encode({ @@ -163,12 +162,30 @@ if #customer_entitlement_deductions == 0 then }) end +-- Usage windows are enforced for positive consumption INCLUDING locks (a +-- lock reserves headroom and counts at lock time), never for refunds, +-- target_balance, or granted-balance edits. Unwinds don't enforce but DO +-- load counters so the freed amount can be decremented back. Computed before +-- init_context so non-participating calls skip the counter reads entirely. +local has_usage_window_limits = not is_nil(usage_window_limits) + and #usage_window_limits > 0 +-- A zero unwind_value (finalize at-or-above the lock) is no unwind at all: +-- the extra delta must still be enforced and counted. +local has_unwind = not is_nil(unwind_value) and safe_number(unwind_value) > 0 +local enforce_usage_windows = is_consumption + and not has_unwind + and has_usage_window_limits +local unwind_usage_windows = has_unwind and has_usage_window_limits + local context = init_context({ org_id = org_id, env = env, customer_id = customer_id, customer_entitlement_deductions = customer_entitlement_deductions, - anchor_entitlements = anchor_entitlements, + usage_window_limits = (enforce_usage_windows or unwind_usage_windows) + and usage_window_limits + or nil, + usage_window_now = usage_window_now, balance_keys_by_feature_id = params.balance_keys_by_feature_id, debug = params.debug, }) @@ -210,6 +227,14 @@ if not is_nil(unwind_value) and safe_number(unwind_value) > 0 then -- Track which entitlements the unwind touched so the caller can sync them. unwind_modified_cus_ent_ids = unwind_result.modified_customer_entitlement_ids or {} + if unwind_usage_windows then + decrement_usage_windows_for_unwind({ + context = context, + iterations = unwind_result.iterations, + now = usage_window_now, + }) + end + -- Fold any skipped unwind (missing entitlements/rollovers) into amount_to_deduct -- so the forward pass compensates against current live entitlements. local skipped = unwind_result.remaining_signed_unwind_value or 0 @@ -220,37 +245,6 @@ end local logger = context.logger --- Usage windows are enforced only for positive consumption, never for refunds, --- target_balance, granted-balance edits, locks, or unwinds. -local enforce_usage_windows = is_consumption - and is_nil(unwind_value) - and (is_nil(lock) or not lock.enabled) - and not is_nil(usage_window_limits) - and #usage_window_limits > 0 - -if enforce_usage_windows then - local clamp_result = clamp_amount_to_usage_windows({ - context = context, - usage_window_limits = usage_window_limits, - amount_to_deduct = amount_to_deduct, - }) - - if not is_nil(clamp_result.exceeded_feature_id) then - return cjson.encode({ - error = 'USAGE_LIMIT_EXCEEDED', - feature_id = clamp_result.exceeded_feature_id, - remaining = safe_number(amount_to_deduct), - updates = {}, - rollover_updates = {}, - modified_customer_entitlement_ids = new_empty_array(), - mutation_logs = new_empty_array(), - logs = context.logs, - }) - end - - amount_to_deduct = clamp_result.amount_to_deduct -end - logger.log("=== LUA DEDUCTION START ===") logger.log("=== PARAMS ===") logger.log(" amount_to_deduct: %s", tostring(amount_to_deduct or "nil")) @@ -298,7 +292,15 @@ local mutation_logs = context.mutation_logs if type(mutation_logs) ~= 'table' or #mutation_logs == 0 then mutation_logs = cjson.decode('[]') end --- Throw error and don't apply updates if we're in reject mode and there's still remaining amount +local usage_window_mutations = context.usage_window_mutations +if type(usage_window_mutations) ~= 'table' or #usage_window_mutations == 0 then + usage_window_mutations = cjson.decode('[]') +end +-- Throw error and don't apply updates if we're in reject mode and there's +-- still remaining amount. Usage-window shortfalls flow through here like any +-- other: the deduction passes already gated every ent by window headroom, so +-- a window-capped leftover clamps under 'cap' and rejects as +-- INSUFFICIENT_BALANCE under 'reject'. if remaining_amount > 0 and overage_behaviour == 'reject' then return cjson.encode({ error = 'INSUFFICIENT_BALANCE', @@ -312,33 +314,9 @@ if remaining_amount > 0 and overage_behaviour == 'reject' then end if enforce_usage_windows then - local exceeded_feature_id = check_usage_window_limits({ - context = context, - usage_window_limits = usage_window_limits, - updates = updates, - amount_to_deduct = amount_to_deduct, - remaining_amount = remaining_amount, - }) - - if not is_nil(exceeded_feature_id) then - return cjson.encode({ - error = 'USAGE_LIMIT_EXCEEDED', - feature_id = exceeded_feature_id, - remaining = remaining_amount, - updates = {}, - rollover_updates = {}, - modified_customer_entitlement_ids = new_empty_array(), - mutation_logs = mutation_logs, - logs = context.logs, - }) - end - increment_usage_window_counters({ context = context, usage_window_limits = usage_window_limits, - updates = updates, - amount_to_deduct = amount_to_deduct, - remaining_amount = remaining_amount, now = usage_window_now, }) end @@ -397,6 +375,10 @@ update_aggregated_balances({ mutation_logs = mutation_logs, }) +if enforce_usage_windows or unwind_usage_windows then + apply_usage_window_writes(context, usage_window_ttl_seconds) +end + if not is_nil(idempotency_key) and not is_nil(idempotency_ttl_ms) then redis.call('SET', idempotency_key, '1', 'PX', idempotency_ttl_ms) end @@ -408,6 +390,9 @@ return cjson.encode({ rollover_updates = rollover_updates, modified_customer_entitlement_ids = modified_customer_entitlement_ids, mutation_logs = mutation_logs, + usage_windows_by_feature_id = + usage_windows_to_result(context) or cjson.null, + usage_window_mutations = usage_window_mutations, remaining = remaining_amount, error = cjson.null, logs = context.logs diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/lock/unwindLockV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/lock/unwindLockV2.lua index b8775ec37..aa7188abc 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/lock/unwindLockV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/lock/unwindLockV2.lua @@ -442,5 +442,8 @@ local function unwind_lock_on_context(params) 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 diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/readSubjectBalances.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/readSubjectBalances.lua index b118c39b5..acbad7d88 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/readSubjectBalances.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/readSubjectBalances.lua @@ -19,9 +19,6 @@ local function read_subject_balances(params) local missing_customer_entitlement_ids = {} local entries_by_balance_key = {} local seen_ids_by_balance_key = {} - -- Anchor-only cus_ents (usage-window owners not in the deduction set) must - -- not abort the deduction when absent; a missing anchor fails closed later. - local anchor_only_ids = {} local balance_keys_by_feature_id = safe_table(params.balance_keys_by_feature_id) local function queue_balance_read(customer_entitlement_id, feature_id) @@ -54,7 +51,6 @@ local function read_subject_balances(params) return true end - local deduction_ids = {} for _, ent_obj in ipairs(params.customer_entitlement_deductions or {}) do local customer_entitlement_id = ent_obj.customer_entitlement_id if customer_entitlement_id then @@ -62,17 +58,6 @@ local function read_subject_balances(params) if not queued then table.insert(missing_customer_entitlement_ids, customer_entitlement_id) end - deduction_ids[customer_entitlement_id] = true - end - end - - -- A deduction target must never be marked anchor_only, or a cache miss on it is - -- swallowed instead of surfacing as missing + triggering the Postgres fallback. - for _, anchor in ipairs(params.anchor_entitlements or {}) do - local customer_entitlement_id = anchor.customer_entitlement_id - if customer_entitlement_id and not deduction_ids[customer_entitlement_id] then - anchor_only_ids[customer_entitlement_id] = true - queue_balance_read(customer_entitlement_id, anchor.feature_id) end end @@ -89,19 +74,16 @@ local function read_subject_balances(params) local subject_balance = decode_subject_balance(raw_value) if subject_balance == nil then - if not anchor_only_ids[customer_entitlement_id] then - table.insert( - missing_customer_entitlement_ids, - customer_entitlement_id - ) - end + table.insert( + missing_customer_entitlement_ids, + customer_entitlement_id + ) else balances_by_id[customer_entitlement_id] = { balance_key = balance_key, customer_entitlement_id = customer_entitlement_id, feature_id = entry.feature_id, subject_balance = subject_balance, - anchor_only = anchor_only_ids[customer_entitlement_id] or nil, } end end diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua index 5e9901b33..1680edbd5 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua @@ -85,18 +85,43 @@ local function process_deduction_pass(params) 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 - usage_allowed=false or not in context", pass_name, ent_id) + 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 = remaining_amount, + amount = ent_amount, credit_cost = credit_cost, pass_number = pass_number, available_overage = available_overage, @@ -107,7 +132,17 @@ local function process_deduction_pass(params) log_prefix = pass_name, }) - remaining_amount = remaining_amount - (deducted / credit_cost) + 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 @@ -146,6 +181,26 @@ local function process_rollover_deduction(params) 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 @@ -155,11 +210,18 @@ local function process_rollover_deduction(params) local rollover_deducted = deduct_from_rollovers({ context = context, rollovers = rollovers, - amount = remaining_amount, + 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 @@ -274,11 +336,6 @@ local function run_deduction_on_context(params) update.adjustment = ent_data.adjustment or 0 update.additional_balance = 0 - - if ent_data.subject_balance - and type(ent_data.subject_balance.usage_windows) == 'table' then - update.usage_windows = ent_data.subject_balance.usage_windows - end end end diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindowUtilsV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindowUtilsV2.lua deleted file mode 100644 index 5cded80e7..000000000 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindowUtilsV2.lua +++ /dev/null @@ -1,237 +0,0 @@ --- ============================================================================ --- USAGE WINDOW UTILITIES (V2) --- Hard windowed usage-limit enforcement, evaluated at the orchestration layer --- against ACTUAL consumed amounts (post-deduction, pre-write). --- --- Counters live inline on the anchor cus_ent's subject_balance.usage_windows as --- a lean ARRAY of rows mirroring the usage_windows table (DbUsageWindow): --- { id, customer_entitlement_id, feature_id, internal_feature_id, --- window_start_at, window_end_at, usage, updated_at } --- A window is identified by (customer_entitlement_id, feature_id, --- window_start_at) -- the table's unique key. The current window is --- found-or-created; a rolled window has a different window_start_at, so its --- counter starts fresh at 0 and old windows are pruned. --- ============================================================================ - --- Tolerance for float drift (credit-ratio conversions leave sub-nano noise). -local USAGE_WINDOW_EPSILON = 1e-9 - -local function get_anchor_usage_windows(context, anchor_customer_entitlement_id) - if is_nil(anchor_customer_entitlement_id) then - return nil - end - - local ent_data = context.customer_entitlements[anchor_customer_entitlement_id] - if not ent_data or not ent_data.subject_balance then - return nil - end - - -- Reset a non-array blob to []: a legacy keyed-map blob (pre-array deploy) whose - -- string keys ipairs would skip and table.insert would corrupt into a - -- sync-breaking JSON object. The current window restarts at 0 (one-time cost). - local windows = ent_data.subject_balance.usage_windows - if type(windows) ~= 'table' - or (next(windows) ~= nil and windows[1] == nil) then - windows = new_empty_array() - ent_data.subject_balance.usage_windows = windows - end - - return windows -end - --- The array is one anchor cus_ent's rows, so customer_entitlement_id is implied; --- a window is the row matching (feature_id, window_start_at). -local function find_usage_window(windows, feature_id, window_start_at) - for _, window in ipairs(windows) do - if window.feature_id == feature_id - and safe_number(window.window_start_at) == window_start_at then - return window - end - end - return nil -end - --- Stable id matching the table's unique key, so the async sync upserts the same --- row each window rather than inserting duplicates. -local function build_usage_window_id(limit) - return limit.anchor_customer_entitlement_id - .. ':' .. limit.feature_id - .. ':' .. string.format('%.0f', limit.window_start_at) -end - --- Actually-consumed amount for a limit, in its native unit. metered_feature --- counts feature units (the tracked total); balance counts credits drained from --- the anchor pool (its `deducted`, which is in credits). -local function usage_window_consumed(params) - local limit = params.limit - local updates = params.updates - - if limit.dimension_type == 'balance' then - local anchor_update = updates[limit.anchor_customer_entitlement_id] - return anchor_update and safe_number(anchor_update.deducted) or 0 - end - - return safe_number(params.amount_to_deduct) - safe_number(params.remaining_amount) -end - --- Clamps metered-feature usage caps before deduction so over-cap tracks apply --- only the remaining headroom. Balance caps are credit-denominated, so unit --- clamping would need credit conversion; they stay on the post-deduction check. -local function clamp_amount_to_usage_windows(params) - local context = params.context - local limits = params.usage_window_limits or {} - local clamped_amount = safe_number(params.amount_to_deduct) - - for _, limit in ipairs(limits) do - local windows = get_anchor_usage_windows( - context, - limit.anchor_customer_entitlement_id - ) - if is_nil(windows) then - return { - amount_to_deduct = clamped_amount, - exceeded_feature_id = limit.feature_id, - } - end - - if limit.dimension_type ~= 'balance' then - local existing = find_usage_window( - windows, - limit.feature_id, - limit.window_start_at - ) - local current_usage = existing and safe_number(existing.usage) or 0 - local headroom = safe_number(limit.limit) - current_usage - if headroom < 0 then - headroom = 0 - end - - if clamped_amount > headroom then - clamped_amount = headroom - end - end - end - - return { - amount_to_deduct = clamped_amount, - exceeded_feature_id = nil, - } -end - --- Returns the feature_id of the first limit that would be exceeded (so the --- caller can hard-reject), or nil if every limit has room. Null/missing anchor --- fails closed: a cap that cannot resolve an owner must not silently allow. -local function check_usage_window_limits(params) - local context = params.context - local limits = params.usage_window_limits or {} - - for _, limit in ipairs(limits) do - local windows = get_anchor_usage_windows( - context, - limit.anchor_customer_entitlement_id - ) - if is_nil(windows) then - return limit.feature_id - end - - local consumed = usage_window_consumed({ - limit = limit, - updates = params.updates, - amount_to_deduct = params.amount_to_deduct, - remaining_amount = params.remaining_amount, - }) - - if consumed > USAGE_WINDOW_EPSILON then - local existing = find_usage_window( - windows, - limit.feature_id, - limit.window_start_at - ) - local current_usage = existing and safe_number(existing.usage) or 0 - if current_usage + consumed - > safe_number(limit.limit) + USAGE_WINDOW_EPSILON then - return limit.feature_id - end - end - end - - return nil -end - --- Applies the consumed amount to each anchor counter (find-or-create the current --- window row), prunes closed windows, and marks the anchor dirty so --- apply_pending_writes persists it. -local function increment_usage_window_counters(params) - local context = params.context - local limits = params.usage_window_limits or {} - local now = params.now - - for _, limit in ipairs(limits) do - local ent_data = - context.customer_entitlements[limit.anchor_customer_entitlement_id] - local windows = get_anchor_usage_windows( - context, - limit.anchor_customer_entitlement_id - ) - if not is_nil(windows) then - -- Rebuild (rather than nil-out) so the array stays hole-free and cjson - -- re-encodes it as [] not {}; prune every pass so it never grows for - -- sporadically-active features. - local kept = new_empty_array() - local pruned = false - for _, window in ipairs(windows) do - if type(window) == 'table' - and safe_number(window.window_end_at) < now then - pruned = true - else - table.insert(kept, window) - end - end - if pruned then - ent_data.subject_balance.usage_windows = kept - windows = kept - end - - local consumed = usage_window_consumed({ - limit = limit, - updates = params.updates, - amount_to_deduct = params.amount_to_deduct, - remaining_amount = params.remaining_amount, - }) - - if consumed > USAGE_WINDOW_EPSILON then - local existing = find_usage_window( - windows, - limit.feature_id, - limit.window_start_at - ) - if is_nil(existing) then - existing = { - id = build_usage_window_id(limit), - customer_entitlement_id = limit.anchor_customer_entitlement_id, - feature_id = limit.feature_id, - internal_feature_id = limit.internal_feature_id, - window_start_at = limit.window_start_at, - window_end_at = limit.window_end_at, - usage = 0, - updated_at = now, - } - table.insert(windows, existing) - end - - existing.usage = safe_number(existing.usage) + consumed - existing.updated_at = now - - mark_customer_entitlement_for_update( - context, - limit.anchor_customer_entitlement_id - ) - elseif pruned then - mark_customer_entitlement_for_update( - context, - limit.anchor_customer_entitlement_id - ) - end - end - end -end diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindows/readUsageWindows.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindows/readUsageWindows.lua new file mode 100644 index 000000000..dd09f2ae8 --- /dev/null +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindows/readUsageWindows.lua @@ -0,0 +1,101 @@ +-- ============================================================================ +-- READ USAGE WINDOWS +-- Loads customer-scoped usage-window counter state into the deduction context +-- (sibling of read_subject_balances). Counters live in the capped feature's +-- balance hash under the reserved '_usage_windows' field, as a lean ARRAY of +-- rows mirroring the usage_windows table (DbUsageWindow): +-- { id, internal_customer_id, internal_entity_id, feature_id, +-- internal_feature_id, anchor_customer_entitlement_id, +-- window_start_at, window_end_at, usage, updated_at } +-- +-- Each loaded entry also carries the deduction-time runtime state the per-ent +-- gate consumes: the resolved limit, its dimension, the remaining `headroom` +-- (limit - current window usage, decremented as the deduction passes drain +-- it), and `consumed` (this operation's total, in the limit's native unit). +-- +-- FAIL OPEN: a missing/undecodable field (or an undeclared balance key) loads +-- as an empty counter set -- the window simply restarts. Stale-cache guards +-- may return in a future iteration. +-- ============================================================================ + +local USAGE_WINDOWS_FIELD = '_usage_windows' + +-- ONE mutable counter row per scope: a row matches its limit on +-- internal_entity_id alone. Bounds are payload, not identity. +local function find_usage_window(windows, limit) + local limit_entity = limit.internal_entity_id + for _, window in ipairs(windows) do + if type(window) == 'table' then + local window_entity = window.internal_entity_id + local entities_match = + (is_nil(limit_entity) and is_nil(window_entity)) + or limit_entity == window_entity + if entities_match then + return window + end + end + end + return nil +end + +-- Returns { [feature_id] = { balance_key, windows, dirty, limit, +-- dimension_type, headroom, consumed } }, one entry per distinct capped +-- feature in usage_window_limits. +local function read_usage_windows(params) + local limits = params.usage_window_limits or {} + local balance_keys_by_feature_id = + safe_table(params.balance_keys_by_feature_id) + local usage_windows = {} + + for _, limit in ipairs(limits) do + local feature_id = limit.feature_id + if usage_windows[feature_id] == nil then + local balance_key = balance_keys_by_feature_id[feature_id] + local windows = nil + + if not is_nil(balance_key) then + local raw_value = redis.call('HGET', balance_key, USAGE_WINDOWS_FIELD) + windows = safe_decode(raw_value) + end + + if type(windows) ~= 'table' then + windows = new_empty_array() + end + + -- cjson decodes an empty JSON object ({}) to the same empty table as []; + -- a non-empty map-like blob should be impossible for this field, but + -- reset it defensively rather than letting ipairs skip rows silently. + if next(windows) ~= nil and windows[1] == nil then + windows = new_empty_array() + end + + local existing = find_usage_window(windows, limit) + -- A count is valid only within its exact stamped window: derive 0 when + -- it expired OR its bounds no longer match the current derivation (the + -- lazy roll persists the zero; this read must not trust it blindly). + local current_usage = 0 + if not is_nil(existing) + and safe_number(existing.window_end_at) > safe_number(params.now) + and safe_number(existing.window_start_at) == limit.window_start_at + then + current_usage = safe_number(existing.usage) + end + local headroom = safe_number(limit.limit) - current_usage + if headroom < 0 then + headroom = 0 + end + + usage_windows[feature_id] = { + balance_key = not is_nil(balance_key) and balance_key or nil, + windows = windows, + dirty = false, + limit = limit, + dimension_type = limit.dimension_type, + headroom = headroom, + consumed = 0, + } + end + end + + return usage_windows +end diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindows/usageWindowContextUtilsV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindows/usageWindowContextUtilsV2.lua new file mode 100644 index 000000000..4fb76381c --- /dev/null +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/usageWindows/usageWindowContextUtilsV2.lua @@ -0,0 +1,301 @@ +-- ============================================================================ +-- USAGE WINDOW CONTEXT UTILITIES (V2) +-- Hard windowed usage-limit enforcement against context.usage_windows (loaded +-- by init_context via read_usage_windows), integrated into the deduction +-- passes the same way spend limits are: +-- per-ent gate (get_available_from_usage_windows, in the deduction loop) +-- -> consume headroom as each ent drains (consume_usage_window_headroom) +-- -> update_in_memory_usage_window (mark dirty) -> apply_usage_window_writes. +-- A window-capped leftover is handled by the standard overage_behaviour path +-- ('cap' applies the partial deduction, 'reject' returns INSUFFICIENT_BALANCE) +-- -- no window-specific error. +-- +-- CONFIG IN: usage_window_limits[] -- resolved caps (limit, bounds, +-- dimension) from fullSubjectToUsageWindowLimits. +-- COUNTERS OUT: context.usage_windows[feature_id].windows -- DbUsageWindow +-- rows (usage amounts), NOT the config. +-- +-- Units: the deduction loop works in TRACKED-FEATURE UNITS; each ent's +-- credit_cost converts them to that ent's balance units. A metered_feature +-- limit counts tracked units (applies to every ent in the deduction set); a +-- balance limit counts credits drained from ents OF the capped feature, so +-- headroom converts via credit_cost at the gate. +-- ============================================================================ + +-- Tolerance for float drift (credit-ratio conversions leave sub-nano noise). +local USAGE_WINDOW_EPSILON = 1e-9 + +-- Max tracked units deductible from ONE ent given every applicable window +-- limit, or nil when unbounded. Windows never store a conversion -- headroom +-- lives in the limit's own unit (tracked units for metered dims, credits for +-- balance dims) and is converted HERE, per call, with the calling ent's +-- credit_cost: the same balance-dim headroom yields different unit allowances +-- for ents with different credit ratios, and only ents OF the capped feature +-- are bound by it at all. Metered dims need no conversion (the deduction loop +-- is denominated in tracked units, whatever pool funds them). +-- +-- Pass ent_feature_id = nil for the rollover phase: metered limits still +-- apply (rollover drains consume tracked units), balance limits do not +-- (parity with spend limits, whose overage math also ignores rollover +-- drains). +local function get_available_from_usage_windows(params) + local context = params.context + local ent_feature_id = params.ent_feature_id + local credit_cost = params.credit_cost or 1 + local allowed = nil + + for feature_id, feature_windows in pairs(context.usage_windows or {}) do + local headroom = feature_windows.headroom + if headroom <= USAGE_WINDOW_EPSILON then + headroom = 0 + end + + local units = nil + if feature_windows.dimension_type ~= 'balance' then + units = headroom + elseif not is_nil(ent_feature_id) and feature_id == ent_feature_id then + units = headroom / credit_cost + end + + if units ~= nil and (allowed == nil or units < allowed) then + allowed = units + end + end + + return allowed +end + +-- Records `units` tracked units drained from an ent against every applicable +-- limit: metered limits consume units 1:1, balance limits consume +-- units * credit_cost (credits). Decrements live headroom so the next ent's +-- gate sees it, and accumulates `consumed` for the counter increment. +local function consume_usage_window_headroom(params) + local context = params.context + local ent_feature_id = params.ent_feature_id + local credit_cost = params.credit_cost or 1 + local units = params.units or 0 + + if units <= 0 then + return + end + + for feature_id, feature_windows in pairs(context.usage_windows or {}) do + local consumed = nil + if feature_windows.dimension_type ~= 'balance' then + consumed = units + elseif not is_nil(ent_feature_id) and feature_id == ent_feature_id then + consumed = units * credit_cost + end + + if consumed ~= nil and consumed > 0 then + feature_windows.headroom = feature_windows.headroom - consumed + if feature_windows.headroom < 0 then + feature_windows.headroom = 0 + end + feature_windows.consumed = feature_windows.consumed + consumed + end + end +end + +-- Sibling of append_mutation_log: records which window row moved and by how +-- much (usage_delta in the limit's native unit). Kept as its own stream so +-- mutation_logs stays entitlement/rollover-shaped. +local function append_usage_window_mutation(params) + local context = params.context + table.insert(context.usage_window_mutations, { + usage_window_id = params.usage_window_id or cjson.null, + feature_id = params.feature_id, + internal_entity_id = params.internal_entity_id or cjson.null, + window_start_at = params.window_start_at, + usage_delta = params.usage_delta or 0, + }) +end + +-- In-memory mutation for one limit (sibling of +-- update_in_memory_customer_entitlement_mutation). ONE mutable row per scope: +-- zero the count if its stored window closed (defensive guard -- the lazy +-- roll action owns the roll), stamp the current bounds/anchor, add consumed. +local function update_in_memory_usage_window(params) + local context = params.context + local limit = params.limit + local now = params.now + + local feature_windows = context.usage_windows[limit.feature_id] + if feature_windows == nil then + return + end + + if feature_windows.consumed > USAGE_WINDOW_EPSILON then + local existing = find_usage_window(feature_windows.windows, limit) + if is_nil(existing) then + -- The TS-minted candidate id is used ONLY at creation; under concurrency + -- the second request finds the first one's row and its id is discarded. + existing = { + id = limit.new_window_id, + internal_customer_id = limit.internal_customer_id, + internal_entity_id = limit.internal_entity_id, + feature_id = limit.feature_id, + internal_feature_id = limit.internal_feature_id, + usage = 0, + } + table.insert(feature_windows.windows, existing) + elseif safe_number(existing.window_end_at) <= now + or safe_number(existing.window_start_at) ~= limit.window_start_at + then + -- A count never survives its stamped window: zero on expiry AND on any + -- bounds re-derivation mismatch (plan change). + existing.usage = 0 + end + + existing.window_start_at = limit.window_start_at + existing.window_end_at = limit.window_end_at + existing.anchor_customer_entitlement_id = + limit.anchor_customer_entitlement_id + existing.usage = safe_number(existing.usage) + feature_windows.consumed + existing.updated_at = now + feature_windows.dirty = true + + append_usage_window_mutation({ + context = context, + usage_window_id = existing.id, + feature_id = limit.feature_id, + internal_entity_id = limit.internal_entity_id, + window_start_at = limit.window_start_at, + usage_delta = feature_windows.consumed, + }) + end +end + +-- Applies each limit's in-flight consumed amount to its counter row. +-- Mirrors a lock UNWIND onto the counters: each applied unwind iteration +-- frees window headroom (metered dims by tracked units, balance dims by the +-- credits restored to that feature's entitlements). Clamped at 0; only the +-- limit's CURRENT window is decremented (a roll between lock and unwind +-- forfeits the old window's count, which is the conservative outcome). +local function decrement_usage_windows_for_unwind(params) + local context = params.context + local iterations = safe_table(params.iterations) + local now = params.now + + if is_nil(context.usage_windows) or #iterations == 0 then + return + end + + local total_units = 0 + local credits_by_feature_id = {} + + for _, iteration in ipairs(iterations) do + local units = safe_number(iteration.unwind_iteration_value) + total_units = total_units + units + + local item = iteration.item or {} + local ent_feature_id = nil + local ent = context.customer_entitlements[item.customer_entitlement_id] + if ent then + ent_feature_id = ent.feature_id + elseif item.rollover_id and context.rollovers[item.rollover_id] then + local rollover_ent = context.customer_entitlements[ + context.rollovers[item.rollover_id].cus_ent_id + ] + if rollover_ent then + ent_feature_id = rollover_ent.feature_id + end + end + + if ent_feature_id then + local credits = units * safe_number(item.credit_cost or 1) + credits_by_feature_id[ent_feature_id] = + (credits_by_feature_id[ent_feature_id] or 0) + credits + end + end + + for feature_id, feature_windows in pairs(context.usage_windows) do + local amount = 0 + if feature_windows.dimension_type == 'balance' then + amount = credits_by_feature_id[feature_id] or 0 + else + amount = total_units + end + + if amount > 0 then + local existing = find_usage_window( + feature_windows.windows, + feature_windows.limit + ) + if not is_nil(existing) then + local current = safe_number(existing.usage) + local next_usage = current - amount + if next_usage < 0 then + next_usage = 0 + end + + if next_usage ~= current then + existing.usage = next_usage + existing.updated_at = now + feature_windows.dirty = true + + append_usage_window_mutation({ + context = context, + usage_window_id = existing.id, + feature_id = feature_windows.limit.feature_id, + internal_entity_id = feature_windows.limit.internal_entity_id, + window_start_at = feature_windows.limit.window_start_at, + usage_delta = next_usage - current, + }) + end + end + end + end +end + +local function increment_usage_window_counters(params) + local context = params.context + local limits = params.usage_window_limits or {} + + for _, limit in ipairs(limits) do + update_in_memory_usage_window({ + context = context, + limit = limit, + now = params.now, + }) + end +end + +-- Persists dirty counter arrays back to their '_usage_windows' fields +-- (sibling of apply_pending_writes; direct HSET like updateAggregatedBalances +-- since the cusEnt pending-write path is keyed by entitlement blobs). +-- The EXPIRE guard is load-bearing under fail-open: a write to a hash that +-- did not exist (capped feature with no entitlements and no rebuild yet) +-- must not create an immortal key. +local function apply_usage_window_writes(context, ttl_seconds) + local ttl = tonumber(ttl_seconds) + + for _, feature_windows in pairs(context.usage_windows or {}) do + if feature_windows.dirty and not is_nil(feature_windows.balance_key) then + redis.call( + 'HSET', + feature_windows.balance_key, + USAGE_WINDOWS_FIELD, + cjson.encode(feature_windows.windows) + ) + if ttl and ttl > 0 + and redis.call('TTL', feature_windows.balance_key) < 0 then + redis.call('EXPIRE', feature_windows.balance_key, ttl) + end + end + end +end + +-- Result payload: { [feature_id] = windows[] } for every loaded capped +-- feature, so the TS caller can refresh the in-flight subject and hand the +-- post-deduction counters to syncItemV4 (no Redis re-read). +local function usage_windows_to_result(context) + local result = nil + for feature_id, feature_windows in pairs(context.usage_windows or {}) do + if result == nil then + result = {} + end + result[feature_id] = feature_windows.windows + end + return result +end diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index 93d91c7b3..fedf8f45c 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -44,12 +44,14 @@ import READ_SUBJECT_BALANCES from "./fullSubjectDeduction/readSubjectBalances.lu import RUN_DEDUCTION_ON_CONTEXT_V2 from "./fullSubjectDeduction/runDeductionOnContextV2.lua"; import SPEND_LIMIT_UTILS_V2 from "./fullSubjectDeduction/spendLimitUtilsV2.lua"; import UPDATE_AGGREGATED_BALANCES from "./fullSubjectDeduction/updateAggregatedBalances.lua"; -import USAGE_WINDOW_UTILS_V2 from "./fullSubjectDeduction/usageWindowUtilsV2.lua"; +import READ_USAGE_WINDOWS from "./fullSubjectDeduction/usageWindows/readUsageWindows.lua"; +import USAGE_WINDOW_CONTEXT_UTILS_V2 from "./fullSubjectDeduction/usageWindows/usageWindowContextUtilsV2.lua"; // ============================================================================ // UPDATE SUBJECT BALANCES HELPERS (V2 cache — per-feature hash updates) // ============================================================================ +import ROLL_USAGE_WINDOWS_MAIN from "./fullSubject/rollUsageWindows/rollUsageWindows.lua"; import APPLY_FIELD_UPDATES from "./fullSubject/updateSubjectBalances/applyFieldUpdates.lua"; import UPDATE_CONTEXT_UTILS from "./fullSubject/updateSubjectBalances/updateContextUtils.lua"; import UPDATE_SUBJECT_BALANCES_MAIN from "./fullSubject/updateSubjectBalances/updateSubjectBalances.lua"; @@ -201,12 +203,13 @@ export const UPDATE_CUSTOMER_PRODUCT_SCRIPT = */ export const DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT = `${LUA_UTILS} ${READ_SUBJECT_BALANCES} +${READ_USAGE_WINDOWS} ${CONTEXT_UTILS_V2} ${GET_TOTAL_BALANCE} ${DEDUCT_FROM_ROLLOVERS_V2} ${DEDUCT_FROM_MAIN_BALANCE_V2} ${SPEND_LIMIT_UTILS_V2} -${USAGE_WINDOW_UTILS_V2} +${USAGE_WINDOW_CONTEXT_UTILS_V2} ${RUN_DEDUCTION_ON_CONTEXT_V2} ${MUTATION_ITEM_UTILS} ${LOCK_RECEIPT_UTILS_V2} @@ -240,3 +243,11 @@ ${UPDATE_CONTEXT_UTILS} ${APPLY_FIELD_UPDATES} ${UPDATE_AGGREGATED_BALANCES} ${UPDATE_SUBJECT_BALANCES_MAIN}`; + +/** + * Lua script for atomically rolling usage-window counters in a per-feature + * balance hash's '_usage_windows' field (zero expired counts, advance + * bounds/anchor). Called once per feature via pipeline by the lazy roll. + */ +export const ROLL_USAGE_WINDOWS_SCRIPT = `${LUA_UTILS} +${ROLL_USAGE_WINDOWS_MAIN}`; diff --git a/server/src/external/redis/initUtils/createRedisAvailability.ts b/server/src/external/redis/initUtils/createRedisAvailability.ts index 98a86f506..c6834f765 100644 --- a/server/src/external/redis/initUtils/createRedisAvailability.ts +++ b/server/src/external/redis/initUtils/createRedisAvailability.ts @@ -114,16 +114,12 @@ export const createRedisAvailability = ({ } const shouldReconnectReadyClient = - failedWhileReady && - consecutiveFailures + 1 >= REDIS_FAILURES_TO_DEGRADE; + failedWhileReady && consecutiveFailures + 1 >= REDIS_FAILURES_TO_DEGRADE; if (shouldReconnectReadyClient) { await reconnectRedis(); } else if (redis.status !== "ready") { - if ( - redis.status === "connecting" || - redis.status === "reconnecting" - ) { + if (redis.status === "connecting" || redis.status === "reconnecting") { reconnectStartedAt ??= Date.now(); if (Date.now() - reconnectStartedAt < REDIS_STALE_RECONNECT_MS) { return false; @@ -149,10 +145,7 @@ export const createRedisAvailability = ({ return { prime: async () => { if (!hasConfig) return; - if ( - redis.status === "connecting" || - redis.status === "reconnecting" - ) { + if (redis.status === "connecting" || redis.status === "reconnecting") { await waitForRedisReady(redis, logPrefix).catch(() => undefined); } const available = await probeRedisAvailability(); @@ -174,8 +167,7 @@ export const createRedisAvailability = ({ clearInterval(redisMonitorInterval); redisMonitorInterval = null; }, - shouldUseRedis: () => - hasConfig && redisAvailabilityState === "healthy", + shouldUseRedis: () => hasConfig && redisAvailabilityState === "healthy", getRedisAvailability: (): RedisAvailabilitySnapshot => ({ configured: hasConfig, state: redisAvailabilityState, diff --git a/server/src/external/redis/initUtils/redisAvailability.ts b/server/src/external/redis/initUtils/redisAvailability.ts index d584b4068..8d79eb9bf 100644 --- a/server/src/external/redis/initUtils/redisAvailability.ts +++ b/server/src/external/redis/initUtils/redisAvailability.ts @@ -1,8 +1,8 @@ -import { redis } from "./redisClientRegistry.js"; import { createRedisAvailability, type RedisAvailabilitySnapshot, } from "./createRedisAvailability.js"; +import { redis } from "./redisClientRegistry.js"; import { hasRedisConfig } from "./redisConfig.js"; const redisAvailability = createRedisAvailability({ diff --git a/server/src/external/redis/initUtils/redisTypes.ts b/server/src/external/redis/initUtils/redisTypes.ts index 69c6b6963..724c8b158 100644 --- a/server/src/external/redis/initUtils/redisTypes.ts +++ b/server/src/external/redis/initUtils/redisTypes.ts @@ -92,6 +92,7 @@ declare module "ioredis" { balanceKey: string, paramsJson: string, ): Promise; + rollUsageWindows(balanceKey: string, paramsJson: string): Promise; deleteFullCustomerCache( cacheKey: string, testGuardKey: string, diff --git a/server/src/external/redis/initUtils/registerRedisCommands.ts b/server/src/external/redis/initUtils/registerRedisCommands.ts index d894a75f3..41131a746 100644 --- a/server/src/external/redis/initUtils/registerRedisCommands.ts +++ b/server/src/external/redis/initUtils/registerRedisCommands.ts @@ -22,6 +22,7 @@ import { DEDUCT_FROM_SUBJECT_BALANCES_SCRIPT, DELETE_FULL_CUSTOMER_CACHE_SCRIPT, RESET_CUSTOMER_ENTITLEMENTS_SCRIPT, + ROLL_USAGE_WINDOWS_SCRIPT, SET_CACHED_FULL_SUBJECT_SCRIPT, SET_FULL_CUSTOMER_CACHE_SCRIPT, UPDATE_CACHED_INVOICE_V2_SCRIPT, @@ -127,6 +128,11 @@ export const registerRedisCommands = ({ lua: prepareScript(UPDATE_SUBJECT_BALANCES_SCRIPT), }); + redisInstance.defineCommand("rollUsageWindows", { + numberOfKeys: 1, + lua: prepareScript(ROLL_USAGE_WINDOWS_SCRIPT), + }); + redisInstance.defineCommand("deleteFullCustomerCache", { numberOfKeys: 4, lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT, diff --git a/server/src/internal/balances/check/getCheckResponseV2.ts b/server/src/internal/balances/check/getCheckResponseV2.ts index 1172b811a..eb086be48 100644 --- a/server/src/internal/balances/check/getCheckResponseV2.ts +++ b/server/src/internal/balances/check/getCheckResponseV2.ts @@ -55,6 +55,7 @@ export const getCheckResponseV2 = async ({ apiSubject: evaluationApiSubject, feature: featureToUse, requiredBalance, + originalFeature, }).allowed : false; diff --git a/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts b/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts index 07cdaf54f..7f84b3f13 100644 --- a/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts +++ b/server/src/internal/balances/finalizeLock/runRedisFinalizeLockV2.ts @@ -41,13 +41,20 @@ export const runRedisFinalizeLockV2 = async ({ throw error; } - const { updates, rolloverUpdates, modifiedCusEntIdsByFeatureId } = - redisResult; + const { + updates, + rolloverUpdates, + modifiedCusEntIdsByFeatureId, + usageWindowUpdates, + } = redisResult; const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); const rolloverIds = Object.keys(rolloverUpdates); - if (modifiedCusEntIds.length > 0 || rolloverIds.length > 0) { - + if ( + modifiedCusEntIds.length > 0 || + rolloverIds.length > 0 || + usageWindowUpdates.length > 0 + ) { globalSyncBatchingManagerV3.addSyncItem({ customerId: receipt.customer_id, orgId: ctx.org.id, @@ -57,6 +64,7 @@ export const runRedisFinalizeLockV2 = async ({ region: currentRegion, entityId: receipt.entity_id ?? undefined, modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }); } diff --git a/server/src/internal/balances/track/v3/handleRedisTrackErrorV3.ts b/server/src/internal/balances/track/v3/handleRedisTrackErrorV3.ts index 62b79af28..8277bae00 100644 --- a/server/src/internal/balances/track/v3/handleRedisTrackErrorV3.ts +++ b/server/src/internal/balances/track/v3/handleRedisTrackErrorV3.ts @@ -5,7 +5,6 @@ import { RecaseError, type TrackParams, type TrackResponseV3, - UsageLimitExceededError, } from "@autumn/shared"; import { RedisUnavailableError } from "@/external/redis/utils/errors.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -40,12 +39,6 @@ export const handleRedisTrackErrorV3 = async ({ }); } - if (error.code === RedisDeductionErrorCode.UsageLimitExceeded) { - throw new UsageLimitExceededError({ - featureId: error.featureId ?? body.feature_id, - }); - } - if (error.code === RedisDeductionErrorCode.LockAlreadyExists) { throw new RecaseError({ message: "A lock with this ID already exists", diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index 577ea1150..234fe1898 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -1,26 +1,26 @@ import type { - FullSubject, - TrackDeduction, - TrackParams, - TrackResponseV3, + FullSubject, + TrackDeduction, + TrackParams, + TrackResponseV3, } from "@autumn/shared"; import { tryCatch } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { globalEventBatchingManager } from "@/internal/balances/events/EventBatchingManager.js"; +import { + buildEventInfo, + initEvent, +} from "@/internal/balances/events/initEvent.js"; import { resolveInternalProductIdForEvent } from "@/internal/balances/events/resolveInternalProductIdForEvent.js"; import { - buildEventInfo, - initEvent, -} from "@/internal/balances/events/initEvent.js"; -import { - deductionToTrackResponseV2, - executeRedisDeductionV2, - projectMutationLogsToTrackDeductionsV2, + deductionToTrackResponseV2, + executeRedisDeductionV2, + projectMutationLogsToTrackDeductionsV2, } from "@/internal/balances/utils/deductionV2/index.js"; import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js"; -import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js"; +import type { UsageWindowUpdate } from "../../utils/types/usageWindowUpdate.js"; import { handleRedisTrackErrorV3 } from "./handleRedisTrackErrorV3.js"; const queueSyncItem = ({ @@ -29,18 +29,25 @@ const queueSyncItem = ({ fullSubject, rolloverUpdates, modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }: { ctx: AutumnContext; body: TrackParams; fullSubject: FullSubject; rolloverUpdates: Record; modifiedCusEntIdsByFeatureId: Record; + usageWindowUpdates?: UsageWindowUpdate[]; }): void => { const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat(); const rolloverIds = Object.keys(rolloverUpdates); - if (cusEntIds.length === 0 && rolloverIds.length === 0) return; - + if ( + cusEntIds.length === 0 && + rolloverIds.length === 0 && + (usageWindowUpdates?.length ?? 0) === 0 + ) { + return; + } globalSyncBatchingManagerV3.addSyncItem({ customerId: body.customer_id, @@ -50,6 +57,7 @@ const queueSyncItem = ({ rolloverIds, entityId: fullSubject.entityId, modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }); }; @@ -129,36 +137,17 @@ export const runRedisTrackV3 = async ({ rolloverUpdates, modifiedCusEntIdsByFeatureId, mutationLogs, + usageWindowUpdates, } = result; - // Write the cap counter through to PG now; a later mutation rebuilds the cache from it. - const hasUsageCap = (fullSubject.customer.spend_limits ?? []).some( - (limit) => limit.usage_limit != null, - ); - if (hasUsageCap) { - await tryCatch( - syncItemV4({ - ctx, - payload: { - customerId: body.customer_id, - orgId: ctx.org.id, - env: ctx.env, - timestamp: Date.now(), - entityId: updatedFullSubject.entityId, - rolloverIds: Object.keys(rolloverUpdates), - modifiedCusEntIdsByFeatureId, - }, - }), - ); - } else { - queueSyncItem({ - ctx, - body, - fullSubject: updatedFullSubject, - rolloverUpdates, - modifiedCusEntIdsByFeatureId, - }); - } + queueSyncItem({ + ctx, + body, + fullSubject: updatedFullSubject, + rolloverUpdates, + modifiedCusEntIdsByFeatureId, + usageWindowUpdates, + }); const deductions = projectMutationLogsToTrackDeductionsV2({ fullSubject: updatedFullSubject, diff --git a/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts b/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts index 2d9318428..1123eba5b 100644 --- a/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts +++ b/server/src/internal/balances/updateBalance/v2/updateRemainingV2.ts @@ -67,11 +67,16 @@ export const updateRemainingV2 = async ({ }); } - const { rolloverUpdates, modifiedCusEntIdsByFeatureId } = result; + const { rolloverUpdates, modifiedCusEntIdsByFeatureId, usageWindowUpdates } = + result; const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat(); const rolloverIds = Object.keys(rolloverUpdates); - if (cusEntIds.length > 0 || rolloverIds.length > 0) { + if ( + cusEntIds.length > 0 || + rolloverIds.length > 0 || + usageWindowUpdates.length > 0 + ) { await syncItemV4({ ctx, payload: { @@ -82,6 +87,7 @@ export const updateRemainingV2 = async ({ rolloverIds, entityId: fullSubject.entityId, modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }, }); } diff --git a/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts b/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts index aa0a11e56..cc189a066 100644 --- a/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts +++ b/server/src/internal/balances/updateBalance/v2/updateUsageV2.ts @@ -111,11 +111,16 @@ export const updateUsageV2 = async ({ }); } - const { rolloverUpdates, modifiedCusEntIdsByFeatureId } = result; + const { rolloverUpdates, modifiedCusEntIdsByFeatureId, usageWindowUpdates } = + result; const cusEntIds = Object.values(modifiedCusEntIdsByFeatureId).flat(); const rolloverIds = Object.keys(rolloverUpdates); - if (cusEntIds.length > 0 || rolloverIds.length > 0) { + if ( + cusEntIds.length > 0 || + rolloverIds.length > 0 || + usageWindowUpdates.length > 0 + ) { await syncItemV4({ ctx, payload: { @@ -126,6 +131,7 @@ export const updateUsageV2 = async ({ rolloverIds, entityId: fullSubject.entityId, modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }, }); } diff --git a/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts b/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts index 26de4db00..4a4fe11a7 100644 --- a/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts +++ b/server/src/internal/balances/utils/deductionV2/applyDeductionUpdateToFullSubject.ts @@ -45,7 +45,6 @@ const applyUpdate = ({ additional_balance: update.additional_balance, adjustment: update.adjustment, entities: update.entities, - usage_windows: update.usage_windows ?? customerEntitlement.usage_windows, replaceables: getUpdatedReplaceables({ replaceables: customerEntitlement.replaceables, update, diff --git a/server/src/internal/balances/utils/deductionV2/applyUsageWindowUpdatesToFullSubject.ts b/server/src/internal/balances/utils/deductionV2/applyUsageWindowUpdatesToFullSubject.ts new file mode 100644 index 000000000..c33679a6e --- /dev/null +++ b/server/src/internal/balances/utils/deductionV2/applyUsageWindowUpdatesToFullSubject.ts @@ -0,0 +1,36 @@ +import type { FullSubject, UsageWindow } from "@autumn/shared"; + +/** + * Refresh the in-flight subject's customer-scoped usage-window counters from + * the deduction result, so usage_limit_used (webhooks, API responses built + * from this subject) reflects the deduction. Sibling of + * applyDeductionUpdateToFullSubject / applyRolloverUpdatesToFullSubject. + * + * The Lua result carries ALL scopes; the subject keeps its own scope only + * (entity subjects hold just their entity's rows). + */ +export const applyUsageWindowUpdatesToFullSubject = ({ + fullSubject, + usageWindowsByFeatureId, +}: { + fullSubject: FullSubject; + usageWindowsByFeatureId: Record | null | undefined; +}): void => { + if (!usageWindowsByFeatureId) return; + + const updatedFeatureIds = new Set(Object.keys(usageWindowsByFeatureId)); + const updatedWindows = Object.values(usageWindowsByFeatureId) + .flat() + .filter((usageWindow) => + fullSubject.internalEntityId + ? usageWindow.internal_entity_id === fullSubject.internalEntityId + : true, + ); + + fullSubject.usage_windows = [ + ...(fullSubject.usage_windows ?? []).filter( + (usageWindow) => !updatedFeatureIds.has(usageWindow.feature_id), + ), + ...updatedWindows, + ]; +}; diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index c0c67a96c..d9a3169c9 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -17,6 +17,7 @@ import { createAllocatedInvoice } from "@/internal/balances/utils/allocatedInvoi import { saveLockReceiptV2 } from "@/internal/balances/utils/lockV2/saveLockReceiptV2.js"; import { buildDeductFromSubjectBalancesKeys } from "@/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.js"; import { buildFullSubjectKey } from "@/internal/customers/cache/fullSubject/builders/buildFullSubjectKey.js"; +import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import type { DeductionOptions } from "../types/deductionTypes.js"; import type { DeductionUpdate } from "../types/deductionUpdate.js"; @@ -28,8 +29,11 @@ import { } from "../types/redisDeductionError.js"; import type { LuaDeductionResult } from "../types/redisDeductionResult.js"; import type { RolloverUpdate } from "../types/rolloverUpdate.js"; +import type { UsageWindowMutation } from "../types/usageWindowMutation.js"; +import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js"; import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js"; import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js"; +import { applyUsageWindowUpdatesToFullSubject } from "./applyUsageWindowUpdatesToFullSubject.js"; import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js"; import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js"; import { normalizeDeductionSyncStateV2 } from "./normalizeDeductionSyncStateV2.js"; @@ -60,6 +64,8 @@ export const executeRedisDeductionV2 = async ({ rolloverUpdates: Record; mutationLogs: MutationLogItem[]; modifiedCusEntIdsByFeatureId: Record; + usageWindowUpdates: UsageWindowUpdate[]; + usageWindowMutations: UsageWindowMutation[]; }> => { const { org, env } = ctx; const oldFullSubject = structuredClone(fullSubject); @@ -95,7 +101,11 @@ export const executeRedisDeductionV2 = async ({ let allUpdates: Record = {}; let allRolloverUpdates: Record = {}; let allMutationLogs: MutationLogItem[] = []; + let allUsageWindowMutations: UsageWindowMutation[] = []; const allModifiedCusEntIdsByFeatureId: Record = {}; + // Keyed by feature id: each Lua result carries the COMPLETE post-deduction + // counter array per capped feature, so last write wins across deductions. + const allUsageWindowUpdates: Record = {}; const customerId = fullSubject.customerId; const routingKey = buildFullSubjectKey({ @@ -123,6 +133,7 @@ export const executeRedisDeductionV2 = async ({ spendLimitByFeatureId, usageBasedCusEntIdsByFeatureId, usageWindowLimits, + usageWindowFeatureIds, rollovers, customerEntitlements, unlimitedFeatureIds, @@ -178,16 +189,6 @@ export const executeRedisDeductionV2 = async ({ }).redisKey : null; - // Anchor features own usage-window counters and may not be in the - // deduction set, so their balance hash keys must be declared too. - const anchorFeatureIds = [ - ...new Set( - (usageWindowLimits ?? []) - .map((limit) => limit.anchor_feature_id) - .filter((featureId): featureId is string => featureId !== null), - ), - ]; - const { keys, balanceKeyIndexByFeatureId } = buildDeductFromSubjectBalancesKeys({ orgId: org.id, @@ -198,7 +199,7 @@ export const executeRedisDeductionV2 = async ({ idempotencyKey: idempotencyRedisKey, customerEntitlementDeductions, fallbackFeatureId: feature.id, - anchorFeatureIds, + usageWindowFeatureIds, }); // Usage windows are enforced/incremented only for real positive @@ -220,6 +221,7 @@ export const executeRedisDeductionV2 = async ({ usageBasedCusEntIdsByFeatureId ?? null, usage_window_limits: usageWindowLimits ?? null, usage_window_now: usageWindowNow, + usage_window_ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS, is_consumption: isConsumption, amount_to_deduct: toDeduct ?? null, target_balance: targetBalance ?? null, @@ -280,6 +282,11 @@ export const executeRedisDeductionV2 = async ({ const mutationLogs = Array.isArray(resultJson.mutation_logs) ? resultJson.mutation_logs : []; + const usageWindowMutations = Array.isArray( + resultJson.usage_window_mutations, + ) + ? resultJson.usage_window_mutations + : []; const modifiedCustomerEntitlementIds = Array.isArray( resultJson.modified_customer_entitlement_ids, ) @@ -296,6 +303,21 @@ export const executeRedisDeductionV2 = async ({ allUpdates = { ...allUpdates, ...updates }; allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates }; allMutationLogs = [...allMutationLogs, ...mutationLogs]; + allUsageWindowMutations = [ + ...allUsageWindowMutations, + ...usageWindowMutations, + ]; + // Typed handoff for the PG mirror; empty arrays kept (prune-to-empty + // must still full-replace). + for (const [featureId, usageWindows] of Object.entries( + resultJson.usage_windows_by_feature_id ?? {}, + )) { + allUsageWindowUpdates[featureId] = { + internal_customer_id: fullSubject.internalCustomerId, + feature_id: featureId, + usage_windows: usageWindows, + }; + } const syncState = normalizeDeductionSyncStateV2({ customerEntitlements, @@ -320,6 +342,11 @@ export const executeRedisDeductionV2 = async ({ rolloverUpdates: rollover_updates, }); + applyUsageWindowUpdatesToFullSubject({ + fullSubject, + usageWindowsByFeatureId: resultJson.usage_windows_by_feature_id, + }); + for (const customerEntitlementId of Object.keys(updates)) { const update = updates[customerEntitlementId]; const customerEntitlement = customerEntitlements.find( @@ -394,5 +421,7 @@ export const executeRedisDeductionV2 = async ({ rolloverUpdates: allRolloverUpdates, mutationLogs: allMutationLogs, modifiedCusEntIdsByFeatureId: allModifiedCusEntIdsByFeatureId, + usageWindowUpdates: Object.values(allUsageWindowUpdates), + usageWindowMutations: allUsageWindowMutations, }; }; diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index 5760e07d5..952344f2c 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -21,6 +21,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js"; import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { generateId } from "@/utils/genUtils.js"; import type { CustomerEntitlementDeduction, DeductionOptions, @@ -132,19 +133,16 @@ export const prepareFeatureDeductionV2 = ({ inStatuses: orgToInStatuses({ org }), }); + // Counters are customer-scoped: a null anchor only means calendar-aligned + // bounds with no provenance, not an unenforceable cap. for (const windowLimit of usageWindowLimits) { + windowLimit.new_window_id = generateId("uw"); if (windowLimit.anchor_customer_entitlement_id === null) { ctx.logger.warn( - `usage window for feature ${windowLimit.feature_id} has no eligible anchor entitlement; failing closed (rejecting). Likely a misconfigured cap with no in-status, non-entity-scoped owning entitlement.`, + `usage window for feature ${windowLimit.feature_id} has no anchor entitlement; using calendar-aligned bounds with no provenance.`, ); } } - if (fullSubject.entity?.spend_limits?.some((s) => s.usage_limit != null)) { - ctx.logger.warn( - `entity-scoped usage windows are not enforced in v1; ignored for entity ${fullSubject.entity.id}`, - ); - } - // set_usage carries no window provenance, so it would silently bypass the hard // cap; reject it when the feature has an enforced usage window. if (notNullish(targetBalance) && usageWindowLimits.length > 0) { @@ -260,6 +258,10 @@ export const prepareFeatureDeductionV2 = ({ : undefined, usageWindowLimits: usageWindowLimits.length > 0 ? usageWindowLimits : undefined, + usageWindowFeatureIds: + usageWindowLimits.length > 0 + ? [...new Set(usageWindowLimits.map((limit) => limit.feature_id))] + : undefined, rollovers: sortedRollovers.map((rollover) => ({ id: rollover.id, credit_cost: rollover.credit_cost, diff --git a/server/src/internal/balances/utils/sql/syncBalancesV2.sql b/server/src/internal/balances/utils/sql/syncBalancesV2.sql index 7553aae52..f918b4140 100644 --- a/server/src/internal/balances/utils/sql/syncBalancesV2.sql +++ b/server/src/internal/balances/utils/sql/syncBalancesV2.sql @@ -6,7 +6,6 @@ -- - balance: number -- - adjustment: number -- - entities: jsonb (the full entities object) --- - usage_windows: jsonb array of DbUsageWindow rows, mirrored to the usage_windows table (null = skip) -- - next_reset_at: bigint/number (unix timestamp, for conflict detection) -- - entity_count: number (for conflict detection) -- - cache_version: number (if defined, skip write if DB cache_version differs) @@ -15,6 +14,12 @@ -- - balance: number -- - usage: number -- - entities: jsonb (the full entities object) +-- usage_window_updates: array of objects with: +-- - internal_customer_id: string +-- - feature_id: string +-- - usage_windows: jsonb array of DbUsageWindow rows (the COMPLETE set for +-- that customer+feature; Redis is authoritative and prunes closed +-- windows, so rows are full-replaced per customer+feature) -- -- Returns JSONB with: -- updates: object mapping customer_entitlement_id -> { balance, adjustment, entities } @@ -33,16 +38,21 @@ AS $$ DECLARE customer_entitlement_updates jsonb := params->'customer_entitlement_updates'; rollover_updates_param jsonb := params->'rollover_updates'; - + usage_window_updates_param jsonb := params->'usage_window_updates'; + ent_obj jsonb; ent_id text; ent_balance numeric; ent_adjustment numeric; ent_entities jsonb; - ent_usage_windows jsonb; ent_next_reset_at bigint; ent_entity_count int; ent_cache_version int; + + uw_obj jsonb; + uw_internal_customer_id text; + uw_feature_id text; + uw_windows jsonb; db_next_reset_at bigint; db_entity_count int; @@ -101,7 +111,6 @@ BEGIN ent_balance := (ent_obj->>'balance')::numeric; ent_adjustment := (ent_obj->>'adjustment')::numeric; ent_entities := ent_obj->'entities'; - ent_usage_windows := ent_obj->'usage_windows'; ent_next_reset_at := (ent_obj->>'next_reset_at')::bigint; ent_entity_count := COALESCE((ent_obj->>'entity_count')::int, 0); ent_cache_version := COALESCE((ent_obj->>'cache_version')::int, 0); @@ -145,48 +154,13 @@ BEGIN WHERE ce.id = ent_id; IF FOUND THEN - -- Mirror the windowed-usage counters into the usage_windows table (Redis is - -- authoritative and already prunes closed windows): full-replace the cus_ent's - -- rows. Clear on ANY present blob -- an emptied window array re-encodes as {} - -- (lua-cjson encodes an empty table as an object), so guarding the DELETE on - -- 'array' would leave stale closed rows. Only a real array has rows to INSERT; - -- a null/object blob simply clears, so the shared sync never reaches - -- jsonb_array_elements on a non-array. null = balance-only sync (untouched). - -- The internal_feature_id filters keep a stray null/orphan row from aborting - -- the whole batch on the NOT NULL + FK column. - IF ent_usage_windows IS NOT NULL AND ent_usage_windows != 'null'::jsonb THEN - DELETE FROM usage_windows WHERE customer_entitlement_id = ent_id; - IF jsonb_typeof(ent_usage_windows) = 'array' THEN - INSERT INTO usage_windows ( - id, customer_entitlement_id, feature_id, internal_feature_id, - window_start_at, window_end_at, usage, updated_at - ) - SELECT - w->>'id', - ent_id, - w->>'feature_id', - w->>'internal_feature_id', - (w->>'window_start_at')::numeric, - (w->>'window_end_at')::numeric, - (w->>'usage')::numeric, - (w->>'updated_at')::numeric - FROM jsonb_array_elements(ent_usage_windows) AS w - WHERE w->>'internal_feature_id' IS NOT NULL - AND EXISTS ( - SELECT 1 FROM features f - WHERE f.internal_id = w->>'internal_feature_id' - ); - END IF; - END IF; - updates_json := jsonb_set( updates_json, ARRAY[ent_id], jsonb_build_object( 'balance', ent_balance, 'adjustment', ent_adjustment, - 'entities', ent_entities, - 'usage_windows', ent_usage_windows + 'entities', ent_entities ) ); END IF; @@ -227,6 +201,75 @@ BEGIN END LOOP; END IF; + -- ============================================================================ + -- STEP 4: Mirror usage-window counters (race-safe upsert) + -- ============================================================================ + -- ONE mutable row per (customer, feature, entity scope); bounds roll in + -- place. Upsert on the scope key (never on id) so concurrent creates can't + -- abort, with an updated_at guard so older snapshots never clobber newer. + IF usage_window_updates_param IS NOT NULL THEN + FOR uw_obj IN SELECT * FROM jsonb_array_elements(usage_window_updates_param) + LOOP + uw_internal_customer_id := uw_obj->>'internal_customer_id'; + uw_feature_id := uw_obj->>'feature_id'; + uw_windows := uw_obj->'usage_windows'; + + IF uw_internal_customer_id IS NOT NULL + AND uw_feature_id IS NOT NULL + AND uw_windows IS NOT NULL + AND uw_windows != 'null'::jsonb THEN + IF jsonb_typeof(uw_windows) != 'array' THEN + uw_windows := '[]'::jsonb; + END IF; + + INSERT INTO usage_windows ( + id, internal_customer_id, internal_entity_id, feature_id, + internal_feature_id, anchor_customer_entitlement_id, + window_start_at, window_end_at, usage, updated_at + ) + SELECT + w->>'id', + uw_internal_customer_id, + w->>'internal_entity_id', + uw_feature_id, + w->>'internal_feature_id', + CASE + WHEN w->>'anchor_customer_entitlement_id' IS NOT NULL + AND EXISTS ( + SELECT 1 FROM customer_entitlements ce + WHERE ce.id = w->>'anchor_customer_entitlement_id' + ) + THEN w->>'anchor_customer_entitlement_id' + ELSE NULL + END, + (w->>'window_start_at')::numeric, + (w->>'window_end_at')::numeric, + (w->>'usage')::numeric, + (w->>'updated_at')::numeric + FROM jsonb_array_elements(uw_windows) AS w + WHERE w->>'id' IS NOT NULL + AND w->>'internal_feature_id' IS NOT NULL + AND EXISTS ( + SELECT 1 FROM features f + WHERE f.internal_id = w->>'internal_feature_id' + ) + ON CONFLICT ( + internal_customer_id, internal_feature_id, + COALESCE(internal_entity_id, '') + ) + DO UPDATE SET + usage = EXCLUDED.usage, + updated_at = EXCLUDED.updated_at, + window_start_at = EXCLUDED.window_start_at, + window_end_at = EXCLUDED.window_end_at, + feature_id = EXCLUDED.feature_id, + anchor_customer_entitlement_id = + EXCLUDED.anchor_customer_entitlement_id + WHERE EXCLUDED.updated_at >= usage_windows.updated_at; + END IF; + END LOOP; + END IF; + RETURN jsonb_build_object( 'updates', updates_json, 'rollover_updates', rollover_updates_json diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts index 61245b703..120bbcb12 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV3.ts @@ -3,6 +3,7 @@ import { logger } from "@/external/logtail/logtailUtils.js"; import { currentRegion } from "@/external/redis/initRedis.js"; import { JobName } from "@/queue/JobName.js"; import { addTaskToQueue } from "@/queue/queueUtils.js"; +import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js"; interface CustomerBatchContext { customerId: string; @@ -14,6 +15,10 @@ interface CustomerBatchContext { rolloverIds: Set; entityId?: string; modifiedCusEntIdsByFeatureId: Record; + // Counter SNAPSHOTS keyed by capped feature: each deduction returns the + // complete post-deduction array, so merging across batched items is + // last-write-wins (unlike cusEnt/rollover ids, which accumulate). + usageWindowUpdatesByFeatureId: Record; } interface CustomerBatch { @@ -33,6 +38,7 @@ export type QueueSyncV4Payload = { rolloverIds: string[]; entityId?: string; modifiedCusEntIdsByFeatureId: Record; + usageWindowUpdates?: UsageWindowUpdate[]; }; messageGroupId?: string; messageDeduplicationId: string; @@ -78,6 +84,7 @@ export class SyncBatchingManagerV3 { region, entityId, modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }: { customerId: string; orgId: string; @@ -87,6 +94,7 @@ export class SyncBatchingManagerV3 { region?: string; entityId?: string; modifiedCusEntIdsByFeatureId: Record; + usageWindowUpdates?: UsageWindowUpdate[]; }): void { const batchKey = this.buildBatchKey({ orgId, env, customerId }); let batch = this.customerBatches.get(batchKey); @@ -112,6 +120,12 @@ export class SyncBatchingManagerV3 { batch.context.modifiedCusEntIdsByFeatureId[featureId].push(...ids); } + for (const usageWindowUpdate of usageWindowUpdates ?? []) { + batch.context.usageWindowUpdatesByFeatureId[ + usageWindowUpdate.feature_id + ] = usageWindowUpdate; + } + const totalSize = batch.context.cusEntIds.size + batch.context.rolloverIds.size; if (totalSize >= this.MAX_BATCH_SIZE) { @@ -177,6 +191,7 @@ export class SyncBatchingManagerV3 { cusEntIds: new Set(), rolloverIds: new Set(), modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdatesByFeatureId: {}, }, timer: null, }; @@ -231,7 +246,13 @@ export class SyncBatchingManagerV3 { this.customerBatches.delete(batchKey); const { context } = batch; - if (context.cusEntIds.size === 0 && context.rolloverIds.size === 0) return; + if ( + context.cusEntIds.size === 0 && + context.rolloverIds.size === 0 && + Object.keys(context.usageWindowUpdatesByFeatureId).length === 0 + ) { + return; + } await this.queueSyncJob({ context }); } @@ -247,10 +268,12 @@ export class SyncBatchingManagerV3 { context, cusEntIds, rolloverIds, + usageWindowUpdates, }: { context: CustomerBatchContext; cusEntIds: string[]; rolloverIds: string[]; + usageWindowUpdates: UsageWindowUpdate[]; }): string { const dedupBucket = Math.floor(Date.now() / this.DEDUP_BUCKET_MS); const dedupKey = JSON.stringify({ @@ -260,6 +283,10 @@ export class SyncBatchingManagerV3 { customerId: context.customerId, cusEntIds, rolloverIds, + // Snapshots ride the payload (cusEnt balances are re-read at consume + // time, counters are not), so a newer snapshot must never be dropped + // as a duplicate of an older one within the bucket. + usageWindowUpdates, dedupBucket, }); @@ -273,10 +300,14 @@ export class SyncBatchingManagerV3 { }): Promise { const cusEntIds = Array.from(context.cusEntIds).sort(); const rolloverIds = Array.from(context.rolloverIds).sort(); + const usageWindowUpdates = Object.values( + context.usageWindowUpdatesByFeatureId, + ); const messageDeduplicationId = this.buildDeduplicationId({ context, cusEntIds, rolloverIds, + usageWindowUpdates, }); try { @@ -292,13 +323,14 @@ export class SyncBatchingManagerV3 { rolloverIds, entityId: context.entityId, modifiedCusEntIdsByFeatureId: context.modifiedCusEntIdsByFeatureId, + usageWindowUpdates, }, // messageGroupId: `sync-v4:${context.orgId}:${context.env}:${context.customerId}`, messageDeduplicationId, }); logger.debug( - `[SyncV4] Queued sync for ${context.customerId}, ${cusEntIds.length} entitlements, ${rolloverIds.length} rollovers`, + `[SyncV4] Queued sync for ${context.customerId}, ${cusEntIds.length} entitlements, ${rolloverIds.length} rollovers, ${usageWindowUpdates.length} usage windows`, ); } catch (error) { logger.error( diff --git a/server/src/internal/balances/utils/sync/syncItemV4.ts b/server/src/internal/balances/utils/sync/syncItemV4.ts index 3450a85f5..8f883ef16 100644 --- a/server/src/internal/balances/utils/sync/syncItemV4.ts +++ b/server/src/internal/balances/utils/sync/syncItemV4.ts @@ -4,13 +4,13 @@ import { type EntityRolloverBalance, type SubjectBalance, tryCatch, - type UsageWindow, } from "@autumn/shared"; import { sql } from "drizzle-orm"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getCachedFeatureBalance } from "@/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.js"; import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import { globalRefreshEntityAggregateBatchingManager } from "../refreshEntityAggregate/RefreshEntityAggregateBatchingManager"; +import type { UsageWindowUpdate } from "../types/usageWindowUpdate.js"; import { logSyncItem } from "./logs/logSyncItem"; const SYNC_CONFLICT_CODES = { @@ -69,6 +69,10 @@ interface SyncItemV4 { timestamp: number; rolloverIds?: string[]; modifiedCusEntIdsByFeatureId: Record; + /** Post-deduction counter snapshots handed straight from the Lua result + * (no Redis re-read); mirrored to the customer-scoped usage_windows table + * via full-replace per (customer, feature). */ + usageWindowUpdates?: UsageWindowUpdate[]; } export interface SyncEntry { @@ -77,7 +81,6 @@ export interface SyncEntry { balance: number; adjustment: number; entities: Record | null; - usage_windows: UsageWindow[] | null; next_reset_at: number | null; entity_count: number; cache_version: number | null; @@ -100,7 +103,6 @@ const subjectBalanceToSyncEntry = ({ balance: subjectBalance.balance ?? 0, adjustment: subjectBalance.adjustment ?? 0, entities: subjectBalance.entities ?? null, - usage_windows: subjectBalance.usage_windows ?? null, next_reset_at: subjectBalance.next_reset_at ?? null, entity_count: subjectBalance.entities ? Object.keys(subjectBalance.entities).length @@ -116,12 +118,17 @@ export const syncItemV4 = async ({ ctx: AutumnContext; payload: SyncItemV4; }): Promise => { - const { customerId, entityId, rolloverIds, modifiedCusEntIdsByFeatureId } = - payload; + const { + customerId, + entityId, + rolloverIds, + modifiedCusEntIdsByFeatureId, + usageWindowUpdates, + } = payload; const { db } = ctx; // Read targeted balance hashes - const allSubjectBalances: SubjectBalance[] = []; + let allSubjectBalances: SubjectBalance[] = []; for (const [featureId, customerEntitlementIds] of Object.entries( modifiedCusEntIdsByFeatureId, )) { @@ -142,7 +149,11 @@ export const syncItemV4 = async ({ feature: featureId, }, }); - return; + // A miss (e.g. an invalidation racing the batch) drops the BALANCE + // sync wholesale, but usage-window snapshots ride in the payload and + // need no cache read -- they must still land. + allSubjectBalances = []; + break; } allSubjectBalances.push(...outcome.value.balances); @@ -172,7 +183,16 @@ export const syncItemV4 = async ({ } } - if (entries.length === 0 && rolloverEntries.length === 0) { + // Customer-scoped usage-window counters arrive pre-built from the deduction + // result (same atomic Lua execution that incremented them) -- no Redis + // re-read here. Full-replaced per (customer, feature) by the SQL function. + const usageWindowEntries: UsageWindowUpdate[] = usageWindowUpdates ?? []; + + if ( + entries.length === 0 && + rolloverEntries.length === 0 && + usageWindowEntries.length === 0 + ) { logSyncItem({ ctx, result: { kind: "skipped", reason: "no_entries" } }); return; } @@ -182,6 +202,7 @@ export const syncItemV4 = async ({ sql`SELECT * FROM sync_balances_v2(${JSON.stringify({ customer_entitlement_updates: entries, rollover_updates: rolloverEntries, + usage_window_updates: usageWindowEntries, })}::jsonb)`, ), ); diff --git a/server/src/internal/balances/utils/types/deductionTypes.ts b/server/src/internal/balances/utils/types/deductionTypes.ts index 800e25ae2..9d431b8e6 100644 --- a/server/src/internal/balances/utils/types/deductionTypes.ts +++ b/server/src/internal/balances/utils/types/deductionTypes.ts @@ -43,9 +43,12 @@ export type PreparedFeatureDeduction = { customerEntitlementDeductions: CustomerEntitlementDeduction[]; spendLimitByFeatureId?: Record; usageBasedCusEntIdsByFeatureId?: Record; - // Resolved windowed usage-limit caps (PR2: passed to Lua but not yet - // enforced; enforcement lands with the deduction-script changes). + // Resolved windowed usage-limit caps, enforced inside the deduction script. usageWindowLimits?: UsageWindowLimit[]; + // Distinct capped feature ids: their balance hashes carry the + // `_usage_windows` counter field, so their keys must be declared in KEYS[] + // even when no deduction entry references them. + usageWindowFeatureIds?: string[]; // rolloverIds: string[]; rollovers: RolloverDeduction[]; unlimitedFeatureIds: string[]; diff --git a/server/src/internal/balances/utils/types/deductionUpdate.ts b/server/src/internal/balances/utils/types/deductionUpdate.ts index a1a80aa43..d06d00dee 100644 --- a/server/src/internal/balances/utils/types/deductionUpdate.ts +++ b/server/src/internal/balances/utils/types/deductionUpdate.ts @@ -2,7 +2,6 @@ import type { EntityBalance, InsertReplaceable, Replaceable, - UsageWindow, } from "@autumn/shared"; export interface DeductionUpdate { @@ -15,7 +14,6 @@ export interface DeductionUpdate { additional_deducted?: number; newReplaceables?: InsertReplaceable[]; deletedReplaceables?: Replaceable[]; - usage_windows?: UsageWindow[] | null; } export type DeductionUpdates = Record; diff --git a/server/src/internal/balances/utils/types/redisDeductionError.ts b/server/src/internal/balances/utils/types/redisDeductionError.ts index 83449fbbb..a802673ab 100644 --- a/server/src/internal/balances/utils/types/redisDeductionError.ts +++ b/server/src/internal/balances/utils/types/redisDeductionError.ts @@ -9,7 +9,6 @@ export enum RedisDeductionErrorCode { SkipCache = "SKIP_CACHE", LockAlreadyExists = "LOCK_ALREADY_EXISTS", DuplicateIdempotencyKey = "DUPLICATE_IDEMPOTENCY_KEY", - UsageLimitExceeded = "USAGE_LIMIT_EXCEEDED", } /** Errors that should trigger a fallback to Postgres */ diff --git a/server/src/internal/balances/utils/types/redisDeductionResult.ts b/server/src/internal/balances/utils/types/redisDeductionResult.ts index 45f595329..5a9e4def8 100644 --- a/server/src/internal/balances/utils/types/redisDeductionResult.ts +++ b/server/src/internal/balances/utils/types/redisDeductionResult.ts @@ -1,12 +1,21 @@ +import type { UsageWindow } from "@autumn/shared"; import type { DeductionUpdate } from "./deductionUpdate.js"; import type { MutationLogItem } from "./mutationLogItem.js"; import type { RolloverUpdate } from "./rolloverUpdate.js"; +import type { UsageWindowMutation } from "./usageWindowMutation.js"; export interface LuaDeductionResult { updates: Record; rollover_updates: Record; modified_customer_entitlement_ids: string[]; mutation_logs: MutationLogItem[]; + /** Post-deduction COUNTER ROWS per capped feature (usage amounts; mirrors + * the usage_windows table) -- not the limits config, which goes IN via + * usage_window_limits. Null when no usage windows were enforced. */ + usage_windows_by_feature_id?: Record | null; + /** Per-window deltas applied by this deduction (sibling stream of + * mutation_logs). */ + usage_window_mutations?: UsageWindowMutation[]; remaining: number; error?: string; feature_id?: string; diff --git a/server/src/internal/balances/utils/types/usageWindowMutation.ts b/server/src/internal/balances/utils/types/usageWindowMutation.ts new file mode 100644 index 000000000..96c1ba40c --- /dev/null +++ b/server/src/internal/balances/utils/types/usageWindowMutation.ts @@ -0,0 +1,14 @@ +/** + * One usage-window counter mutation from a deduction (sibling of + * MutationLogItem, kept as its own stream): which window row moved and by how + * much. The row is identified by its stored id plus the logical key + * (feature + window + entity scope); `usage_delta` is in the limit's native + * unit (tracked units for metered dims, credits for balance dims). + */ +export interface UsageWindowMutation { + usage_window_id: string | null; + feature_id: string; + internal_entity_id: string | null; + window_start_at: number; + usage_delta: number; +} diff --git a/server/src/internal/balances/utils/types/usageWindowUpdate.ts b/server/src/internal/balances/utils/types/usageWindowUpdate.ts new file mode 100644 index 000000000..bc1934fc3 --- /dev/null +++ b/server/src/internal/balances/utils/types/usageWindowUpdate.ts @@ -0,0 +1,18 @@ +import type { UsageWindow } from "@autumn/shared"; + +/** + * Post-deduction usage-window counter state for one capped feature, handed + * down from the Lua result through the deduction flow to syncItemV4 (sibling + * of DeductionUpdate / RolloverUpdate). + * + * Deliberately a SNAPSHOT, not a MutationLog-style delta: the deduction + * script is atomic and the Postgres sync full-replaces rows per (customer, + * feature), so the complete `usage_windows` array IS the update. An empty + * array is meaningful (all windows pruned/closed) and still full-replaces. + * Matches the `usage_window_updates` jsonb param of sync_balances_v2 1:1. + */ +export interface UsageWindowUpdate { + internal_customer_id: string; + feature_id: string; + usage_windows: UsageWindow[]; +} diff --git a/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts b/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts index d8aaed637..50593020c 100644 --- a/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts +++ b/server/src/internal/billing/v2/setup/setupBillingCycleAnchor.ts @@ -78,5 +78,21 @@ export const setupBillingCycleAnchor = ({ // Billing cycle anchor = trial ends at if exists if (newIsTrialing) return trialContext?.trialEndsAt ?? "now"; - return secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now"; + const stripeAnchorMs = secondsToMs(stripeSubscription?.billing_cycle_anchor); + + // Stripe stores the anchor in SECONDS (rounded either way from the ms + // instant it was created). When it's the same instant the current product + // started, prefer the ms-precision starts_at so cycles recomputed across + // updates/upgrades don't drift sub-second (which would churn + // next_reset_at and spuriously move cycle-keyed state like usage windows). + const startsAtMs = customerProduct?.starts_at; + if ( + stripeAnchorMs != null && + startsAtMs != null && + Math.abs(startsAtMs - stripeAnchorMs) < 1000 + ) { + return startsAtMs; + } + + return stripeAnchorMs ?? "now"; }; diff --git a/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts index bfcbcd21d..f2a5f245f 100644 --- a/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/chargeRowToRefundLineItem.ts @@ -85,7 +85,7 @@ export const chargeRowToRefundLineItem = ({ context, stripePriceId: chargeRow.stripe_price_id ?? undefined, stripeProductId: chargeRow.stripe_product_id ?? undefined, - chargeImmediately: true, + chargeImmediately: chargeRow.invoice_id === null ? false : true, prorated: true, discounts: (chargeRow.discounts as InvoiceLineItemDiscount[] | null)?.map((d) => ({ diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts index b5941b2b6..a89bb0847 100644 --- a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItems.ts @@ -9,12 +9,14 @@ export const getRefundLineItems = ({ billingContext, priceFilters, billingCycleAnchorMsOverride, + includeCatalogFallback = true, }: { ctx: AutumnContext; customerProduct: FullCusProduct; billingContext: BillingContext; priceFilters?: { excludeOneOffPrices?: boolean }; billingCycleAnchorMsOverride?: BillingContext["billingCycleAnchorMs"]; + includeCatalogFallback?: boolean; }): LineItem[] => { const { lineItems: matchedCredits, @@ -27,6 +29,7 @@ export const getRefundLineItems = ({ }); if (allPricesResolved) return matchedCredits; + if (!includeCatalogFallback) return matchedCredits; const catalogCredits = customerProductToLineItems({ ctx, diff --git a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts index 2afa57bf8..25281349b 100644 --- a/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts +++ b/server/src/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice.ts @@ -19,6 +19,7 @@ export const getRefundLineItemsForPrice = ({ ctx, customerProduct, billingContext, + includeCatalogFallback: false, }); const matchedRefundsForPrice = matchedRefundLineItems.filter( diff --git a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts index 423284811..11562139e 100644 --- a/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems.ts @@ -57,7 +57,7 @@ export const invoiceCreditFromStoredLineItems = ({ row.customer_product_ids.length > 0 && row.effective_period_start != null && row.effective_period_end != null && - row.effective_period_start < now && + row.effective_period_start <= now && row.effective_period_end > now, ); @@ -76,7 +76,7 @@ export const invoiceCreditFromStoredLineItems = ({ r.customer_product_ids.includes(customerProduct.id) && r.effective_period_end != null && r.effective_period_start != null && - r.effective_period_start < now && + r.effective_period_start <= now && r.effective_period_end > now, ); diff --git a/server/src/internal/customers/actions/resetUsageWindows/applyUsageWindowRollsToSubject.ts b/server/src/internal/customers/actions/resetUsageWindows/applyUsageWindowRollsToSubject.ts new file mode 100644 index 000000000..f7a95e887 --- /dev/null +++ b/server/src/internal/customers/actions/resetUsageWindows/applyUsageWindowRollsToSubject.ts @@ -0,0 +1,34 @@ +import type { FullSubject, NormalizedFullSubject } from "@autumn/shared"; +import type { UsageWindowRoll } from "./computeUsageWindowRolls.js"; + +/** Mirrors persisted rolls onto the in-flight subject (and its normalized + * twin), so this request's response already shows the rolled state. */ +export const applyUsageWindowRollsToSubject = ({ + fullSubject, + normalized, + rolls, + now, +}: { + fullSubject: FullSubject; + normalized?: NormalizedFullSubject; + rolls: UsageWindowRoll[]; + now: number; +}): void => { + const rollsById = new Map(rolls.map((roll) => [roll.id, roll])); + + const apply = (windows: FullSubject["usage_windows"]) => { + for (const usageWindow of windows ?? []) { + const roll = rollsById.get(usageWindow.id); + if (!roll) continue; + if (roll.zero_usage) usageWindow.usage = 0; + usageWindow.window_start_at = roll.window_start_at; + usageWindow.window_end_at = roll.window_end_at; + usageWindow.anchor_customer_entitlement_id = + roll.anchor_customer_entitlement_id; + usageWindow.updated_at = now; + } + }; + + apply(fullSubject.usage_windows); + if (normalized) apply(normalized.usage_windows); +}; diff --git a/server/src/internal/customers/actions/resetUsageWindows/computeUsageWindowRolls.ts b/server/src/internal/customers/actions/resetUsageWindows/computeUsageWindowRolls.ts new file mode 100644 index 000000000..c4ce49c3b --- /dev/null +++ b/server/src/internal/customers/actions/resetUsageWindows/computeUsageWindowRolls.ts @@ -0,0 +1,87 @@ +import { + findUsageWindowLimitByWindow, + type UsageWindow, + type UsageWindowLimit, +} from "@autumn/shared"; + +export type UsageWindowRoll = { + id: string; + feature_id: string; + internal_entity_id: string | null; + /** True when the stored window closed: the count must zero. A roll never + * writes a count otherwise, so it can't clobber a concurrent deduction. */ + zero_usage: boolean; + window_start_at: number; + window_end_at: number; + anchor_customer_entitlement_id: string | null; +}; + +/** + * Decides, per counter row, whether it needs rolling. A count is only valid + * within the exact window stamped on it; the anchor is provenance, so an + * anchor-only re-point keeps the count: + * + * expired | window moved | anchor moved | result + * --------+--------------+--------------+--------------------------------- + * no | no | no | no roll (the common case) + * no | no | yes | re-point anchor, count kept + * no | yes | any | re-bound, count zeroed (plan change) + * yes | any | any | re-bound, count zeroed (period over) + * yes | (no limit) | -- | bounds kept, count zeroed (entity rows, v1) + * + * "Window moved" compares the row's bounds against its limit's CURRENT + * derivation (anchor ent's cycle). Entity-scoped rows have no resolvable + * limit in v1, so their bounds can't re-derive -- but an expired count must + * still zero. + */ +export const computeUsageWindowRolls = ({ + usageWindows, + limits, + now, +}: { + usageWindows: UsageWindow[]; + limits: UsageWindowLimit[]; + now: number; +}): UsageWindowRoll[] => { + const rolls: UsageWindowRoll[] = []; + + for (const usageWindow of usageWindows) { + const expired = Number(usageWindow.window_end_at) <= now; + + const limit = findUsageWindowLimitByWindow({ limits, usageWindow }); + + const target = limit + ? { + window_start_at: limit.window_start_at, + window_end_at: limit.window_end_at, + anchor_customer_entitlement_id: limit.anchor_customer_entitlement_id, + } + : { + window_start_at: Number(usageWindow.window_start_at), + window_end_at: Number(usageWindow.window_end_at), + anchor_customer_entitlement_id: + usageWindow.anchor_customer_entitlement_id ?? null, + }; + + const windowMoved = + Number(usageWindow.window_start_at) !== target.window_start_at || + Number(usageWindow.window_end_at) !== target.window_end_at; + const anchorMoved = + (usageWindow.anchor_customer_entitlement_id ?? null) !== + target.anchor_customer_entitlement_id; + + if (!expired && !windowMoved && !anchorMoved) continue; + + rolls.push({ + id: usageWindow.id, + feature_id: usageWindow.feature_id, + internal_entity_id: usageWindow.internal_entity_id ?? null, + // A count never survives its stamped window; an anchor-only + // re-point (e.g. an ent recreated with the same cycle) keeps it. + zero_usage: expired || windowMoved, + ...target, + }); + } + + return rolls; +}; diff --git a/server/src/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.ts b/server/src/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.ts new file mode 100644 index 000000000..7c2e51005 --- /dev/null +++ b/server/src/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.ts @@ -0,0 +1,76 @@ +import { + type FullSubject, + fullSubjectToUsageWindowLimits, + type NormalizedFullSubject, + orgToInStatuses, +} from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; +import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { usageWindowRepo } from "@/internal/customers/usageWindows/repos/index.js"; +import { applyUsageWindowRollsToSubject } from "./applyUsageWindowRollsToSubject.js"; +import { computeUsageWindowRolls } from "./computeUsageWindowRolls.js"; +import { rollUsageWindowsCache } from "./rollUsageWindowsCache.js"; + +/** + * Lazily ROLLS the subject's usage-window counters on every subject read: + * zero counts whose stored window closed, and re-align bounds/anchor to the + * current derivation (this is where a plan change lands in the DB). The + * decision table lives in computeUsageWindowRolls. + * + * Best-effort, like lazyResetSubjectEntitlements: reads and the deduction + * script both derive a closed count as 0 and stamp fresh bounds on write, so + * a failed roll only delays persistence. Rolls are idempotent (same target + * state), so concurrent reads converge. Returns true if any rows rolled. + */ +export const lazyResetSubjectUsageWindows = async ({ + ctx, + fullSubject, + normalized, +}: { + ctx: AutumnContext; + fullSubject: FullSubject; + normalized?: NormalizedFullSubject; +}): Promise => { + if (getDbHealth() === PgHealth.Degraded) return false; + + const now = Date.now(); + const usageWindows = fullSubject.usage_windows ?? []; + if (usageWindows.length === 0) return false; + + try { + const limits = fullSubjectToUsageWindowLimits({ + fullSubject, + featureIds: [ + ...new Set(usageWindows.map((usageWindow) => usageWindow.feature_id)), + ], + features: ctx.features, + now, + inStatuses: orgToInStatuses({ org: ctx.org }), + }); + + const rolls = computeUsageWindowRolls({ usageWindows, limits, now }); + if (rolls.length === 0) return false; + + ctx.logger.info( + `[lazyResetSubjectUsageWindows] customer: ${fullSubject.customerId}, rolling: ${rolls.length}`, + ); + + await usageWindowRepo.rollWindows({ db: ctx.db, rolls, now }); + await rollUsageWindowsCache({ + ctx, + customerId: fullSubject.customerId, + rolls, + now, + }); + applyUsageWindowRollsToSubject({ fullSubject, normalized, rolls, now }); + + return true; + } catch (error) { + ctx.logger.error( + `[lazyResetSubjectUsageWindows] customer: ${fullSubject.customerId}, failed: ${error}`, + ); + Sentry.captureException(error); + return false; + } +}; diff --git a/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts b/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts new file mode 100644 index 000000000..cdc2922c8 --- /dev/null +++ b/server/src/internal/customers/actions/resetUsageWindows/rollUsageWindowsCache.ts @@ -0,0 +1,60 @@ +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; +import { FULL_SUBJECT_CACHE_TTL_SECONDS } from "@/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; +import type { UsageWindowRoll } from "./computeUsageWindowRolls.js"; + +/** + * Atomically patches rolled counters into each affected feature's + * '_usage_windows' field (one rollUsageWindows Lua call per feature, + * pipelined). Fire-and-forget -- reads and the deduction script both derive + * a closed window as 0, so a missed patch only delays the persisted roll. + */ +export const rollUsageWindowsCache = async ({ + ctx, + customerId, + rolls, + now, +}: { + ctx: AutumnContext; + customerId: string; + rolls: UsageWindowRoll[]; + now: number; +}): Promise => { + if (rolls.length === 0) return; + + try { + const { org, env, redisV2 } = ctx; + + const rollsByFeatureId: Record = {}; + for (const roll of rolls) { + const featureRolls = rollsByFeatureId[roll.feature_id] ?? []; + featureRolls.push(roll); + rollsByFeatureId[roll.feature_id] = featureRolls; + } + + const pipeline = redisV2.pipeline(); + for (const [featureId, featureRolls] of Object.entries(rollsByFeatureId)) { + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: org.id, + env, + customerId, + featureId, + }); + pipeline.rollUsageWindows( + balanceKey, + JSON.stringify({ + now, + ttl_seconds: FULL_SUBJECT_CACHE_TTL_SECONDS, + rolls: featureRolls, + }), + ); + } + + await tryRedisWrite(() => pipeline.exec(), redisV2); + } catch (error) { + ctx.logger.error( + `[rollUsageWindowsCache] customer=${customerId}, failed: ${error}`, + ); + } +}; diff --git a/server/src/internal/customers/actions/update/updateCustomer.ts b/server/src/internal/customers/actions/update/updateCustomer.ts index 89c2f304e..9ddc47034 100644 --- a/server/src/internal/customers/actions/update/updateCustomer.ts +++ b/server/src/internal/customers/actions/update/updateCustomer.ts @@ -139,6 +139,8 @@ export const updateCustomer = async ({ billingControlUpdates.auto_topups = billing_controls.auto_topups; if (billing_controls.spend_limits !== undefined) billingControlUpdates.spend_limits = billing_controls.spend_limits; + if (billing_controls.usage_limits !== undefined) + billingControlUpdates.usage_limits = billing_controls.usage_limits; if (billing_controls.usage_alerts !== undefined) billingControlUpdates.usage_alerts = billing_controls.usage_alerts; if (billing_controls.overage_allowed !== undefined) diff --git a/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts index b9c2740b1..6bace36b0 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/getCachedFullSubject.ts @@ -7,10 +7,12 @@ import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRoutin import { runRedisOp } from "@/external/redis/utils/runRedisOp.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js"; +import { lazyResetSubjectUsageWindows } from "@/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.js"; import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js"; import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js"; import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js"; +import { applyLiveUsageWindows } from "../balances/applyLiveUsageWindows.js"; import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js"; import { buildFullSubjectKey } from "../builders/buildFullSubjectKey.js"; import { buildFullSubjectViewEpochKey } from "../builders/buildFullSubjectViewEpochKey.js"; @@ -195,12 +197,19 @@ export const getCachedFullSubject = async ({ } const isCustomerSubject = !entityId; + // Capped features may have no entitlements, so they aren't guaranteed to be + // in meteredFeatures; union them in so their `_usage_windows` field is read. + const usageWindowFeatureIds = new Set(cached.usageWindowFeatureIds ?? []); + const batchFeatureIds = [ + ...new Set([...cached.meteredFeatures, ...usageWindowFeatureIds]), + ]; const balancesOutcome = await getCachedFeatureBalancesBatch({ ctx, customerId, - featureIds: cached.meteredFeatures, + featureIds: batchFeatureIds, customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId, includeAggregated: isCustomerSubject, + usageWindowFeatureIds, }); if (balancesOutcome.kind === "missing") { @@ -220,9 +229,9 @@ export const getCachedFullSubject = async ({ } const balances = balancesOutcome.value; - if (balances.length !== cached.meteredFeatures.length) { + if (balances.length !== batchFeatureIds.length) { logger.warn( - `[getCachedFullSubject] Incomplete cache for ${customerId}${entityId ? `:${entityId}` : ""}: expected ${cached.meteredFeatures.length} balance keys, got ${balances.length}. Rebuilding from DB, source: ${source}`, + `[getCachedFullSubject] Incomplete cache for ${customerId}${entityId ? `:${entityId}` : ""}: expected ${batchFeatureIds.length} balance keys, got ${balances.length}. Rebuilding from DB, source: ${source}`, ); await invalidateCachedFullSubjectExact({ ctx, @@ -249,8 +258,14 @@ export const getCachedFullSubject = async ({ }); } + applyLiveUsageWindows({ + normalized, + featureBalances: balances, + }); + const fullSubject = normalizedToFullSubject({ normalized }); await lazyResetSubjectEntitlements({ ctx, fullSubject }); + await lazyResetSubjectUsageWindows({ ctx, fullSubject, normalized }); await checkPendingMigrationsForCustomer({ ctx, fullCustomer: fullSubjectToFullCustomer({ fullSubject }), diff --git a/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts index aee65fc5e..8e5547147 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.ts @@ -1,7 +1,7 @@ import { - CustomerNotFoundError, - EntityNotFoundError, - type FullSubject, + CustomerNotFoundError, + EntityNotFoundError, + type FullSubject, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js"; @@ -27,7 +27,6 @@ export const getOrSetCachedFullSubject = async ({ let fetchedSubjectViewEpoch = 0; - if (useRedis) { // The pipeline inside getCachedFullSubject already fetches + refreshes // the epoch, so we reuse it on miss instead of a second round trip. diff --git a/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateSharedBalanceFields.ts b/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateSharedBalanceFields.ts index e2e9a8934..aa69c7f31 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateSharedBalanceFields.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/invalidate/invalidateSharedBalanceFields.ts @@ -3,7 +3,10 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js"; -import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js"; +import { + AGGREGATED_BALANCE_FIELD, + USAGE_WINDOWS_FIELD, +} from "../../config/fullSubjectCacheConfig.js"; import type { CachedFullSubject } from "../../fullSubjectCacheModel.js"; /** @@ -61,19 +64,37 @@ async function deleteFieldsFromManifest({ const { customerEntitlementIdsByFeatureId } = manifest; if (!customerEntitlementIdsByFeatureId) return; + // Capped features may have no entitlements, so their hashes only appear in + // usageWindowFeatureIds; union both so `_usage_windows` is cleared too. + // Safe to delete counters: capped tracks write through to PG synchronously, + // and the rebuild re-seeds the field from PG. + // Raw blob, no sanitize walker: cjson re-encodes empty arrays as {}, so + // array fields must be Array.isArray-guarded before spreading. + const usageWindowFeatureIds = Array.isArray(manifest.usageWindowFeatureIds) + ? manifest.usageWindowFeatureIds + : []; + const featureIds = new Set([ + ...Object.keys(customerEntitlementIdsByFeatureId), + ...usageWindowFeatureIds, + ]); + const pipeline = redisV2.pipeline(); let fieldCount = 0; - for (const [featureId, cusEntIds] of Object.entries( - customerEntitlementIdsByFeatureId, - )) { + for (const featureId of featureIds) { + const rawCusEntIds = customerEntitlementIdsByFeatureId[featureId]; + const cusEntIds = Array.isArray(rawCusEntIds) ? rawCusEntIds : []; const balanceKey = buildSharedFullSubjectBalanceKey({ orgId: org.id, env, customerId, featureId, }); - const fieldsToDelete = [...cusEntIds, AGGREGATED_BALANCE_FIELD]; + const fieldsToDelete = [ + ...cusEntIds, + AGGREGATED_BALANCE_FIELD, + USAGE_WINDOWS_FIELD, + ]; pipeline.hdel(balanceKey, ...fieldsToDelete); fieldCount += fieldsToDelete.length; } diff --git a/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts index 1d9446e29..eb399c58a 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/partial/getCachedPartialFullSubject.ts @@ -4,9 +4,11 @@ import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRoutin import { runRedisOp } from "@/external/redis/utils/runRedisOp.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js"; +import { lazyResetSubjectUsageWindows } from "@/internal/customers/actions/resetUsageWindows/lazyResetSubjectUsageWindows.js"; import { getFullSubjectRolloutSnapshot } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js"; import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js"; import { applyLiveAggregatedBalances } from "../../balances/applyLiveAggregatedBalances.js"; +import { applyLiveUsageWindows } from "../../balances/applyLiveUsageWindows.js"; import { getCachedFeatureBalancesBatch } from "../../balances/getCachedFeatureBalances.js"; import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js"; import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectViewEpochKey.js"; @@ -212,9 +214,22 @@ export const getCachedPartialFullSubject = async ({ }; } - const meteredFeatureIdsToFetch = featureIds.filter((featureId) => - cached.meteredFeatures.includes(featureId), + // Capped features carry the '_usage_windows' counter field and may have no + // entitlements at all, so they must be part of the batch even when absent + // from meteredFeatures. + const usageWindowFeatureIds = new Set( + (cached.usageWindowFeatureIds ?? []).filter((featureId) => + featureIds.includes(featureId), + ), ); + const meteredFeatureIdsToFetch = [ + ...new Set([ + ...featureIds.filter((featureId) => + cached.meteredFeatures.includes(featureId), + ), + ...usageWindowFeatureIds, + ]), + ]; const isCustomerSubject = !entityId; const featureBalancesOutcome = await getCachedFeatureBalancesBatch({ @@ -223,6 +238,7 @@ export const getCachedPartialFullSubject = async ({ featureIds: meteredFeatureIdsToFetch, customerEntitlementIdsByFeatureId: cached.customerEntitlementIdsByFeatureId, includeAggregated: isCustomerSubject, + usageWindowFeatureIds, }); const invalidateIncomplete = () => @@ -287,8 +303,14 @@ export const getCachedPartialFullSubject = async ({ }); } + applyLiveUsageWindows({ + normalized, + featureBalances, + }); + const fullSubject = normalizedToFullSubject({ normalized }); await lazyResetSubjectEntitlements({ ctx, fullSubject, normalized }); + await lazyResetSubjectUsageWindows({ ctx, fullSubject, normalized }); return fullSubject; }, invalidate: () => diff --git a/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts b/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts index 3ed08c1d7..44d0141e7 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/rehydrateWithLiveBalances.ts @@ -2,6 +2,7 @@ import type { NormalizedFullSubject } from "@autumn/shared"; import { type FullSubject, normalizedToFullSubject } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { applyLiveAggregatedBalances } from "../balances/applyLiveAggregatedBalances.js"; +import { applyLiveUsageWindows } from "../balances/applyLiveUsageWindows.js"; import { getCachedFeatureBalancesBatch } from "../balances/getCachedFeatureBalances.js"; /** @@ -31,7 +32,18 @@ export const rehydrateWithLiveBalances = async ({ list.push(ce.id); customerEntitlementIdsByFeatureId[ce.feature_id] = list; } - const featureIds = Object.keys(customerEntitlementIdsByFeatureId); + const usageWindowFeatureIds = new Set( + [ + ...(normalized.customer.usage_limits ?? []), + ...(normalized.entity?.usage_limits ?? []), + ].map((usageLimit) => usageLimit.feature_id), + ); + const featureIds = [ + ...new Set([ + ...Object.keys(customerEntitlementIdsByFeatureId), + ...usageWindowFeatureIds, + ]), + ]; const isCustomerSubject = !entityId; const outcome = await getCachedFeatureBalancesBatch({ @@ -40,6 +52,7 @@ export const rehydrateWithLiveBalances = async ({ featureIds, customerEntitlementIdsByFeatureId, includeAggregated: isCustomerSubject, + usageWindowFeatureIds, }); if (outcome.kind !== "ok") return undefined; @@ -53,5 +66,10 @@ export const rehydrateWithLiveBalances = async ({ }); } + applyLiveUsageWindows({ + normalized, + featureBalances: outcome.value, + }); + return normalizedToFullSubject({ normalized }); }; diff --git a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts index 530ffcc95..1fc9bcca6 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setCachedFullSubject.ts @@ -49,6 +49,8 @@ export const setCachedFullSubject = async ({ customerEntitlements: normalized.customer_entitlements, aggregatedCustomerEntitlements: normalized.entity_aggregations?.aggregated_customer_entitlements ?? [], + usageWindows: normalized.usage_windows ?? [], + usageWindowFeatureIds: cached.usageWindowFeatureIds, }); const keys: string[] = [subjectKey, epochKey]; diff --git a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setSharedFullSubjectBalances.ts b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setSharedFullSubjectBalances.ts index c1774ce41..11d0571de 100644 --- a/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setSharedFullSubjectBalances.ts +++ b/server/src/internal/customers/cache/fullSubject/actions/setCachedFullSubject/setSharedFullSubjectBalances.ts @@ -1,10 +1,14 @@ import type { AggregatedFeatureBalance, NormalizedFullSubject, + UsageWindow, } from "@autumn/shared"; import { featureBalancesToHashFields } from "../../balances/featureBalancesToHashFields.js"; import { buildSharedFullSubjectBalanceKey } from "../../builders/buildSharedFullSubjectBalanceKey.js"; -import { AGGREGATED_BALANCE_FIELD } from "../../config/fullSubjectCacheConfig.js"; +import { + AGGREGATED_BALANCE_FIELD, + USAGE_WINDOWS_FIELD, +} from "../../config/fullSubjectCacheConfig.js"; export type SharedBalanceWrite = { balanceKey: string; @@ -17,12 +21,16 @@ export const buildSharedBalanceWrites = ({ customerId, customerEntitlements, aggregatedCustomerEntitlements, + usageWindows = [], + usageWindowFeatureIds = [], }: { orgId: string; env: string; customerId: string; customerEntitlements: NormalizedFullSubject["customer_entitlements"]; aggregatedCustomerEntitlements: AggregatedFeatureBalance[]; + usageWindows?: UsageWindow[]; + usageWindowFeatureIds?: string[]; }): SharedBalanceWrite[] => { const balancesByFeatureId = new Map(); @@ -38,9 +46,24 @@ export const buildSharedBalanceWrites = ({ aggregatedByFeatureId.set(aggregated.feature_id, aggregated); } + // Capped features get a `_usage_windows` field even with no rows and no + // entitlements: a present-but-empty field means "fresh counter", a missing + // field means "stale cache" and the deduction script fails closed on it. + const usageWindowsByFeatureId = new Map(); + for (const featureId of usageWindowFeatureIds) { + usageWindowsByFeatureId.set(featureId, []); + } + for (const usageWindow of usageWindows) { + const existingWindows = usageWindowsByFeatureId.get(usageWindow.feature_id); + // Rows for features whose cap is no longer armed are not re-cached. + if (!existingWindows) continue; + existingWindows.push(usageWindow); + } + const allFeatureIds = new Set([ ...balancesByFeatureId.keys(), ...aggregatedByFeatureId.keys(), + ...usageWindowsByFeatureId.keys(), ]); return Array.from(allFeatureIds).map((featureId) => { @@ -52,6 +75,11 @@ export const buildSharedBalanceWrites = ({ fields[AGGREGATED_BALANCE_FIELD] = JSON.stringify(aggregated); } + const featureUsageWindows = usageWindowsByFeatureId.get(featureId); + if (featureUsageWindows) { + fields[USAGE_WINDOWS_FIELD] = JSON.stringify(featureUsageWindows); + } + return { balanceKey: buildSharedFullSubjectBalanceKey({ orgId, diff --git a/server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts b/server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts new file mode 100644 index 000000000..03cf75971 --- /dev/null +++ b/server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts @@ -0,0 +1,20 @@ +import type { NormalizedFullSubject } from "@autumn/shared"; +import type { FeatureBalanceResult } from "./getCachedFeatureBalances.js"; + +/** + * Fill `normalized.usage_windows` from the live `_usage_windows` hash fields + * returned by the batch balance read. The cached subject view never carries + * counter rows (they'd be instantly stale), so this is the only hydration + * source on the cache-hit path. + */ +export const applyLiveUsageWindows = ({ + normalized, + featureBalances, +}: { + normalized: NormalizedFullSubject; + featureBalances: FeatureBalanceResult[]; +}): void => { + normalized.usage_windows = featureBalances.flatMap( + (featureBalance) => featureBalance.usageWindows ?? [], + ); +}; diff --git a/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts b/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts index 19b92fff3..a75b27736 100644 --- a/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts +++ b/server/src/internal/customers/cache/fullSubject/balances/getCachedFeatureBalances.ts @@ -1,8 +1,15 @@ -import type { AggregatedFeatureBalance, SubjectBalance } from "@autumn/shared"; +import type { + AggregatedFeatureBalance, + SubjectBalance, + UsageWindow, +} from "@autumn/shared"; import { runRedisOp } from "@/external/redis/utils/runRedisOp.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { buildSharedFullSubjectBalanceKey } from "../builders/buildSharedFullSubjectBalanceKey.js"; -import { AGGREGATED_BALANCE_FIELD } from "../config/fullSubjectCacheConfig.js"; +import { + AGGREGATED_BALANCE_FIELD, + USAGE_WINDOWS_FIELD, +} from "../config/fullSubjectCacheConfig.js"; import { roundSubjectBalance } from "../roundCacheBalance.js"; import { sanitizeCachedAggregatedFeatureBalance, @@ -13,6 +20,24 @@ export type FeatureBalanceResult = { featureId: string; balances: SubjectBalance[]; aggregated?: AggregatedFeatureBalance; + /** Customer-scoped windowed-cap counters for this feature; only present for + * features in the requested usageWindowFeatureIds set. */ + usageWindows?: UsageWindow[]; +}; + +// Fail open: a missing/unparseable `_usage_windows` field reads as an empty +// counter set (the window restarts). cjson also encodes an empty Lua table as +// `{}`, so a non-array blob is an empty set, not corruption. +const parseUsageWindowsField = ( + usageWindowsJson: string | null, +): UsageWindow[] => { + if (!usageWindowsJson) return []; + try { + const parsed = JSON.parse(usageWindowsJson); + return Array.isArray(parsed) ? (parsed as UsageWindow[]) : []; + } catch { + return []; + } }; export type FeatureBalanceOutcome = @@ -118,12 +143,16 @@ export const getCachedFeatureBalancesBatch = async ({ featureIds, customerEntitlementIdsByFeatureId, includeAggregated = false, + usageWindowFeatureIds, }: { ctx: AutumnContext; customerId: string; featureIds: string[]; customerEntitlementIdsByFeatureId: Record; includeAggregated?: boolean; + /** Features with an armed windowed cap: their `_usage_windows` field is + * read too. A missing field fails open (reads as an empty counter set). */ + usageWindowFeatureIds?: Set; }): Promise => { if (featureIds.length === 0) return { kind: "ok", value: [] }; @@ -132,9 +161,11 @@ export const getCachedFeatureBalancesBatch = async ({ for (const featureId of featureIds) { const customerEntitlementIds = customerEntitlementIdsByFeatureId[featureId] ?? []; - const fields = includeAggregated - ? [...customerEntitlementIds, AGGREGATED_BALANCE_FIELD] - : customerEntitlementIds; + const fields = [...customerEntitlementIds]; + if (includeAggregated) fields.push(AGGREGATED_BALANCE_FIELD); + if (usageWindowFeatureIds?.has(featureId)) { + fields.push(USAGE_WINDOWS_FIELD); + } pipeline.hmget( buildSharedFullSubjectBalanceKey({ orgId: org.id, @@ -167,7 +198,12 @@ export const getCachedFeatureBalancesBatch = async ({ }; let aggregated: AggregatedFeatureBalance | undefined; - let ceValues: (string | null)[]; + let usageWindows: UsageWindow[] | undefined; + + // Pop reserved fields in reverse push order: [_aggregated?, _usage_windows?]. + if (usageWindowFeatureIds?.has(featureIds[i])) { + usageWindows = parseUsageWindowsField(allValues.pop() ?? null); + } if (includeAggregated) { const aggregatedJson = allValues.pop() ?? null; @@ -181,11 +217,10 @@ export const getCachedFeatureBalancesBatch = async ({ // Malformed _aggregated is non-fatal; fall back to subject string value } } - ceValues = allValues; - } else { - ceValues = allValues; } + const ceValues = allValues; + if (ceValues.length !== customerEntitlementIds.length) return { kind: "missing", @@ -221,6 +256,7 @@ export const getCachedFeatureBalancesBatch = async ({ featureId: featureIds[i], balances, aggregated, + usageWindows, }); } diff --git a/server/src/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.ts b/server/src/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.ts index b08776582..9fd90e07e 100644 --- a/server/src/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.ts +++ b/server/src/internal/customers/cache/fullSubject/builders/buildDeductFromSubjectBalancesKeys.ts @@ -21,7 +21,7 @@ export const buildDeductFromSubjectBalancesKeys = ({ idempotencyKey, customerEntitlementDeductions, fallbackFeatureId, - anchorFeatureIds = [], + usageWindowFeatureIds = [], }: { orgId: string; env: AppEnv; @@ -31,10 +31,10 @@ export const buildDeductFromSubjectBalancesKeys = ({ idempotencyKey?: string | null; customerEntitlementDeductions: { feature_id?: string }[]; fallbackFeatureId: string; - // Features owning a usage-window anchor counter. Their balance hash keys must - // be declared in KEYS[] so Lua can load the anchor even when it is not in the - // deduction set. - anchorFeatureIds?: string[]; + // Capped features: their balance hashes carry the `_usage_windows` counter + // field, and a capped feature may have no entitlements (so no deduction + // entry references its hash). Declare those keys in KEYS[] too. + usageWindowFeatureIds?: string[]; }) => { const balanceKeysByFeatureId: Record = {}; const addFeatureKey = (featureId: string) => { @@ -49,8 +49,8 @@ export const buildDeductFromSubjectBalancesKeys = ({ for (const deductionEntry of customerEntitlementDeductions) { addFeatureKey(deductionEntry.feature_id ?? fallbackFeatureId); } - for (const anchorFeatureId of anchorFeatureIds) { - addFeatureKey(anchorFeatureId); + for (const usageWindowFeatureId of usageWindowFeatureIds) { + addFeatureKey(usageWindowFeatureId); } const balanceFeatureIds = Object.keys(balanceKeysByFeatureId); diff --git a/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts b/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts index 20024f0b7..cf4ef8fe8 100644 --- a/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts +++ b/server/src/internal/customers/cache/fullSubject/config/fullSubjectCacheConfig.ts @@ -3,3 +3,9 @@ import { seconds } from "@autumn/shared"; export const FULL_SUBJECT_CACHE_TTL_SECONDS = seconds.days(3); export const FULL_SUBJECT_EPOCH_TTL_SECONDS = seconds.days(5); export const AGGREGATED_BALANCE_FIELD = "_aggregated"; +// Customer-scoped usage-window counters for the capped feature, stored as a +// reserved field in that feature's balance hash (JSON array of rows). The +// rebuild writes it (even []) for armed caps; readers fail OPEN on a missing +// field (the window restarts), so it is a warm-read optimization, not a +// correctness contract. +export const USAGE_WINDOWS_FIELD = "_usage_windows"; diff --git a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts index 772616c04..f1782b19d 100644 --- a/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts +++ b/server/src/internal/customers/cache/fullSubject/fullSubjectCacheModel.ts @@ -16,14 +16,23 @@ import { } from "@autumn/shared"; import { z } from "zod/v4"; +// `usage_windows` is omitted alongside balances: counters live in the +// per-feature balance hashes (`_usage_windows` field) and would be instantly +// stale if serialized into the subject view. export type CachedFullSubject = Omit< NormalizedFullSubject, - "customer_entitlements" + "customer_entitlements" | "usage_windows" > & { _schemaVersion: number; _cachedAt: number; meteredFeatures: string[]; customerEntitlementIdsByFeatureId: Record; + /** Features with an armed windowed cap (customer + entity usage_limits); + * may include features with no entitlements, so it cannot be derived from + * customerEntitlementIdsByFeatureId. Drives `_usage_windows` reads, + * writes, and invalidation. Optional: cache entries written before usage + * windows existed don't carry it (treat as []). */ + usageWindowFeatureIds?: string[]; subjectViewEpoch: number; }; @@ -72,6 +81,9 @@ export const CachedFullSubjectSchema = z.object({ _cachedAt: z.number(), meteredFeatures: z.array(z.string()), customerEntitlementIdsByFeatureId: z.record(z.string(), z.array(z.string())), + // Optional (not defaulted): pre-usage-windows cache entries don't carry it, + // and the hole-filling walker must not invent it. + usageWindowFeatureIds: z.array(z.string()).optional(), subjectViewEpoch: z.number(), }); @@ -105,6 +117,15 @@ export const normalizedToCachedFullSubject = ({ const meteredFeatures = [...meteredFeatureSet]; + const usageWindowFeatureIds = [ + ...new Set( + [ + ...(normalized.customer.usage_limits ?? []), + ...(normalized.entity?.usage_limits ?? []), + ].map((usageLimit) => usageLimit.feature_id), + ), + ]; + return { subjectType: normalized.subjectType, customerId: normalized.customerId, @@ -128,6 +149,7 @@ export const normalizedToCachedFullSubject = ({ _cachedAt: Date.now(), meteredFeatures, customerEntitlementIdsByFeatureId, + usageWindowFeatureIds, subjectViewEpoch, }; }; @@ -159,5 +181,8 @@ export const cachedFullSubjectToNormalized = ({ invoices: cached.invoices, entity_aggregations: cached.entity_aggregations, migration_item_runs: cached.migration_item_runs ?? [], + // Live data: filled from the balance hashes' `_usage_windows` fields by + // the caller, never from the cached subject view. + usage_windows: [], }; }; diff --git a/server/src/internal/customers/cache/fullSubject/roundCacheBalance.ts b/server/src/internal/customers/cache/fullSubject/roundCacheBalance.ts index 5ba806455..bb4099dea 100644 --- a/server/src/internal/customers/cache/fullSubject/roundCacheBalance.ts +++ b/server/src/internal/customers/cache/fullSubject/roundCacheBalance.ts @@ -5,9 +5,7 @@ import { Decimal } from "decimal.js"; * Round a number to avoid floating-point precision issues from Lua 5.1 double arithmetic. * Uses Decimal.js toDecimalPlaces(10) — enough precision while eliminating float drift. */ -export const roundCacheBalance = ( - value: number | null | undefined, -): number => { +export const roundCacheBalance = (value: number | null | undefined): number => { if (value === null || value === undefined) return 0; return new Decimal(value).toDecimalPlaces(10).toNumber(); }; @@ -23,11 +21,19 @@ export const roundSubjectBalance = ({ }): SubjectBalance => { subjectBalance.balance = roundCacheBalance(subjectBalance.balance); - if (subjectBalance.adjustment !== null && subjectBalance.adjustment !== undefined) + if ( + subjectBalance.adjustment !== null && + subjectBalance.adjustment !== undefined + ) subjectBalance.adjustment = roundCacheBalance(subjectBalance.adjustment); - if (subjectBalance.additional_balance !== null && subjectBalance.additional_balance !== undefined) - subjectBalance.additional_balance = roundCacheBalance(subjectBalance.additional_balance); + if ( + subjectBalance.additional_balance !== null && + subjectBalance.additional_balance !== undefined + ) + subjectBalance.additional_balance = roundCacheBalance( + subjectBalance.additional_balance, + ); if (subjectBalance.entities && typeof subjectBalance.entities === "object") { for (const entityId of Object.keys(subjectBalance.entities)) { diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts index f57dcb695..a82edf393 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.ts @@ -6,7 +6,7 @@ import { type CustomerLegacyData, type FullCustomer, fullCustomerToFullSubject, - fullSubjectToApiSpendLimits, + fullSubjectToApiUsageLimits, orgToInStatuses, scopeExpandForCtx, } from "@autumn/shared"; @@ -48,7 +48,7 @@ export const getApiCustomerBase = async ({ ctx: subscriptionsScopedCtx, fullCus, }); - const spendLimits = fullSubjectToApiSpendLimits({ + const usageLimits = fullSubjectToApiUsageLimits({ fullSubject: fullCustomerToFullSubject({ fullCustomer: fullCus }), features: ctx.features, inStatuses: orgToInStatuses({ org: ctx.org }), @@ -76,7 +76,8 @@ export const getApiCustomerBase = async ({ send_email_receipts: fullCus.send_email_receipts ?? false, billing_controls: { auto_topups: fullCus.auto_topups ?? undefined, - spend_limits: spendLimits, + spend_limits: fullCus.spend_limits ?? undefined, + usage_limits: usageLimits, usage_alerts: fullCus.usage_alerts ?? undefined, overage_allowed: fullCus.overage_allowed ?? undefined, }, diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts index 3c250ab76..951b42ce5 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusAutoTopupPurchaseLimits.ts @@ -4,8 +4,8 @@ import { CustomerExpand, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; import { normalizeWindowCounter } from "@/internal/balances/autoTopUp/helpers/limits/autoTopupLimitWindowUtils.js"; +import { autoTopupLimitRepo } from "@/internal/balances/autoTopUp/repos"; /** * When `expand=billing_controls.auto_topups.purchase_limit` is requested, diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusProcessors.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusProcessors.ts index 88b2537e4..3149137dc 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusProcessors.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusProcessors.ts @@ -2,8 +2,8 @@ import { type ApiCusProcessors, type Customer, customerProductHasActiveStatus, - filterCustomerProductsByProcessorType, type FullCusProduct, + filterCustomerProductsByProcessorType, ProcessorType, } from "@autumn/shared"; diff --git a/server/src/internal/customers/cusUtils/cusUtils.ts b/server/src/internal/customers/cusUtils/cusUtils.ts index 4bbc4d444..35bfd889c 100644 --- a/server/src/internal/customers/cusUtils/cusUtils.ts +++ b/server/src/internal/customers/cusUtils/cusUtils.ts @@ -42,9 +42,8 @@ export const updateCustomerDetails = async ({ } if (!fullCustomer.email && customerData?.email) { if ( - z - .email({ pattern: z.regexes.unicodeEmail }) - .safeParse(customerData.email).error + z.email({ pattern: z.regexes.unicodeEmail }).safeParse(customerData.email) + .error ) { logger.info(`Invalid email ${customerData.email}, skipping update`); } else { diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts index 8e54e0db7..bab4f83b3 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiBalance/getApiBalancesV2.ts @@ -4,10 +4,10 @@ import { type ApiFlagV0, type Feature, FeatureType, - findFeatureByInternalId, type FullAggregatedFeatureBalance, type FullCusEntWithFullCusProduct, type FullSubject, + findFeatureByInternalId, fullSubjectToCustomerEntitlements, orgToInStatuses, scopeExpandForCtx, @@ -50,8 +50,10 @@ const getFeatureInputs = ({ string, FullAggregatedFeatureBalance > = {}; - const aggregatedSubjectFlagByFeatureId: Record = - {}; + const aggregatedSubjectFlagByFeatureId: Record< + string, + AggregatedSubjectFlag + > = {}; if (fullSubject.subjectType === "customer") { for (const aggregatedFeatureBalance of fullSubject.aggregated_customer_entitlements ?? diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts index ea38770a7..84f84ef6f 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiCustomerBaseV2.ts @@ -4,7 +4,7 @@ import { CustomerExpand, type CustomerLegacyData, type FullSubject, - fullSubjectToApiSpendLimits, + fullSubjectToApiUsageLimits, orgToInStatuses, scopeExpandForCtx, } from "@autumn/shared"; @@ -48,7 +48,7 @@ export const getApiCustomerBaseV2 = async ({ }); const customer = fullSubject.customer; - const spendLimits = fullSubjectToApiSpendLimits({ + const usageLimits = fullSubjectToApiUsageLimits({ fullSubject, features: ctx.features, inStatuses: orgToInStatuses({ org: ctx.org }), @@ -73,7 +73,8 @@ export const getApiCustomerBaseV2 = async ({ send_email_receipts: customer.send_email_receipts ?? false, billing_controls: { auto_topups: customer.auto_topups ?? undefined, - spend_limits: spendLimits, + spend_limits: customer.spend_limits ?? undefined, + usage_limits: usageLimits, usage_alerts: customer.usage_alerts ?? undefined, overage_allowed: customer.overage_allowed ?? undefined, }, diff --git a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts index fa2faa9e4..13a94fe45 100644 --- a/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts +++ b/server/src/internal/customers/cusUtils/getApiCustomerV2/getApiSubscription/getApiSubscriptionV2.ts @@ -129,12 +129,12 @@ export const getApiSubscriptionV2 = async ({ trial_ends_at: isCustomerProductTrialing(customerProduct) ? (customerProduct.trial_ends_at ?? null) : null, - started_at: customerProduct.starts_at, - quantity: customerProduct.quantity, - current_period_start: subscriptionPeriod.current_period_start, - current_period_end: subscriptionPeriod.current_period_end, - scope: customerProduct.internal_entity_id ? "entity" : "customer", - } satisfies ApiSubscriptionV1), + started_at: customerProduct.starts_at, + quantity: customerProduct.quantity, + current_period_start: subscriptionPeriod.current_period_start, + current_period_end: subscriptionPeriod.current_period_end, + scope: customerProduct.internal_entity_id ? "entity" : "customer", + } satisfies ApiSubscriptionV1), legacyData: { subscription_id: subId || undefined, options: customerProduct.options, diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts index 5c084343c..e628324b9 100644 --- a/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubject.ts @@ -9,6 +9,7 @@ import { import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { checkPendingMigrationsForCustomer } from "@/internal/migrations/v2/lazy/checkPendingMigrationsForCustomer.js"; import { lazyResetSubjectEntitlements } from "../../actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js"; +import { lazyResetSubjectUsageWindows } from "../../actions/resetUsageWindows/lazyResetSubjectUsageWindows.js"; import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js"; import { runWithFullSubjectGate } from "./getFullSubjectGate.js"; import { getFullSubjectQuery } from "./getFullSubjectQuery.js"; @@ -59,6 +60,7 @@ export async function getFullSubject({ allowMissingEntity, }); await lazyResetSubjectEntitlements({ ctx, fullSubject }); + await lazyResetSubjectUsageWindows({ ctx, fullSubject }); await checkPendingMigrationsForCustomer({ ctx, fullCustomer: fullSubjectToFullCustomer({ fullSubject }), @@ -113,6 +115,7 @@ export async function getFullSubjectNormalized({ const fullSubject = normalizedToFullSubject({ normalized }); await lazyResetSubjectEntitlements({ ctx, fullSubject, normalized }); + await lazyResetSubjectUsageWindows({ ctx, fullSubject, normalized }); await checkPendingMigrationsForCustomer({ ctx, fullCustomer: fullSubjectToFullCustomer({ fullSubject }), diff --git a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts index dd6534bdf..6bdbe85a2 100644 --- a/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts +++ b/server/src/internal/customers/repos/getFullSubject/getFullSubjectRowsQuery.ts @@ -204,7 +204,9 @@ export const getFullSubjectRowsQuery = ({ cus_usage_windows AS ( SELECT uw.* FROM usage_windows uw - WHERE uw.customer_entitlement_id IN (SELECT id FROM all_cus_ent_ids) + WHERE uw.internal_customer_id IN ( + SELECT internal_customer_id FROM subject_records + ) ), cus_replaceables AS ( @@ -406,11 +408,7 @@ export const getFullSubjectRowsQuery = ({ ORDER BY uw.window_start_at ASC, uw.id ASC ) FROM cus_usage_windows uw - WHERE uw.customer_entitlement_id IN ( - SELECT ace.id - FROM all_cus_ent_ids ace - WHERE ace.subject_key = sr.subject_key - ) + WHERE uw.internal_customer_id = sr.internal_customer_id ), '[]'::json ) AS usage_windows, diff --git a/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts b/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts index 2e17b3262..9e916eda3 100644 --- a/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts +++ b/server/src/internal/customers/repos/getFullSubject/subjectQueryRowToNormalized.ts @@ -2,8 +2,8 @@ import type { FeatureOptions } from "@autumn/shared"; import { type AggregatedFeatureBalance, type AggregatedSubjectFlag, - type Customer, CusProductStatus, + type Customer, type DbCustomerEntitlement, type DbCustomerPrice, type DbFreeTrial, @@ -73,14 +73,6 @@ export const subjectQueryRowToNormalized = ({ replaceablesByCusEntId.set(replaceable.cus_ent_id, existing); } - const usageWindowsByCusEntId = new Map(); - for (const usageWindow of row.usage_windows) { - const existing = - usageWindowsByCusEntId.get(usageWindow.customer_entitlement_id) ?? []; - existing.push(usageWindow); - usageWindowsByCusEntId.set(usageWindow.customer_entitlement_id, existing); - } - const customerProductsById = new Map( row.customer_products.map( (customerProduct) => [customerProduct.id, customerProduct] as const, @@ -217,7 +209,6 @@ export const subjectQueryRowToNormalized = ({ entitlement: catalogEntitlement as EntitlementWithFeature, replaceables: replaceablesByCusEntId.get(customerEntitlement.id) ?? [], rollovers: rolloversByCusEntId.get(customerEntitlement.id) ?? [], - usage_windows: usageWindowsByCusEntId.get(customerEntitlement.id) ?? [], customerPrice: resolveCustomerPrice({ customerEntitlement, entitlement: catalogEntitlement as EntitlementWithFeature, @@ -294,6 +285,7 @@ export const subjectQueryRowToNormalized = ({ customer_products: row.customer_products, customer_entitlements: meteredCustomerEntitlements, customer_prices: row.customer_prices, + usage_windows: (row.usage_windows ?? []) as DbUsageWindow[], flags, products: row.products as DbProduct[], entitlements: row.entitlements as EntitlementWithFeature[], diff --git a/server/src/internal/customers/usageWindows/repos/index.ts b/server/src/internal/customers/usageWindows/repos/index.ts new file mode 100644 index 000000000..8a87812e0 --- /dev/null +++ b/server/src/internal/customers/usageWindows/repos/index.ts @@ -0,0 +1,5 @@ +import { rollUsageWindows } from "./rollUsageWindows"; + +export const usageWindowRepo = { + rollWindows: rollUsageWindows, +}; diff --git a/server/src/internal/customers/usageWindows/repos/rollUsageWindows.ts b/server/src/internal/customers/usageWindows/repos/rollUsageWindows.ts new file mode 100644 index 000000000..8ea909994 --- /dev/null +++ b/server/src/internal/customers/usageWindows/repos/rollUsageWindows.ts @@ -0,0 +1,32 @@ +import { usageWindows } from "@autumn/shared"; +import { eq, sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle"; +import type { UsageWindowRoll } from "@/internal/customers/actions/resetUsageWindows/computeUsageWindowRolls.js"; + +/** + * Rolls counter rows in place: advances bounds/anchor to the current + * derivation; zeroes the count only when its stored window closed (a + * bounds-only re-alignment, e.g. after a plan change, keeps the count). + */ +export const rollUsageWindows = async ({ + db, + rolls, + now, +}: { + db: DrizzleCli; + rolls: UsageWindowRoll[]; + now: number; +}): Promise => { + for (const roll of rolls) { + await db + .update(usageWindows) + .set({ + usage: roll.zero_usage ? 0 : sql`${usageWindows.usage}`, + window_start_at: roll.window_start_at, + window_end_at: roll.window_end_at, + anchor_customer_entitlement_id: roll.anchor_customer_entitlement_id, + updated_at: now, + }) + .where(eq(usageWindows.id, roll.id)); + } +}; diff --git a/server/src/internal/entities/actions/updateEntity.ts b/server/src/internal/entities/actions/updateEntity.ts index 08b476df8..c4209054c 100644 --- a/server/src/internal/entities/actions/updateEntity.ts +++ b/server/src/internal/entities/actions/updateEntity.ts @@ -43,6 +43,7 @@ export const updateEntity = async ({ const filteredUpdates = Object.fromEntries( Object.entries({ spend_limits: billing_controls?.spend_limits, + usage_limits: billing_controls?.usage_limits, usage_alerts: billing_controls?.usage_alerts, overage_allowed: billing_controls?.overage_allowed, }).filter(([, value]) => value !== undefined), diff --git a/server/src/internal/entities/entityUtils/getApiEntityV2/getApiEntityBaseV2.ts b/server/src/internal/entities/entityUtils/getApiEntityV2/getApiEntityBaseV2.ts index bb8b62338..d4c50c16c 100644 --- a/server/src/internal/entities/entityUtils/getApiEntityV2/getApiEntityBaseV2.ts +++ b/server/src/internal/entities/entityUtils/getApiEntityV2/getApiEntityBaseV2.ts @@ -3,7 +3,9 @@ import { ApiEntityV2Schema, type EntityLegacyData, type FullSubject, + fullSubjectToApiUsageLimits, InternalError, + orgToInStatuses, scopeExpandForCtx, } from "@autumn/shared"; import type { RequestContext } from "@/honoUtils/HonoEnv.js"; @@ -59,6 +61,12 @@ export const getApiEntityBaseV2 = async ({ flags, billing_controls: { spend_limits: entity.spend_limits ?? undefined, + usage_limits: fullSubjectToApiUsageLimits({ + fullSubject, + features: ctx.features, + inStatuses: orgToInStatuses({ org: ctx.org }), + source: "entity", + }), usage_alerts: entity.usage_alerts ?? undefined, overage_allowed: entity.overage_allowed ?? undefined, }, diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index 59a08b93d..8c8f1b4ba 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -1,100 +1,30 @@ import type { TestGroup } from "./types"; const activeTempPaths = [ - "integration/billing/attach/free-trial/trial-basic.test.ts", - "integration/billing/attach/free-trial/trial-conversion.test.ts", - "integration/billing/attach/free-trial/trial-downgrade.test.ts", - "integration/billing/attach/free-trial/trial-entity-upgrade.test.ts", - "integration/billing/attach/free-trial/trial-merge.test.ts", -]; - -export const tempBacklogPhases = [ - [ - "unit/billing/setup-billing-cycle-anchor.spec.ts", - "unit/billing/stripe-backdate-start-date-utils.spec.ts", - "unit/billing/stripe/discounts/apply-stripe-discounts-to-line-items.spec.ts", - "integration/billing/attach/params/start-date/starts-at-backdate.test.ts", - "integration/billing/attach/params/start-date/starts-at-backdate-invoice.test.ts", - ], - [ - "integration/billing/attach/params/start-date/starts-at-backdate-new-billing-subscription.test.ts", - "integration/billing/attach/params/start-date/starts-at-backdate-scheduled-replacement.test.ts", - "integration/billing/attach/params/start-date/starts-at-validation.test.ts", - "integration/billing/attach/params/start-date/starts-at-scheduling.test.ts", - "integration/billing/attach/params/start-date/starts-at-enable-plan-immediately.test.ts", - ], - [ - "integration/billing/attach/new-plan/attach-paid.test.ts", - "integration/billing/attach/new-plan/attach-addon.test.ts", - "integration/billing/attach/new-plan/attach-entities.test.ts", - "integration/billing/attach/new-plan/new-prepaid.test.ts", - "integration/billing/attach/new-plan/prepaid", - ], - [ - "integration/billing/attach/free-trial", - "integration/billing/attach/free-trial/override", - "integration/billing/attach/params/plan-schedule", - "integration/billing/attach/params/billing-cycle-anchor", - "integration/billing/attach/params/custom-plan/custom-plan-entity.test.ts", - ], - [ - "integration/billing/attach/discounts", - "integration/billing/attach/immediate-switch", - "integration/billing/attach/scheduled-switch", - "integration/billing/attach/checkout/stripe-checkout/stripe-checkout-entities.test.ts", - "integration/billing/attach/checkout/stripe-checkout/stripe-checkout-multi-interval.test.ts", - ], - [ - "integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-entities.test.ts", - "integration/billing/attach/invoice/attach-invoice-finalized-immediate.test.ts", - "integration/billing/attach/invoice/attach-invoice-draft-immediate.test.ts", - "integration/billing/attach/invoice-line-items/backdate-line-items.test.ts", - "integration/billing/attach/invoice-line-items/line-item-discounts.test.ts", - ], - [ - "integration/billing/multi-attach/basic", - "integration/billing/multi-attach/customize", - "integration/billing/multi-attach/multi-attach-paid-features.test.ts", - "integration/billing/multi-attach/multi-attach-multi-interval.test.ts", - "integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts", - ], - [ - "integration/billing/multi-attach/scheduled-switch", - "integration/billing/create-schedule/backdate/create-schedule-backdate.test.ts", - "integration/billing/create-schedule/create-schedule-annual-proration.test.ts", - "integration/billing/create-schedule/phases/create-schedule-phases.test.ts", - "integration/billing/create-schedule/phases/create-schedule-phases-checkout.test.ts", - ], - [ - "integration/billing/create-schedule/phases/create-schedule-phases-replacements.test.ts", - "integration/billing/create-schedule/phases/create-schedule-phases-schedules.test.ts", - "integration/billing/create-schedule/phases/create-schedule-phases-validation.test.ts", - "integration/billing/create-schedule/params/create-schedule-enable-plan-immediately.test.ts", - "integration/billing/create-schedule/params/create-schedule-customize.test.ts", - ], - [ - "integration/billing/create-schedule/params/create-schedule-subscription-id.test.ts", - "integration/billing/create-schedule/one-off-prepaid-preserve/preserve-on-schedule.test.ts", - "integration/billing/update-subscription/billing-behavior/next-cycle-only.test.ts", - "integration/billing/update-subscription/billing-behavior/next-cycle-only-cancel.test.ts", - "integration/billing/update-subscription/discounts/proration-discount.test.ts", - ], - [ - "integration/billing/update-subscription/discounts/discount-applies-to.test.ts", - "integration/billing/update-subscription/discounts/multiple-discounts.test.ts", - "integration/billing/update-subscription/free-trial", - "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-with-changes.test.ts", - "integration/billing/update-subscription/params/billing-cycle-anchor/update-sub-anchor-reset-no-partial-refund.test.ts", - ], - [ - "integration/billing/stripe-webhooks/invoice-created/invoice-created-multi-interval.test.ts", - ], + "integration/balances/usage-windows/usage-window-enforcement.test.ts", + "integration/balances/usage-windows/usage-window-own-feature.test.ts", + "integration/balances/usage-windows/usage-window-persistence.test.ts", + "integration/balances/usage-windows/usage-window-reset.test.ts", + "integration/balances/usage-windows/plan-changes/plan-change-upgrade.test.ts", + "integration/balances/usage-windows/plan-changes/plan-change-anchor.test.ts", + "integration/balances/usage-windows/plan-changes/plan-change-replacement.test.ts", + "integration/balances/usage-windows/plan-changes/plan-change-scheduled.test.ts", + "integration/balances/usage-windows/plan-changes/plan-change-update.test.ts", + "integration/balances/usage-windows/usage-window-sync.test.ts", + "integration/balances/usage-windows/usage-window-api.test.ts", + "integration/balances/usage-windows/usage-window-check.test.ts", + "integration/balances/usage-windows/usage-window-lock.test.ts", + "unit/full-subject-cache/setSharedFullSubjectBalances.test.ts", + "unit/usage-windows/buildUsageWindowKey.test.ts", + "unit/usage-windows/computeUsageWindowRolls.test.ts", + "unit/usage-windows/fullSubjectToUsageWindowLimits.test.ts", + "unit/usage-windows/getUsageWindowBounds.test.ts", + "unit/usage-windows/pickAnchorCustomerEntitlementId.test.ts", ]; export const temp: TestGroup = { name: "temp", - description: - "active temp slice for starts_at and next-cycle preview regressions", + description: "usage-windows PR tests (uw-1-storage review)", tier: "domain", paths: activeTempPaths, maxConcurrency: 2, diff --git a/server/tests/_temp/cycle-differential-sweep.ts b/server/tests/_temp/cycle-differential-sweep.ts new file mode 100644 index 000000000..45be113ee --- /dev/null +++ b/server/tests/_temp/cycle-differential-sweep.ts @@ -0,0 +1,210 @@ +/** + * Differential sweep: OLD (origin/main) getCycleStart/getCycleEnd vs NEW + * (bracket walk), both checked against an independent reference = the unique + * lattice boundary pair bracketing `now` (unique because add(anchor, k) is + * strictly increasing in k for every interval type). + * + * Verdict criteria: + * - newDeviations MUST be 0 (new always matches the ground truth) + * - every divergence between old and new MUST be an input where old != ref + * (i.e. the fix only changes outputs that were provably wrong) + */ + +import { UTCDate } from "@date-fns/utc"; +import { + addDays, + addHours, + addMonths, + addWeeks, + addYears, + differenceInDays, + differenceInHours, + differenceInMonths, + differenceInWeeks, + differenceInYears, +} from "date-fns"; +import { + BillingInterval, + EntInterval, + getCycleEnd, + getCycleStart, +} from "@autumn/shared"; + +type Fns = { + add: (d: Date, n: number) => Date; + diff: (l: Date, e: Date) => number; +}; + +const FNS: Record = { + hour: { add: addHours, diff: differenceInHours }, + day: { add: addDays, diff: differenceInDays }, + week: { add: addWeeks, diff: differenceInWeeks }, + month: { add: addMonths, diff: differenceInMonths }, + quarter: { + add: (d, n) => addMonths(d, n * 3), + diff: (l, e) => Math.floor(differenceInMonths(l, e) / 3), + }, + semi_annual: { + add: (d, n) => addMonths(d, n * 6), + diff: (l, e) => Math.floor(differenceInMonths(l, e) / 6), + }, + year: { add: addYears, diff: differenceInYears }, +}; + +const INTERVAL_ENUM: Record = { + hour: EntInterval.Hour, + day: EntInterval.Day, + week: BillingInterval.Week, + month: BillingInterval.Month, + quarter: BillingInterval.Quarter, + semi_annual: BillingInterval.SemiAnnual, + year: BillingInterval.Year, +}; + +// --- OLD implementations, verbatim logic from origin/main --- +const oldStart = (fns: Fns, anchor: number, c: number, now: number) => { + const a = new UTCDate(anchor); + const k = Math.floor(fns.diff(new UTCDate(now), a) / c); + const cycleStart = fns.add(a, k * c); + if (cycleStart.getTime() > now) return fns.add(a, (k - 1) * c).getTime(); + return cycleStart.getTime(); +}; + +const oldEnd = (fns: Fns, anchor: number, c: number, now: number) => { + const a = new UTCDate(anchor); + const k = Math.floor(fns.diff(new UTCDate(now), a) / c); + const candidate = fns.add(a, k * c); + if (candidate.getTime() > now) return candidate.getTime(); + return fns.add(a, (k + 1) * c).getTime(); +}; + +// --- Independent reference: walk to the unique bracketing k --- +const ref = (fns: Fns, anchor: number, c: number, now: number) => { + const a = new UTCDate(anchor); + let k = Math.floor(fns.diff(new UTCDate(now), a) / c); + let guard = 0; + while (fns.add(a, (k + 1) * c).getTime() <= now) { + k++; + if (++guard > 10_000) throw new Error("ref walk diverged (up)"); + } + while (fns.add(a, k * c).getTime() > now) { + k--; + if (++guard > 10_000) throw new Error("ref walk diverged (down)"); + } + const start = fns.add(a, k * c).getTime(); + const end = fns.add(a, (k + 1) * c).getTime(); + if (!(start <= now && now < end)) throw new Error("ref bracket violated"); + return { start, end }; +}; + +const fmt = (ms: number) => + new UTCDate(ms).toISOString().replace("T", " ").slice(0, 16); + +type Config = { + name: string; + interval: string; + c: number; + anchors: number[]; + sweepStart: number; + sweepEnd: number; + stepMs: number; +}; + +const d = ( + y: number, + mo: number, + day: number, + h = 10, + mi = 0, +): number => new UTCDate(y, mo - 1, day, h, mi, 0).getTime(); + +const HOUR = 3_600_000; +const jan2025Days = (days: number[]) => days.map((dd) => d(2025, 1, dd)); +const ALL_JAN_DAYS = jan2025Days( + Array.from({ length: 31 }, (_, index) => index + 1), +); +const EOM_ANCHORS = jan2025Days([2, 15, 28, 29, 30, 31]); + +const configs: Config[] = [ + { name: "month c=1 (all 31 anchor days, incl. future-anchor region)", interval: "month", c: 1, anchors: ALL_JAN_DAYS, sweepStart: d(2024, 7, 1), sweepEnd: d(2026, 7, 1), stepMs: 6 * HOUR }, + { name: "month c=2", interval: "month", c: 2, anchors: EOM_ANCHORS, sweepStart: d(2024, 7, 1), sweepEnd: d(2026, 7, 1), stepMs: 6 * HOUR }, + { name: "month c=3", interval: "month", c: 3, anchors: EOM_ANCHORS, sweepStart: d(2024, 7, 1), sweepEnd: d(2026, 7, 1), stepMs: 6 * HOUR }, + { name: "quarter c=1", interval: "quarter", c: 1, anchors: EOM_ANCHORS, sweepStart: d(2024, 7, 1), sweepEnd: d(2026, 7, 1), stepMs: 6 * HOUR }, + { name: "semi_annual c=1 (incl. Aug 31 anchor -> Feb 28 clamp)", interval: "semi_annual", c: 1, anchors: [...EOM_ANCHORS, d(2024, 8, 31)], sweepStart: d(2024, 1, 1), sweepEnd: d(2027, 1, 1), stepMs: 12 * HOUR }, + { name: "year c=1 (incl. Feb 29 leap anchor)", interval: "year", c: 1, anchors: [d(2024, 2, 29, 12), d(2023, 2, 28, 12), d(2024, 12, 31), d(2025, 1, 1)], sweepStart: d(2023, 6, 1), sweepEnd: d(2027, 6, 1), stepMs: 12 * HOUR }, + { name: "week c=1", interval: "week", c: 1, anchors: [d(2025, 1, 7, 13, 37)], sweepStart: d(2024, 11, 1), sweepEnd: d(2025, 11, 1), stepMs: 3 * HOUR }, + { name: "day c=1", interval: "day", c: 1, anchors: [d(2025, 1, 7, 13, 37)], sweepStart: d(2024, 12, 1), sweepEnd: d(2025, 4, 1), stepMs: 1 * HOUR }, + { name: "hour c=1", interval: "hour", c: 1, anchors: [d(2025, 1, 7, 13, 37)], sweepStart: d(2025, 1, 1), sweepEnd: d(2025, 1, 14), stepMs: 7 * 60_000 }, +]; + +let totalCombos = 0; +let totalOldWrong = 0; +let totalNewWrong = 0; +let totalChangedWhileOldCorrect = 0; + +for (const cfg of configs) { + const fns = FNS[cfg.interval]; + const intervalEnum = INTERVAL_ENUM[cfg.interval]; + let combos = 0; + let oldWrong = 0; + let newWrong = 0; + let changedWhileOldCorrect = 0; + const samples: string[] = []; + + const checkOne = (anchor: number, now: number) => { + combos++; + const r = ref(fns, anchor, cfg.c, now); + const os = oldStart(fns, anchor, cfg.c, now); + const oe = oldEnd(fns, anchor, cfg.c, now); + const ns = getCycleStart({ anchor, interval: intervalEnum, intervalCount: cfg.c, now }); + const ne = getCycleEnd({ anchor, interval: intervalEnum, intervalCount: cfg.c, now }); + + const oldOk = os === r.start && oe === r.end; + const newOk = ns === r.start && ne === r.end; + if (!newOk) { + newWrong++; + samples.push(`NEW WRONG anchor=${fmt(anchor)} now=${fmt(now)} new=[${fmt(ns)},${fmt(ne)}) ref=[${fmt(r.start)},${fmt(r.end)})`); + } + if (!oldOk) { + oldWrong++; + if (samples.length < 4) { + samples.push(`old wrong: anchor=${fmt(anchor)} now=${fmt(now)} old=[${fmt(os)},${fmt(oe)}) ref=[${fmt(r.start)},${fmt(r.end)})`); + } + } + if (oldOk && (ns !== os || ne !== oe)) { + changedWhileOldCorrect++; + samples.push(`REGRESSION anchor=${fmt(anchor)} now=${fmt(now)} old=[${fmt(os)},${fmt(oe)}) new=[${fmt(ns)},${fmt(ne)})`); + } + }; + + for (const anchor of cfg.anchors) { + for (let now = cfg.sweepStart; now < cfg.sweepEnd; now += cfg.stepMs) { + checkOne(anchor, now); + } + // exact boundary instants ±1ms for the first 24 boundaries after sweepStart + let k = 0; + for (;;) { + const b = fns.add(new UTCDate(anchor), k * cfg.c).getTime(); + if (b > cfg.sweepEnd || k > 24) break; + if (b >= cfg.sweepStart) { + checkOne(anchor, b - 1); + checkOne(anchor, b); + checkOne(anchor, b + 1); + } + k++; + } + } + + totalCombos += combos; + totalOldWrong += oldWrong; + totalNewWrong += newWrong; + totalChangedWhileOldCorrect += changedWhileOldCorrect; + console.log(`${cfg.name.padEnd(58)} combos=${String(combos).padStart(7)} old-wrong=${String(oldWrong).padStart(5)} new-wrong=${newWrong} changed-while-old-correct=${changedWhileOldCorrect}`); + for (const s of samples.slice(0, 3)) console.log(` ${s}`); +} + +console.log("\n=== TOTALS ==="); +console.log(`combos tested: ${totalCombos}`); +console.log(`old deviates from truth: ${totalOldWrong}`); +console.log(`new deviates from truth: ${totalNewWrong} <- must be 0`); +console.log(`new changed a correct old: ${totalChangedWhileOldCorrect} <- must be 0`); diff --git a/server/tests/integration/balances/track/usage-limit/track-customer-usage-limit.test.ts b/server/tests/integration/balances/track/usage-limit/track-customer-usage-limit.test.ts deleted file mode 100644 index 237444ec0..000000000 --- a/server/tests/integration/balances/track/usage-limit/track-customer-usage-limit.test.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { expect, test } from "bun:test"; -import { - type ApiCustomerV5, - type CustomerBillingControls, - EntInterval, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { items } from "@tests/utils/fixtures/items.js"; -import { products } from "@tests/utils/fixtures/products.js"; -import { timeout } from "@tests/utils/genUtils.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; -import { sql } from "drizzle-orm"; -import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js"; -import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; - -// biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped -const queryRows = (result: unknown): any[] => - // biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped - Array.isArray(result) ? result : ((result as { rows?: any[] })?.rows ?? []); - -type AutumnV2_1Client = Awaited>["autumnV2_1"]; - -// Arms a windowed usage cap via spend_limits[].usage_limit (overage off); -// `interval` sets the explicit window override. -const setCustomerUsageLimit = async ({ - autumn, - customerId, - featureId, - limit, - interval = EntInterval.Month, -}: { - autumn: AutumnV2_1Client; - customerId: string; - featureId: string; - limit: number; - interval?: EntInterval; -}) => { - const billingControls: CustomerBillingControls = { - spend_limits: [ - { - feature_id: featureId, - enabled: false, - usage_limit: limit, - usage_limit_interval: interval, - }, - ], - }; - - await timeout(2000); - await autumn.customers.update(customerId, { - billing_controls: billingControls, - }); - await timeout(3000); -}; - -// Credit system: 100 credits, 1 action1 = 0.2 credits (see v2Features.ts). -// A cap of 5 action1 units consumes only 1 credit, so the cap must clamp the -// 6th unit while ~99 credits remain, proving it's a second, independent -// dimension, not a balance check. -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit1: per-feature cap clamps the over-cap unit while credits remain")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-usage-limit", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - - const customerId = `track-customer-usage-limit-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Action1, - limit: 5, - }); - - // Consume exactly up to the cap: 5 action1 units = 1 credit deducted. Assert - // the synchronous track response; a re-read races the async write-through. - const consumed = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 5, - }); - expect(consumed.balances?.[TestFeature.Credits]).toMatchObject({ - feature_id: TestFeature.Credits, - granted: 100, - remaining: 99, - usage: 1, - }); - - // The 6th unit is over the cap, so it clamps to 0: the track succeeds but - // applies nothing, leaving credits unchanged. - const overCap = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 1, - }); - expect(overCap.balances?.[TestFeature.Credits]).toMatchObject({ - granted: 100, - remaining: 99, - usage: 1, - }); - }, -); - -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit2: credit-pool sub-interval cap (1 credit/day) blocks while monthly credits remain")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-credit-day-cap", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - - const customerId = `track-customer-credit-day-cap-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - // 1 action1 = 0.2 credits, so 5 action1 = exactly 1 credit (the daily cap). - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Credits, - limit: 1, - interval: EntInterval.Day, - }); - - const consumed = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 5, - }); - expect(consumed.balances?.[TestFeature.Credits]).toMatchObject({ - feature_id: TestFeature.Credits, - granted: 100, - remaining: 99, - usage: 1, - }); - - let blocked = false; - let blockedCode: string | undefined; - try { - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 1, - }); - } catch (error) { - blocked = true; - blockedCode = (error as { code?: string }).code; - } - - expect(blocked).toBe(true); - expect(blockedCode).toBe("usage_limit_exceeded"); - }, -); - -// set_usage must be rejected when the feature has an enforced usage window; -// otherwise it bypasses the hard cap (it carries no window provenance). -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit3: set_usage is rejected when the feature has a usage window")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-setusage-guard", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const customerId = `track-customer-setusage-guard-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Messages, - limit: 5, - }); - - let blockedCode: string | undefined; - try { - await autumnV2_1.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - } catch (error) { - blockedCode = (error as { code?: string }).code; - } - - expect(blockedCode).toBe("set_usage_not_allowed_with_usage_limit"); - }, -); - -// A single spend_limit entry carrying BOTH an overage_limit and a windowed usage -// cap must still clamp on the window (the two caps are independent). -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit4: a spend_limit with both overage_limit and a usage window clamps the window")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-compound-cap", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - - const customerId = `track-customer-compound-cap-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - const billingControls: CustomerBillingControls = { - spend_limits: [ - { - feature_id: TestFeature.Action1, - enabled: true, - overage_limit: 20, - usage_limit: 5, - usage_limit_interval: EntInterval.Month, - }, - ], - }; - await timeout(2000); - await autumnV2_1.customers.update(customerId, { - billing_controls: billingControls, - }); - await timeout(3000); - - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 5, - }); - - // The window cap clamps the over-cap unit to 0 (the overage path is separate), - // so the track succeeds and credits are unchanged. - const overCap = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 1, - }); - expect(overCap.balances?.[TestFeature.Credits]).toMatchObject({ - remaining: 99, - usage: 1, - }); - }, -); - -// Two concurrent tracks on the SAME customer's SAME window must serialize (Redis -// runs each deduction Lua atomically): combined value exceeds the cap, so the -// second track clamps and the counter reflects exactly the capped usage. -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit6: concurrent tracks on one window serialize, total clamped to the cap")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-concurrent-cap", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - - const customerId = `track-customer-concurrent-cap-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - // Cap action1 at 5/month; two concurrent tracks of 5 each => combined 10 > 5. - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Action1, - limit: 5, - }); - - const results = await Promise.allSettled([ - autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 5, - }), - autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 5, - }), - ]); - - // Both succeed now (clamp, not reject), but the window clamps the combined - // applied usage to the cap: one applies 5, the other clamps to 0. - expect(results.every((result) => result.status === "fulfilled")).toBe(true); - - await timeout(2000); - const final = await autumnV2_1.customers.get(customerId); - expect(final.balances?.[TestFeature.Credits]).toMatchObject({ - feature_id: TestFeature.Credits, - remaining: 99, - usage: 1, - }); - }, -); - -// Write-through: the Redis counter must reach the usage_windows table via the -// shared sync (the other tests assert only the synchronous Redis response). -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit-sync: window counter writes through to the usage_windows table")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-uw-sync", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - - const customerId = `track-customer-uw-sync-1-${Date.now()}`; - const { autumnV2_1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Credits, - limit: 5, - interval: EntInterval.Day, - }); - - // 5 action1 = 1 credit; under the 5-credit/day cap. The counter lives on the - // credits cus-ent (balance dimension). - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 5, - }); - - const creditsEnt = queryRows( - await ctx.db.execute(sql` - SELECT id, internal_feature_id FROM customer_entitlements - WHERE customer_id = ${customerId} AND feature_id = ${TestFeature.Credits} - LIMIT 1 - `), - )[0]; - expect(creditsEnt?.id).toBeTruthy(); - - // Drive the async write-through synchronously, then assert the mirrored row. - await syncItemV4({ - ctx, - payload: { - customerId, - orgId: ctx.org.id, - env: ctx.env, - timestamp: Date.now(), - modifiedCusEntIdsByFeatureId: { - [TestFeature.Credits]: [creditsEnt.id], - }, - }, - }); - - const windowRows = queryRows( - await ctx.db.execute(sql` - SELECT feature_id, internal_feature_id, usage - FROM usage_windows WHERE customer_entitlement_id = ${creditsEnt.id} - `), - ); - expect(windowRows).toHaveLength(1); - expect(windowRows[0].feature_id).toBe(TestFeature.Credits); - expect(windowRows[0].internal_feature_id).toBe( - creditsEnt.internal_feature_id, - ); - expect(Number(windowRows[0].usage)).toBeCloseTo(1, 5); - }, -); - -// Deploy-migration safety: a leftover pre-array keyed-map blob must be reset to a -// clean array, never iterated-then-corrupted into a JSON object that wedges sync. -test.concurrent( - `${chalk.yellowBright("track-customer-usage-limit-legacy: a pre-array keyed-map blob is reset, not corrupted into an object")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-uw-legacy", - items: [items.monthlyCredits({ includedUsage: 100 })], - }); - - const customerId = `track-customer-uw-legacy-1-${Date.now()}`; - const { autumnV2_1, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Credits, - limit: 5, - interval: EntInterval.Day, - }); - - // One track creates a proper array blob on the credits cus-ent. - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 1, - }); - - const creditsEnt = queryRows( - await ctx.db.execute(sql` - SELECT id FROM customer_entitlements - WHERE customer_id = ${customerId} AND feature_id = ${TestFeature.Credits} - LIMIT 1 - `), - )[0]; - expect(creditsEnt?.id).toBeTruthy(); - - const balanceKey = buildSharedFullSubjectBalanceKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId, - featureId: TestFeature.Credits, - }); - - // Overwrite usage_windows with a LEGACY keyed-map shape (the pre-array format - // ipairs would skip and table.insert would corrupt into a JSON object). - const blobJson = await ctx.redisV2.hget(balanceKey, creditsEnt.id); - expect(blobJson).toBeTruthy(); - const blob = JSON.parse(blobJson as string); - blob.usage_windows = { - "customer:balance:credits:day:legacy": { - key: "customer:balance:credits:day:legacy", - usage_amount: 0.2, - window_start_at: 1_700_000_000_000, - window_end_at: 9_999_999_999_999, - dimension_type: "balance", - interval: "day", - }, - }; - await ctx.redisV2.hset(balanceKey, creditsEnt.id, JSON.stringify(blob)); - - // The next track must RESET the map blob to a clean array, not corrupt it. - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Action1, - value: 1, - }); - - const after = JSON.parse( - (await ctx.redisV2.hget(balanceKey, creditsEnt.id)) as string, - ); - // The poison case is a JSON OBJECT (string keys); it must be a clean array. - expect(Array.isArray(after.usage_windows)).toBe(true); - expect(after.usage_windows).toHaveLength(1); - expect(after.usage_windows[0].feature_id).toBe(TestFeature.Credits); - expect(typeof after.usage_windows[0].id).toBe("string"); - }, -); - -// No manual sync flush: the counter must survive the mutation's cache invalidation on -// its own, else the cap silently resets and hands out fresh headroom. -test( - `${chalk.yellowBright("track-customer-usage-limit-lowercap: lowering the cap below current usage keeps the counter (clamps, no reset)")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-uw-lowercap", - items: [items.monthlyMessages({ includedUsage: 1000 })], - }); - - const customerId = `track-customer-uw-lowercap-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Messages, - limit: 10, - }); - - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 8, - }); - - await autumnV2_1.customers.update(customerId, { - billing_controls: { - spend_limits: [ - { - feature_id: TestFeature.Messages, - enabled: false, - usage_limit: 3, - usage_limit_interval: EntInterval.Month, - }, - ], - }, - }); - - const clamped = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 1, - }); - expect(clamped.balance).toMatchObject({ - remaining: 992, - usage: 8, - }); - }, -); - -// Bug 1: a second balance grant (balances.create) is a cache-invalidating mutation; -// the cap counter must survive it. It used to reset to 0, opening fresh headroom. -test( - `${chalk.yellowBright("track-customer-usage-limit-regrant: the cap counter survives a re-grant (clamps)")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-uw-regrant", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const customerId = `track-customer-uw-regrant-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Messages, - limit: 5, - }); - - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 5, - }); - - const clampedBefore = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 1, - }); - expect(clampedBefore.balance).toMatchObject({ usage: 5 }); - - // Re-grant a second balance for the same feature while at the cap. - await autumnV2_1.post("/balances.create", { - customer_id: customerId, - feature_id: TestFeature.Messages, - included_grant: 100, - reset: { interval: EntInterval.Month }, - }); - - const clampedAfter = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 1, - }); - expect(clampedAfter.balance).toMatchObject({ usage: 5 }); - }, -); - -// Q1 clamp: an over-cap track applies what fits (the remaining headroom) instead of -// rejecting the whole track. cap 5, track 10 from 0 -> applies 5 (not 10, not a 400). -test( - `${chalk.yellowBright("track-customer-usage-limit-clamp: over-cap track applies what fits (clamp, not reject)")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-uw-clamp", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const customerId = `track-customer-uw-clamp-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Messages, - limit: 5, - }); - - // Track 10 against a cap of 5 (from 0): clamps to 5, returns 200, not a reject. - const clamped = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 10, - }); - expect(clamped.value).toBe(10); - expect(clamped.balance).toMatchObject({ remaining: 95, usage: 5 }); - - // At the cap: a further track applies 0 (fully clamped), still 200. - const atCap = await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 3, - }); - expect(atCap.balance).toMatchObject({ remaining: 95, usage: 5 }); - }, -); - -// Q2: the spend_limit in the customer response exposes the current window usage. -test( - `${chalk.yellowBright("track-customer-usage-limit-counter: spend_limit exposes the current window usage")}`, - async () => { - const customerProduct = products.base({ - id: "track-customer-uw-counter", - items: [items.monthlyMessages({ includedUsage: 100 })], - }); - - const customerId = `track-customer-uw-counter-1-${Date.now()}`; - const { autumnV2_1 } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success", testClock: false }), - s.products({ list: [customerProduct] }), - ], - actions: [s.billing.attach({ productId: customerProduct.id })], - }); - - await setCustomerUsageLimit({ - autumn: autumnV2_1, - customerId, - featureId: TestFeature.Messages, - limit: 5, - }); - - await autumnV2_1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 3, - }); - - const customer = (await autumnV2_1.get( - `/customers/${customerId}`, - )) as ApiCustomerV5; - const limit = customer.billing_controls?.spend_limits?.find( - (entry) => entry.feature_id === TestFeature.Messages, - ); - expect(limit?.usage_limit_used).toBe(3); - }, -); diff --git a/server/tests/integration/balances/usage-windows/entities/entity-usage-window-check.test.ts b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-check.test.ts new file mode 100644 index 000000000..dcb09a9da --- /dev/null +++ b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-check.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { setCustomerUsageLimit } from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + expectEntityUsageLimit, + setEntityUsageLimit, +} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js"; + +/** + * TDD tests for CHECK against entity-level usage limits. + * + * Contract under test: + * - an entity's own cap gates that entity's checks: required_balance within + * the entity window's headroom -> allowed true; beyond -> allowed false, + * even with ample balance; pure checks never consume window headroom + * - a sibling entity with no cap anywhere is unconstrained + * - carve-out checks: an entity with its own cap checks against IT, while a + * capless entity checks against the customer's aggregate window + */ + +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("ent-uw-check1: entity's own cap gates that entity's checks only")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-check-own-cap", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-check-1"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 3, + }); + + // ── Headroom is 2: within allowed, beyond rejected despite 97 balance ── + const within = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 2, + }); + expect(within.allowed).toBe(true); + + const beyond = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 3, + }); + expect(beyond.allowed).toBe(false); + + // ── Pure checks never consume: window usage still 3 ── + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // ── The capless sibling entity is unconstrained (full balance) ── + const sibling = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + required_balance: 50, + }); + expect(sibling.allowed).toBe(true); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-check2: carved-out entity checks its own cap, capless entity checks the aggregate")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-check-carveout", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-check-2"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + limit: 2, + }); + + await autumnV2_3.customers.get(customerId); // initialize cache. + for (const entity of entities) { + await autumnV2_3.entities.get(customerId, entity.id); // initialize cache. + } + + // ── e1 (own cap 2): check 3 rejected even though the aggregate has 5 ── + const carvedBeyond = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + required_balance: 3, + }); + expect(carvedBeyond.allowed).toBe(false); + + const carvedWithin = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + required_balance: 2, + }); + expect(carvedWithin.allowed).toBe(true); + + // ── e0 (capless) checks the aggregate: 5 fits, 6 does not ── + const aggregateWithin = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 5, + }); + expect(aggregateWithin.allowed).toBe(true); + + const aggregateBeyond = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 6, + }); + expect(aggregateBeyond.allowed).toBe(false); + + // ── e0's tracks fill the aggregate; e1's own cap is untouched by them ── + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 5, + }); + + const aggregateFull = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + required_balance: 1, + }); + expect(aggregateFull.allowed).toBe(false); + + const carvedStillOpen = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + required_balance: 2, + }); + expect(carvedStillOpen.allowed).toBe(true); + }, +); diff --git a/server/tests/integration/balances/usage-windows/entities/entity-usage-window-credits.test.ts b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-credits.test.ts new file mode 100644 index 000000000..40af8537d --- /dev/null +++ b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-credits.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, ErrCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { expectEntityFeatureBalance } from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; +import { + expectEntityUsageLimit, + setEntityUsageLimit, +} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js"; + +/** + * TDD test for an ENTITY-LEVEL usage limit on action1 when the entity is + * funded by prepaid + consumable CREDITS (metered cap on a credit-system + * member feature; 1 action1 unit = 0.2 credits). + * + * Contract under test: + * - check on action1 (entity subject) is gated by the entity cap's remaining + * headroom in ACTION1 UNITS, while hundreds of credits remain + * - track clamps at the cap: only the allowed units drain credits + * (prepaid + consumable cusEnts, breakdown 2) + * - over-cap track with reject -> InsufficientBalance + * - entities.get reports the cap's window usage + * - a direct credits check is NOT gated by the action1 cap + */ + +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("ent-uw-credits1: entity cap on action1 over prepaid + consumable credits")}`, + async () => { + const prepaidQuantity = 300; + const consumableIncluded = 200; + // granted = prepaid quantity + consumable included usage, per entity. + const grantedCredits = prepaidQuantity + consumableIncluded; + const action1CreditCost = 0.2; + + const perEntityProduct = products.base({ + id: "ent-uw-credits-prepaid-consumable", + items: [ + items.prepaid({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 100, + price: 8.5, + entityFeatureId: TestFeature.Users, + }), + items.consumable({ + featureId: TestFeature.Credits, + includedUsage: consumableIncluded, + price: 0.5, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-credits-1"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ + productId: perEntityProduct.id, + options: [ + { feature_id: TestFeature.Credits, quantity: prepaidQuantity }, + ], + }), + ], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Action1, + limit: 5, + }); + + // ── 3 of 5 units used (= 0.6 credits) ── + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: 3, + }); + + // ── Check converts cap headroom in action1 units: 2 left ── + const within = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + required_balance: 2, + }); + expect(within.allowed).toBe(true); + + const beyond = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + required_balance: 3, + }); + expect(beyond.allowed).toBe(false); + + // ── Over-cap track clamps: 4 requested, 2 applied (5 total = 1 credit) ── + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: 4, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Credits, + granted: grantedCredits, + remaining: grantedCredits - 5 * action1CreditCost, + usage: 5 * action1CreditCost, + breakdownLength: 2, + }); + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Action1, + usage: 5, + limit: 5, + }); + + // ── Cap exhausted: reject fires while ~499 credits remain ── + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Action1, + value: 1, + overage_behavior: "reject", + }), + }); + + // ── A direct credits check is not gated by the action1 cap ── + const creditsCheck = await autumnV2_3.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Credits, + required_balance: 100, + }); + expect(creditsCheck.allowed).toBe(true); + }, +); diff --git a/server/tests/integration/balances/usage-windows/entities/entity-usage-window-enforcement.test.ts b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-enforcement.test.ts new file mode 100644 index 000000000..b05e6950e --- /dev/null +++ b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-enforcement.test.ts @@ -0,0 +1,354 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, ErrCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerFeatureBalance, + expectEntityFeatureBalance, +} from "../../utils/spend-limit-utils/entitySpendLimitUtils.js"; +import { + expectEntityUsageLimit, + setEntityUsageLimit, +} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js"; +import { fetchUsageWindowRows } from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +/** + * TDD tests for ENTITY-LEVEL usage windows (spend-limit mirror semantics: + * exactly ONE cap per feature per subject — the entity's own usage_limits + * entry wins; without one the customer's entry applies at customer scope). + * + * Contract under test (enforcement half): + * - entities.update(..., { billing_controls: { usage_limits } }) arms a cap + * - entity tracks against an ENTITY-SCOPED window: counts only that entity's + * usage, clamps over-cap tracks to what fits ("apply what fits") + * - overage_behavior "reject" over the cap -> InsufficientBalance + * - windows are isolated between entities; customer balance still aggregates + * - entity cap works on customer-scoped features too (no entity_feature_id): + * only that entity's tracks count; customer-level tracks are uncapped + * - side effect: usage_windows PG row per entity with internal_entity_id set + * + * Pre-impl red: EntityBillingControls has no usage_limits, so the update is + * rejected/dropped and no cap is ever enforced. + */ + +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("ent-uw-enforce1: entity's own cap clamps that entity's tracks")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-enforce-own-cap", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-enforce-1"; + const { entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + + // ── Cap reached exactly: 3 then 4 applies only the remaining 2 ── + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 3, + }); + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 4, + }); + + await expectEntityFeatureBalance({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 95, + usage: 5, + }); + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // ── Over the cap: a further track applies nothing ── + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectEntityFeatureBalance({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + granted: 100, + remaining: 95, + usage: 5, + }); + + // ── Side effect: ONE window row, entity-scoped (no customer row) ── + const rows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rows).toHaveLength(1); + expect(rows[0].internal_entity_id).not.toBeNull(); + expect(Number(rows[0].usage)).toBe(5); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-enforce2: two entities with different caps stay isolated while customer balance aggregates")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-enforce-isolated", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-enforce-2"; + const { entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + limit: 10, + }); + + // e0: 7 -> clamps to 5. e1: 7 then 5 -> clamps to 10 total. + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 7, + }); + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 7, + }); + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 5, + }); + + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + usage: 10, + limit: 10, + }); + + // Balances aggregate at the customer even though windows are isolated. + await expectCustomerFeatureBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + granted: 200, + remaining: 185, + usage: 15, + }); + + // ── Side effect: one row per entity, distinct scopes ── + const rows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rows).toHaveLength(2); + const entityIds = rows.map( + (row: { internal_entity_id: string | null }) => row.internal_entity_id, + ); + expect(entityIds[0]).not.toBeNull(); + expect(entityIds[1]).not.toBeNull(); + expect(entityIds[0]).not.toBe(entityIds[1]); + expect( + rows + .map((row: { usage: string | number }) => Number(row.usage)) + .sort((a: number, b: number) => a - b), + ).toEqual([5, 10]); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-enforce3: over-cap entity track with reject returns InsufficientBalance")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-enforce-reject", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-enforce-3"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 5, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-enforce4: entity cap on a customer-scoped feature counts only that entity's tracks")}`, + async () => { + const customerProduct = products.base({ + id: "ent-uw-enforce-cus-feature", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "ent-uw-enforce-4"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await autumnV2_3.customers.get(customerId); // initialize cache. + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 3, + }); + + // Entity track over its cap: applies 3 of 5. + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 3, + limit: 3, + }); + + // Customer-level track is NOT bound by the entity's cap. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + await expectCustomerFeatureBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + granted: 100, + remaining: 87, + usage: 13, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/entities/entity-usage-window-inheritance.test.ts b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-inheritance.test.ts new file mode 100644 index 000000000..7e5667941 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-inheritance.test.ts @@ -0,0 +1,342 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, ErrCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + expectEntityUsageLimit, + setEntityUsageLimit, +} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js"; +import { fetchUsageWindowRows } from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +/** + * TDD tests for usage-limit INHERITANCE (spend-limit mirror: per feature the + * entity's own usage_limits entry wins; without one the customer's entry + * "fills the gap" and applies at CUSTOMER scope — one shared aggregate window + * across the customer and every entity without its own cap). + * + * Contract under test (inheritance half): + * - no entity entry -> entity tracks count into the shared customer window + * (inherit1 pins this aggregate behavior) + * - entity with its own entry is CARVED OUT: its tracks consume only its + * entity window, never the customer's aggregate window (inherit2) + * - arming an entity cap mid-window moves that entity to a fresh entity + * window; the customer window keeps its count (inherit3) + * + * Pre-impl red: inherit2/inherit3 fail because entity usage_limits don't + * exist, so every entity track still lands in the customer window. + */ + +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("ent-uw-inherit1: entities without their own cap share the customer's aggregate window")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-inherit-aggregate", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-inherit-1"; + const { entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.customers.get(customerId); // initialize cache. + for (const entity of entities) { + await autumnV2_3.entities.get(customerId, entity.id); // initialize cache. + } + + // e0: 3, e1: 1 -> aggregate 4. e1's next 3 applies only the remaining 1. + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 3, + }); + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 1, + }); + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 3, + }); + + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + + // ── Side effect: ONE shared customer-scope row, no entity rows ── + await timeout(4000); + const rows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rows).toHaveLength(1); + expect(rows[0].internal_entity_id).toBeNull(); + expect(Number(rows[0].usage)).toBe(5); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-inherit2: an entity with its own cap is carved out of the customer's aggregate window")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-inherit-carveout", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-inherit-2"; + const { entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + limit: 2, + }); + + await autumnV2_3.customers.get(customerId); // initialize cache. + for (const entity of entities) { + await autumnV2_3.entities.get(customerId, entity.id); // initialize cache. + } + + // e1 (own cap 2) tracks 3 -> applies 2, into its OWN window. + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 3, + }); + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[1].id, + featureId: TestFeature.Messages, + usage: 2, + limit: 2, + }); + + // e0 (no own cap) tracks 5 -> the FULL 5 fits in the customer window, + // proving e1's tracks never touched it (else only 3 would fit). + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // Both windows now full: e0 rejects on the customer window, e1 on its own. + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + await expectAutumnError({ + errCode: ErrCode.InsufficientBalance, + func: async () => + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 1, + overage_behavior: "reject", + }), + }); + + // ── Side effect: one customer-scope row (5) + one entity row (2) ── + await timeout(4000); + const rows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rows).toHaveLength(2); + const customerRow = rows.find( + (row: { internal_entity_id: string | null }) => + row.internal_entity_id === null, + ); + const entityRow = rows.find( + (row: { internal_entity_id: string | null }) => + row.internal_entity_id !== null, + ); + expect(customerRow).toBeDefined(); + expect(entityRow).toBeDefined(); + expect(Number(customerRow.usage)).toBe(5); + expect(Number(entityRow.usage)).toBe(2); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-inherit3: arming an entity cap mid-window moves the entity to a fresh window")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-inherit-midwindow", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-inherit-3"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.customers.get(customerId); // initialize cache. + for (const entity of entities) { + await autumnV2_3.entities.get(customerId, entity.id); // initialize cache. + } + + // e0 inherits: 4 land in the customer window. + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 4, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 4, + limit: 5, + }); + + // Carve e0 out mid-window: its next 5 fit its FRESH entity window + // (had it still tracked the customer window, only 1 would fit). + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // Customer window kept its 4: e1 (still inheriting) fits exactly 1 more. + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 3, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/entities/entity-usage-window-persistence.test.ts b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-persistence.test.ts new file mode 100644 index 000000000..2dddc2a93 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/entities/entity-usage-window-persistence.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "bun:test"; +import { type ApiEntityV2, ApiVersion, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectEntityUsageLimit, + setEntityUsageLimit, +} from "../../utils/usage-limit-utils/entityUsageLimitUtils.js"; +import { fetchUsageWindowRows } from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +/** + * TDD tests for entity usage-limit PERSISTENCE + API exposure. + * + * Contract under test: + * - the entity window counter syncs to Postgres (internal_entity_id set) and + * a skip_cache read serves the synced count (persist1) + * - entities.get exposes billing_controls.usage_limits: the entity's OWN + * entries, each decorated with the current window's `usage` (persist2) + * - entities.update rejects duplicate feature_id usage_limits entries + * (persist3) + * + * Pre-impl red: entity usage_limits don't exist on the schema, so the arm is + * dropped, nothing syncs, nothing is exposed, and dupes aren't validated. + */ + +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("ent-uw-persist1: entity window counter syncs to Postgres and survives skip_cache reads")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-persist-sync", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-persist-1"; + const { entities, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 3, + }); + await timeout(4000); + + // ── PG row: entity-scoped, counted ── + const rows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rows).toHaveLength(1); + expect(rows[0].internal_entity_id).not.toBeNull(); + expect(Number(rows[0].usage)).toBe(3); + expect(Number(rows[0].window_end_at)).toBeGreaterThan(Date.now()); + + // ── skip_cache read serves the synced counter ── + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + skipCache: true, + }); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-persist2: entities.get exposes the entity's usage_limits with current window usage")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-persist-expose", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-persist-2"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await setEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Before any usage: entry echoed with usage 0. + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 0, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 2, + }); + await timeout(3000); + + await expectEntityUsageLimit({ + autumn: autumnV2_3, + customerId, + entityId: entities[0].id, + featureId: TestFeature.Messages, + usage: 2, + limit: 5, + }); + + // The OTHER entity has no entries: nothing echoed for it. + const otherEntity = await autumnV2_3.entities.get( + customerId, + entities[1].id, + ); + expect( + otherEntity.billing_controls?.usage_limits ?? undefined, + ).toBeUndefined(); + }, +); + +test.concurrent( + `${chalk.yellowBright("ent-uw-persist3: duplicate feature_id entries in entity usage_limits are rejected")}`, + async () => { + const perEntityProduct = products.base({ + id: "ent-uw-persist-dupe", + items: [ + items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], + }); + + const customerId = "ent-uw-persist-3"; + const { entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [perEntityProduct] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: perEntityProduct.id })], + }); + + await expectAutumnError({ + func: async () => + await autumnV2_3.entities.update(customerId, entities[0].id, { + billing_controls: { + usage_limits: [ + { + feature_id: TestFeature.Messages, + limit: 5, + interval: ResetInterval.Month, + }, + { + feature_id: TestFeature.Messages, + limit: 9, + interval: ResetInterval.Month, + }, + ], + }, + }), + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/plan-changes/plan-change-anchor.test.ts b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-anchor.test.ts new file mode 100644 index 000000000..079fd7618 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-anchor.test.ts @@ -0,0 +1,254 @@ +/** + * TDD tests for usage-window ANCHOR selection across plan changes. + * + * Contract under test: + * - top-up-only customers: the window anchors to the loose top-up ent and + * aligns to the UTC calendar (no cycle exists) + * - subscribing later transfers the anchor to the plan ent: the window + * re-keys to the plan cycle (window_end == plan ent next_reset_at) and + * the moved window ZEROES the counter + * - when both exist up front, the plan-backed ent outranks the OLDER + * loose top-up ent + */ + +import { expect, test } from "bun:test"; +import { ApiVersion, EntInterval, getUsageWindowBounds } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + fetchActivePlanCusEnt, + fetchLooseCusEnt, + fetchUsageWindowRows, +} from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// ── Contract: top-up-only anchors to the loose ent, calendar bounds ── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-anchor1: a cap with only a top-up grant anchors to it with calendar bounds")}`, + async () => { + const customerId = "uw-anchor-topup-1"; + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [] })], + actions: [], + }); + + await autumnV2_3.post("/balances.create", { + customer_id: customerId, + feature_id: TestFeature.Credits, + included_grant: 100, + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 2, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 2, + limit: 5, + }); + + // PG: anchored to the loose top-up ent; lifetime grants have no cycle, so + // the window is UTC-calendar aligned. + await timeout(4000); + const topUpEnt = await fetchLooseCusEnt({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + expect(topUpEnt?.id).toBeTruthy(); + + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + expect(windowRows).toHaveLength(1); + expect(windowRows[0].anchor_customer_entitlement_id).toBe(topUpEnt.id); + + const calendar = getUsageWindowBounds({ + interval: EntInterval.Month, + now: Date.now(), + }); + expect(Number(windowRows[0].window_start_at)).toBe(calendar.windowStartAt); + expect(Number(windowRows[0].window_end_at)).toBe(calendar.windowEndAt); + }, +); + +// ── Contract: subscribing transfers the anchor to the plan cycle ── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-anchor2: subscribing re-anchors the window to the plan ent and restarts the counter")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-anchor-subscribe-1"; + const { ctx, autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + await autumnV2_3.post("/balances.create", { + customer_id: customerId, + feature_id: TestFeature.Credits, + included_grant: 100, + }); + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 2, + }); + + // Flush the counter to Postgres before the cache-invalidating change + // (the rebuild re-seeds counters from PG). + await timeout(4000); + + // Subscribe: the plan ent now outranks the top-up, the window re-keys to + // the plan cycle, and the moved window zeroes the counter. + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); + + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 0, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 3, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 3, + limit: 5, + }); + + await timeout(4000); + const planEnt = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + expect(planEnt?.next_reset_at).toBeTruthy(); + + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + const currentRow = windowRows.find( + (row) => Number(row.window_end_at) === Number(planEnt.next_reset_at), + ); + expect(currentRow).toBeDefined(); + expect(currentRow.anchor_customer_entitlement_id).toBe(planEnt.id); + expect(Number(currentRow.window_end_at)).toBe( + Number(planEnt.next_reset_at), + ); + }, +); + +// ── Contract: plan-backed ent outranks an OLDER top-up ent ────────── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-anchor3: the plan ent wins the anchor over an older top-up ent")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-anchor-rank-1"; + const { ctx, autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Top-up FIRST (older created_at), plan second. + await autumnV2_3.post("/balances.create", { + customer_id: customerId, + feature_id: TestFeature.Credits, + included_grant: 50, + }); + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 2, + }); + + await timeout(4000); + const planEnt = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Credits, + }); + expect(windowRows).toHaveLength(1); + expect(windowRows[0].anchor_customer_entitlement_id).toBe(planEnt.id); + expect(Number(windowRows[0].window_end_at)).toBe( + Number(planEnt.next_reset_at), + ); + }, +); diff --git a/server/tests/integration/balances/usage-windows/plan-changes/plan-change-replacement.test.ts b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-replacement.test.ts new file mode 100644 index 000000000..142b02df4 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-replacement.test.ts @@ -0,0 +1,194 @@ +/** + * TDD tests for usage windows across plan REPLACEMENT and expiry. + * + * Contract under test (windows follow the anchor ent's reset cycle): + * - replacing a free plan re-anchors the window to the new ent (new + * bounds, window_end == its next_reset_at) and the moved window ZEROES + * the counter (moved from persistence3) + * - cancelling to NOTHING re-aligns the cap to the UTC calendar, zeroing + * the counter with the moved window + */ + +import { expect, test } from "bun:test"; +import { ApiVersion, EntInterval, getUsageWindowBounds } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + fetchActivePlanCusEnt, + fetchUsageWindowRows, +} from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// ── Contract: replacement restarts the cap with the new cycle ─────── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-replacement1: free-plan replacement restarts the cap on the new ent's cycle")}`, + async () => { + const planA = products.base({ + id: "uw-replace-a", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const planB = products.base({ + id: "uw-replace-b", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const customerId = "uw-replace-1"; + const { ctx, autumnV2_1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [planA, planB] }), + ], + actions: [s.billing.attach({ productId: planA.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Exhaust the cap on plan A. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await timeout(4000); + + // Replace plan A with plan B: plan A's ents (incl. the anchor) expire and + // plan B's ent starts a fresh cycle -- the window restarts with it. + await autumnV2_1.attach({ + customer_id: customerId, + product_id: planB.id, + }); + await timeout(2000); + + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 0, + limit: 5, + }); + + // Fresh headroom on the new cycle. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + granted: 200, + remaining: 199, + usage: 1, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 1, + limit: 5, + }); + + await timeout(4000); + const planBEnt = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + const currentRow = windowRows.find( + (row) => Number(row.window_end_at) === Number(planBEnt.next_reset_at), + ); + expect(currentRow).toBeDefined(); + expect(currentRow.anchor_customer_entitlement_id).toBe(planBEnt.id); + expect(Number(currentRow.window_end_at)).toBe( + Number(planBEnt.next_reset_at), + ); + }, +); + +// ── Contract: cancel-to-nothing re-aligns the cap to the calendar ─── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-replacement2: cancelling the plan re-aligns the cap to calendar bounds, counter zeroed")}`, + async () => { + const freePlan = products.base({ + id: "uw-replace-cancel", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-replace-cancel-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2, + }); + + // Flush the counter to Postgres before the cache-invalidating change + // (the rebuild re-seeds counters from PG). + await timeout(4000); + + // Cancel the plan immediately: no ents remain for the feature. + await autumnV2_3.subscriptions.update({ + customer_id: customerId, + plan_id: freePlan.id, + cancel_action: "cancel_immediately", + }); + + // The cap entry survives on billing_controls with no anchor: the window + // re-aligns to the UTC calendar, zeroing the counter with the move. + const customer = await autumnV2_3.customers.get(customerId); + // biome-ignore lint/suspicious/noExplicitAny: response inspected loosely + const limit = (customer as any).billing_controls?.usage_limits?.find( + // biome-ignore lint/suspicious/noExplicitAny: response inspected loosely + (entry: any) => entry.feature_id === TestFeature.Messages, + ); + expect(limit).toBeDefined(); + expect(limit.limit).toBe(5); + expect(limit.usage ?? 0).toBe(0); + + // Sanity: the calendar window the cap now lives on is derivable. + const calendar = getUsageWindowBounds({ + interval: EntInterval.Month, + now: Date.now(), + }); + expect(calendar.windowEndAt).toBeGreaterThan(Date.now()); + }, +); diff --git a/server/tests/integration/balances/usage-windows/plan-changes/plan-change-scheduled.test.ts b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-scheduled.test.ts new file mode 100644 index 000000000..c02f64e8a --- /dev/null +++ b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-scheduled.test.ts @@ -0,0 +1,170 @@ +/** + * TDD test for usage windows across a SCHEDULED downgrade (premium -> pro at + * cycle end, triggered by advancing the test clock past the next invoice). + * + * Contract under test (WINDOW-IDENTITY rule): + * - while the downgrade is only scheduled, the cap keeps binding on the + * premium cycle (counter untouched by scheduling) + * - the scheduled switch preserves the billing cycle, so the pro-anchored + * bracket equals the premium one: anchor-only re-point, count KEPT. + * + * NOTE: in production the switch fires exactly when the old window closes by + * WALL clock, so the count zeroes there via natural expiry. Test clocks + * can't show that (windows live on server wall time); this test pins the + * re-anchor + carry half of the contract. + */ + +import { expect, test } from "bun:test"; +import { ApiVersion, EntInterval, getUsageWindowBounds } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { advanceTestClock } from "@tests/utils/stripeUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + fetchActivePlanCusEnt, + fetchUsageWindowRows, +} from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("uw-plan-change-scheduled1: premium -> pro at cycle end re-keys the window to the pro cycle")}`, + async () => { + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-sched-downgrade-1"; + const { + ctx, + autumnV1, + testClockId: maybeTestClockId, + advancedTo, + } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [s.billing.attach({ productId: premium.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + // Schedule the downgrade: premium keeps running (canceling), pro is + // scheduled. The cap is untouched by the scheduling itself. + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // Flush the counter, then turn the cycle: premium ends, pro activates. + await timeout(4000); + const testClockId = maybeTestClockId as string; + expect(testClockId).toBeTruthy(); + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId, + advanceTo: addMonths(new Date(advancedTo ?? Date.now()), 1).getTime(), + waitForSeconds: 30, + }); + await advanceTestClock({ + stripeCli: ctx.stripeCli, + testClockId, + numberOfHours: hoursToFinalizeInvoice, + startingFrom: addMonths(new Date(advancedTo ?? Date.now()), 1), + waitForSeconds: 30, + }); + + // Cycle preserved across the switch: anchor re-points, count carried. + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // PG: the live row anchors to the pro ent; bounds derive from its + // next_reset_at (computed, since the test clock runs ahead of wall time). + await timeout(4000); + const proEnt = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(proEnt?.next_reset_at).toBeTruthy(); + + const expectedBounds = getUsageWindowBounds({ + interval: EntInterval.Month, + now: Date.now(), + anchor: Number(proEnt.next_reset_at), + }); + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + const currentRow = windowRows.find( + (row) => Number(row.window_end_at) === expectedBounds.windowEndAt, + ); + expect(currentRow).toBeDefined(); + expect(Number(currentRow.usage)).toBe(5); + // Bounds (above) prove pro-cycle alignment; the anchor id itself depends + // on which of the plan's ents the resolver tie-breaks to post-switch. + expect(currentRow.anchor_customer_entitlement_id).not.toBeNull(); + expect(Number(currentRow.window_start_at)).toBe( + expectedBounds.windowStartAt, + ); + expect(Number(currentRow.window_end_at)).toBe(expectedBounds.windowEndAt); + }, +); diff --git a/server/tests/integration/balances/usage-windows/plan-changes/plan-change-update.test.ts b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-update.test.ts new file mode 100644 index 000000000..c8f0ce812 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-update.test.ts @@ -0,0 +1,209 @@ +/** + * TDD tests for usage windows across SUBSCRIPTION UPDATES (customize items, + * non-patch PUT). + * + * Contract under test (anchor-only re-point): + * - a customize that keeps the feature's reset cadence PRESERVES the + * cycle: the recreated main ent's next_reset_at equals the pre-update + * value, the usage-window anchor follows a cycle-bearing ent, the window + * does not move, and the counter is NOT zeroed (only the anchor + * re-points to the new ent id) + * - this holds both for a base-price-only customize and for updating the + * capped item itself (includedUsage bump) + */ + +import { expect, test } from "bun:test"; +import { ApiVersion, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + fetchActivePlanCusEnt, + fetchUsageWindowRows, +} from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// ── Contract: price-only customization never resets the counter ───── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-update1: a base-price-only customize preserves the cycle and the counter")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-update-price-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + // Flush the counter to Postgres before the cache-invalidating change + // (the rebuild re-seeds counters from PG). + await timeout(4000); + const entBefore = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(entBefore?.next_reset_at).toBeTruthy(); + + // Customize the base price only; the messages item is untouched. + await autumnV2_3.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + customize: { + price: { amount: 50, interval: "month" }, + }, + }); + + // ── Contract: a price-only customize doesn't touch the ent at all ── + const entAfter = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(entAfter?.id).toBe(entBefore.id); + expect(Number(entAfter?.next_reset_at)).toBe( + Number(entBefore.next_reset_at), + ); + + // ── Contract: same window => anchor-only re-point, count KEPT ───── + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // ── Contract: the cap keeps binding (track 5 clamps to headroom 2) ─ + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // ── Contract: PG row stayed on the preserved cycle ──────────────── + await timeout(4000); + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(windowRows).toHaveLength(1); + expect(Number(windowRows[0].usage)).toBe(5); + expect(Number(windowRows[0].window_end_at)).toBe( + Number(entBefore.next_reset_at), + ); + }, +); + +// ── Contract: updating the capped item preserves the cycle + counter ── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-update2: updating the capped item preserves the cycle and the counter")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-update-item-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + // Flush the counter to Postgres before the cache-invalidating change + // (the rebuild re-seeds counters from PG). + await timeout(4000); + + // Bump the capped item's included usage (100 -> 500): the cadence is + // unchanged, so the cycle is preserved and the counter survives. + await autumnV2_3.subscriptions.update({ + customer_id: customerId, + plan_id: pro.id, + customize: { + items: [ + { + feature_id: TestFeature.Messages, + included: 500, + reset: { interval: ResetInterval.Month }, + }, + ], + }, + }); + + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // Still binding on the enlarged balance: track 5 clamps to 2. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/plan-changes/plan-change-upgrade.test.ts b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-upgrade.test.ts new file mode 100644 index 000000000..43b31d3a1 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/plan-changes/plan-change-upgrade.test.ts @@ -0,0 +1,313 @@ +/** + * TDD tests for usage-window behavior on IMMEDIATE plan upgrades. + * + * Contract under test (WINDOW-IDENTITY rule): the count belongs to the + * window, not the plan. Upgrades here PRESERVE the billing cycle (same + * window bounds; the recomputed next_reset_at is precision-corrected by + * applyExistingNextResetAts), so an upgrade is an anchor-only re-point: + * - the counter SURVIVES the upgrade -- no fresh cap headroom mid-cycle + * - a counter AT the cap stays exhausted through the upgrade + * - multi-balance: the carried cap keeps binding; the top-up persists + * A cycle-RESTARTING change would move the window and zero (see the + * computeUsageWindowRolls unit table). + */ + +import { expect, test } from "bun:test"; +import { type ApiCustomerV5, ApiVersion } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { + fetchActivePlanCusEnt, + fetchUsageWindowRows, +} from "../../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// ── Contract: upgrade resets the window, aligned to the new cycle ── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-upgrade1: pro -> premium carries the counter; window_end == new ent next_reset_at")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const customerId = "uw-upgrade-reset-1"; + const { ctx, autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // Immediate upgrade: pro is expired, premium's cycle starts now. + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + // Cycle preserved: anchor-only re-point, the counter SURVIVES. + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // Only the remaining headroom (2) applies from a track of 5. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + granted: 200, + remaining: 198, + usage: 2, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // PG: the live counter row is anchored to the premium ent's cycle. + await timeout(4000); + const premiumEnt = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(premiumEnt?.next_reset_at).toBeTruthy(); + + const windowRows = await fetchUsageWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + const currentRow = windowRows.find( + (row) => Number(row.window_end_at) === Number(premiumEnt.next_reset_at), + ); + expect(currentRow).toBeDefined(); + expect(Number(currentRow.usage)).toBe(5); + expect(currentRow.anchor_customer_entitlement_id).toBe(premiumEnt.id); + }, +); + +// ── Contract: an exhausted cap yields fresh headroom post-upgrade ── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-upgrade2: a counter AT the cap stays exhausted through the upgrade")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyMessages({ includedUsage: 200 })], + }); + + const customerId = "uw-upgrade-atcap-1"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Exhaust the cap; the next track fully clamps. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + // Cycle preserved: the cap stays exhausted, a track fully clamps. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + granted: 200, + remaining: 200, + usage: 0, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); + +// ── Contract: multi-balance — cap resets, loose top-up untouched ── +test.concurrent( + `${chalk.yellowBright("uw-plan-change-upgrade3: cap on credits carries through upgrade while the top-up balance persists")}`, + async () => { + const pro = products.pro({ + id: "pro", + items: [items.monthlyCredits({ includedUsage: 3 })], + }); + const premium = products.premium({ + id: "premium", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-upgrade-multibal-1"; + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro, premium] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + // Loose lifetime top-up alongside the plan credits. + await autumnV2_3.post("/balances.create", { + customer_id: customerId, + feature_id: TestFeature.Credits, + included_grant: 50, + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + }); + + // Track 5 credits: drains the 3 monthly then 2 from the top-up; the + // counter sums both (cap exhausted). + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 5, + limit: 5, + }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + // The counter carried (cap still exhausted); the top-up too. + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 5, + limit: 5, + }); + const postUpgrade = + await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: postUpgrade, + featureId: TestFeature.Credits, + // premium's fresh 100 + the top-up's 50 (2 already used pre-upgrade: + // the loose grant carries its usage across the plan change). + granted: 150, + remaining: 148, + usage: 2, + }); + + // No fresh headroom: a further track fully clamps. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 2, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 5, + limit: 5, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-api.test.ts b/server/tests/integration/balances/usage-windows/usage-window-api.test.ts new file mode 100644 index 000000000..ccc9f1287 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-api.test.ts @@ -0,0 +1,189 @@ +import { test } from "bun:test"; +import { + type ApiCustomerV5, + ApiVersion, + type CustomerBillingControls, + ErrCode, + ResetInterval, +} from "@autumn/shared"; +import { expectUsageLimitCorrect } from "@tests/integration/utils/expectUsageLimitCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { setCustomerUsageLimit } from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +// Usage-window API surface: what the HTTP contract exposes and guards around +// windowed caps (not deduction outcomes). + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// The usage_limits entry in the customer response exposes the current window +// usage. +test.concurrent( + `${chalk.yellowBright("usage-window-api1: usage_limits exposes the current window usage")}`, + async () => { + const customerProduct = products.base({ + id: "uw-api-counter", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-api-counter-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + const customer = await autumnV2_3.customers.get(customerId); + expectUsageLimitCorrect({ + customer, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + interval: ResetInterval.Month, + }); + }, +); + +// set_usage must be rejected when the feature has an enforced usage window; +// otherwise it bypasses the hard cap (it carries no window provenance). +test.concurrent( + `${chalk.yellowBright("usage-window-api2: set_usage is rejected when the feature has a usage window")}`, + async () => { + const customerProduct = products.base({ + id: "uw-api-setusage", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-api-setusage-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await expectAutumnError({ + errCode: ErrCode.SetUsageNotAllowedWithUsageLimit, + func: async () => + await autumnV2_3.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + }), + }); + }, +); + +// usage_limits entries are strictly validated on write: limit, interval, and +// feature_id are all required, and one_off (never-resetting) windows are not +// supported. +test.concurrent( + `${chalk.yellowBright("usage-window-api3: usage_limits entries are validated (interval required, no one_off)")}`, + async () => { + const customerProduct = products.base({ + id: "uw-api-validate", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-api-validate-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + // Missing interval. + await expectAutumnError({ + func: async () => + await autumnV2_3.customers.update(customerId, { + billing_controls: { + usage_limits: [{ feature_id: TestFeature.Messages, limit: 5 }], + } as unknown as CustomerBillingControls, + }), + }); + + // one_off window. + await expectAutumnError({ + func: async () => + await autumnV2_3.customers.update(customerId, { + billing_controls: { + usage_limits: [ + { + feature_id: TestFeature.Messages, + limit: 5, + interval: "one_off", + }, + ], + } as unknown as CustomerBillingControls, + }), + }); + + // Missing limit. + await expectAutumnError({ + func: async () => + await autumnV2_3.customers.update(customerId, { + billing_controls: { + usage_limits: [ + { feature_id: TestFeature.Messages, interval: "month" }, + ], + } as unknown as CustomerBillingControls, + }), + }); + + // Duplicate feature_id entries. + await expectAutumnError({ + func: async () => + await autumnV2_3.customers.update(customerId, { + billing_controls: { + usage_limits: [ + { + feature_id: TestFeature.Messages, + limit: 5, + interval: "month", + }, + { + feature_id: TestFeature.Messages, + limit: 10, + interval: "day", + }, + ], + } as unknown as CustomerBillingControls, + }), + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-check.test.ts b/server/tests/integration/balances/usage-windows/usage-window-check.test.ts new file mode 100644 index 000000000..ec773f58f --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-check.test.ts @@ -0,0 +1,188 @@ +/** + * TDD tests for usage-limit awareness in check / lock / finalize. + * + * Contract under test: + * Pure check: + * - required_balance > window headroom (balance sufficient) -> allowed: false + * - required_balance <= headroom -> allowed: true; balance never deducted + * - metered cap on a credit-system member feature converts via credit_cost + * + * Lock / finalize contracts live in usage-window-lock.test.ts. + */ + +import { expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerBalance, + setCustomerUsageLimit, +} from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// ── Contract: pure check is gated by window headroom ────────────── +test.concurrent( + `${chalk.yellowBright("usage-window-check1: pure check respects window headroom (metered cap)")}`, + async () => { + const freePlan = products.base({ + id: "uw-check-pure", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-check-pure-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + // Headroom is 2: a check within it is allowed... + const within = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 2, + }); + expect(within.allowed).toBe(true); + + // ...and a check beyond it is rejected, despite 97 balance remaining. + const beyond = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 3, + }); + expect(beyond.allowed).toBe(false); + + // Pure checks never consume anything. + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 97, + usage: 3, + }); + }, +); + +// ── Contract: pure check converts metered caps via credit_cost ──── +test.concurrent( + `${chalk.yellowBright("usage-window-check2: pure check converts a metered cap on a credit-funded feature")}`, + async () => { + const freePlan = products.base({ + id: "uw-check-convert", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-check-convert-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 5, + }); + + // 3 of 5 action1 units used (= 0.6 credits). + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 3, + }); + + const within = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 2, + }); + expect(within.allowed).toBe(true); + + // 3 more units exceed the 5-unit cap while ~99.4 credits remain. + const beyond = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 3, + }); + expect(beyond.allowed).toBe(false); + }, +); + +// ── Contract: a cap on another feature never gates this one ─────── +test.concurrent( + `${chalk.yellowBright("usage-window-check6: an exhausted action1 cap does not gate a credits check")}`, + async () => { + const freePlan = products.base({ + id: "uw-check-scope", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-check-scope-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 5, + }); + + // Exhaust the action1 cap. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + // Sanity: the cap binds its own feature... + const action1Check = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 1, + }); + expect(action1Check.allowed).toBe(false); + + // ...but a direct credits check sails through on the 99 remaining credits. + const creditsCheck = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: 50, + }); + expect(creditsCheck.allowed).toBe(true); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-enforcement.test.ts b/server/tests/integration/balances/usage-windows/usage-window-enforcement.test.ts new file mode 100644 index 000000000..55e2cc018 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-enforcement.test.ts @@ -0,0 +1,697 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { + ApiVersion, + type CustomerBillingControls, + ResetInterval, +} from "@autumn/shared"; +import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { expectUsageLimitCorrect } from "@tests/integration/utils/expectUsageLimitCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +// Usage-window ENFORCEMENT: what a track actually applies under a windowed +// cap (the deduction-script path). Covers both cap dimensions -- +// metered_feature (cap counts tracked units) and balance (cap counts credits +// drained) -- including credit conversion, multi-cusEnt deductions, the +// compound overage_limit case, and concurrency. + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// Credit system: 100 credits, 1 action1 = 0.2 credits (see v2Features.ts). +// A cap of 5 action1 units consumes only 1 credit, so the cap must clamp the +// 6th unit while ~99 credits remain, proving it's a second, independent +// dimension, not a balance check. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement1: metered cap clamps the over-cap unit while credits remain")}`, + async () => { + const customerProduct = products.base({ + id: "uw-enforce-metered", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-enforce-metered-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 5, + }); + + // Consume exactly up to the cap: 5 action1 units = 1 credit deducted. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 99, + usage: 1, + }); + + // The 6th unit is over the cap, so it clamps to 0: the track succeeds but + // applies nothing, leaving credits unchanged. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 99, + usage: 1, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + usage: 5, + limit: 5, + }); + }, +); + +// An over-cap track applies what fits (the remaining headroom) instead of +// rejecting the whole track. cap 5, track 10 from 0 -> applies 5 (not 10, not +// a 400). +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement2: over-cap track applies what fits (clamp, not reject)")}`, + async () => { + const customerProduct = products.base({ + id: "uw-enforce-clamp", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-enforce-clamp-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Track 10 against a cap of 5 (from 0): clamps to 5, returns 200, not a reject. + const clamped = await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + expect(clamped.value).toBe(10); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 95, + usage: 5, + }); + + // At the cap: a further track applies 0 (fully clamped), still 200. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 95, + usage: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); + +// Metered cap with TWO credits cusEnts (monthly + lifetime): the clamped +// deduction must drain the monthly bucket first, spill exactly 1 credit into +// lifetime, and stop there. 1 action1 = 0.2 credits, so the 10-unit cap is +// worth exactly 2 credits. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement3: metered cap clamps across monthly then lifetime credits cusEnts")}`, + async () => { + const monthlyCreditsItem = items.monthlyCredits({ includedUsage: 1 }); + const lifetimeCreditsItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 10, + interval: null, + }); + const freePlan = products.base({ + id: "uw-enforce-metered-multi", + items: [monthlyCreditsItem, lifetimeCreditsItem], + }); + + const customerId = "uw-enforce-metered-multi-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 10, + }); + + // Track 15 action1 against a 10/month cap: clamps to 10 units = 2 credits. + // 1 credit drains the monthly cusEnt, 1 comes out of lifetime. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 15, + }); + + const afterClamp = + await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: afterClamp, + featureId: TestFeature.Credits, + granted: 11, + remaining: 9, + usage: 2, + breakdown: { + [ResetInterval.Month]: { + included_grant: 1, + remaining: 0, + usage: 1, + }, + [ResetInterval.OneOff]: { + included_grant: 10, + remaining: 9, + usage: 1, + }, + }, + }); + + // At the cap: a further track applies 0, so lifetime credits stay put. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + const atCap = await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: atCap, + featureId: TestFeature.Credits, + granted: 11, + remaining: 9, + usage: 2, + breakdown: { + [ResetInterval.Month]: { remaining: 0, usage: 1 }, + [ResetInterval.OneOff]: { remaining: 9, usage: 1 }, + }, + }); + expectUsageLimitCorrect({ + customer: atCap, + featureId: TestFeature.Action1, + usage: 10, + limit: 10, + }); + }, +); + +// Balance-dim cap: the cap is denominated in CREDITS (cap on the credit pool +// itself), enforced while monthly credits remain. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement4: balance cap (1 credit/day) clamps while monthly credits remain")}`, + async () => { + const customerProduct = products.base({ + id: "uw-enforce-balance", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-enforce-balance-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + // 1 action1 = 0.2 credits, so 5 action1 = exactly 1 credit (the daily cap). + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 1, + interval: ResetInterval.Day, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 99, + usage: 1, + }); + + // The credit-pool window is exhausted: the next track clamps to 0 (the + // window shortfall flows through the standard 'cap' overage behaviour), + // leaving the ~99 remaining monthly credits untouched. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 99, + usage: 1, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 1, + limit: 1, + }); + }, +); + +// Balance-dim cap with TWO credits cusEnts: credits drained from BOTH must +// count toward the window. (The old entitlement-anchored counter only saw the +// anchor cusEnt's drain, silently under-counting multi-cusEnt consumption.) +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement5: balance cap counts drains across monthly and lifetime cusEnts")}`, + async () => { + const monthlyCreditsItem = items.monthlyCredits({ includedUsage: 1 }); + const lifetimeCreditsItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 10, + interval: null, + }); + const freePlan = products.base({ + id: "uw-enforce-balance-multi", + items: [monthlyCreditsItem, lifetimeCreditsItem], + }); + + const customerId = "uw-enforce-balance-multi-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + // Cap the credit pool at 2/day. 1 action1 = 0.2 credits, so 10 action1 = + // 2 credits: 1 drains the monthly cusEnt, 1 spills into lifetime. Both + // drains must land on the same customer-level counter. + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 2, + interval: ResetInterval.Day, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 10, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 11, + remaining: 9, + usage: 2, + }); + + // The counter saw the full 2 credits (1 monthly + 1 lifetime), so the cap + // is exhausted: the next consumption clamps to 0 instead of being served + // from the 9 remaining lifetime credits. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1, + }); + const afterClamp = + await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: afterClamp, + featureId: TestFeature.Credits, + granted: 11, + remaining: 9, + usage: 2, + }); + + // usage is served from the customer-scoped counter. + expectUsageLimitCorrect({ + customer: afterClamp, + featureId: TestFeature.Credits, + usage: 2, + limit: 2, + }); + }, +); + +// An overage spend_limit and a usage limit on the SAME feature are separate +// billing controls that coexist: the window must still clamp on its own. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement6: a usage limit clamps alongside an overage spend_limit on the same feature")}`, + async () => { + const customerProduct = products.base({ + id: "uw-enforce-compound", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-enforce-compound-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + const billingControls: CustomerBillingControls = { + spend_limits: [ + { + feature_id: TestFeature.Action1, + enabled: true, + overage_limit: 20, + }, + ], + usage_limits: [ + { + feature_id: TestFeature.Action1, + limit: 5, + interval: ResetInterval.Month, + }, + ], + }; + await timeout(2000); + await autumnV2_3.customers.update(customerId, { + billing_controls: billingControls, + }); + await timeout(3000); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + // The window cap clamps the over-cap unit to 0 (the overage path is separate), + // so the track succeeds and credits are unchanged. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + remaining: 99, + usage: 1, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + usage: 5, + limit: 5, + }); + }, +); + +// Two concurrent tracks on the SAME customer's SAME window must serialize +// (Redis runs each deduction Lua atomically): combined value exceeds the cap, +// so the second track clamps and the counter reflects exactly the capped usage. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement7: concurrent tracks on one window serialize, total clamped to the cap")}`, + async () => { + const customerProduct = products.base({ + id: "uw-enforce-concurrent", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = `uw-enforce-concurrent-${Date.now()}`; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + // Cap action1 at 5/month; two concurrent tracks of 5 each => combined 10 > 5. + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 5, + }); + + const results = await Promise.allSettled([ + autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }), + autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }), + ]); + + // Both succeed (clamp, not reject), but the window clamps the combined + // applied usage to the cap: one applies 5, the other clamps to 0. + expect(results.every((result) => result.status === "fulfilled")).toBe(true); + + await timeout(2000); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + remaining: 99, + usage: 1, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + usage: 5, + limit: 5, + }); + + // BOTH tracks record events (clamped tracks too, matching how + // balance-clamped tracks have always behaved): events reflect requests. + await expectCustomerEventsCorrect({ + customerId, + events: [{ value: 5 }, { value: 5 }], + }); + }, +); + +// Metered cap funded by MIXED entitlements: a native action1 cusEnt AND a +// credits cusEnt (different conversion rates in one deduction). Deduction +// order is native-first (see track-credit-system3), so a clamped track of 10 +// against cap 8 drains the 5 native units, then 3 units via credits at 0.2 +// credits/unit -- the counter must see all 8 tracked units across both. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement8: metered cap counts units across native and credit-system cusEnts")}`, + async () => { + const action1Item = items.free({ + featureId: TestFeature.Action1, + includedUsage: 5, + }); + const creditsItem = items.monthlyCredits({ includedUsage: 100 }); + const freePlan = products.base({ + id: "uw-enforce-mixed", + items: [action1Item, creditsItem], + }); + + const customerId = "uw-enforce-mixed-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 8, + }); + + // Track 10 against cap 8: applies 8 -- the native pool's 5 units, then 3 + // units from credits (3 x 0.2 = 0.6 credits). + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 10, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + granted: 5, + remaining: 0, + usage: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 99.4, + usage: 0.6, + }); + + // The counter saw all 8 units (5 native + 3 credit-funded): exhausted, so + // a further track clamps to 0 despite the 99.4 remaining credits. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1, + }); + const atCap = await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: atCap, + featureId: TestFeature.Credits, + remaining: 99.4, + usage: 0.6, + }); + + expectUsageLimitCorrect({ + customer: atCap, + featureId: TestFeature.Action1, + usage: 8, + limit: 8, + }); + }, +); + +// A cap counts ITS OWN dimension: a metered cap on action1 must neither gate +// nor be incremented by tracking the credits feature directly, even when the +// cap is fully exhausted. +test.concurrent( + `${chalk.yellowBright("usage-window-enforcement9: an action1 cap does not touch direct credits tracking")}`, + async () => { + const customerProduct = products.base({ + id: "uw-enforce-scope", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-enforce-scope-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + limit: 5, + }); + + // Exhaust the action1 cap (5 units = 1 credit). + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + // Direct credits tracking applies IN FULL: no clamp from the action1 cap... + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 10, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 89, + usage: 11, + }); + + // ...and the action1 counter never moved. + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + usage: 5, + limit: 5, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-lock.test.ts b/server/tests/integration/balances/usage-windows/usage-window-lock.test.ts new file mode 100644 index 000000000..61ac126a4 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-lock.test.ts @@ -0,0 +1,315 @@ +/** + * TDD tests for usage limits on the LOCK / FINALIZE flow. + * + * Contract under test: + * - a lock is gated by window headroom and counts at lock time + * - finalize at the lock value does not double count + * - finalize below the lock decrements the counter (freed headroom reusable) + * - finalize ABOVE the lock is capped at the window limit: only the + * remaining headroom of the extra delta applies, and it is counted + */ + +import { expect, test } from "bun:test"; +import { ApiVersion } from "@autumn/shared"; +import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// ── Contract: locks are gated by headroom and count at lock time ── +test.concurrent( + `${chalk.yellowBright("usage-window-lock1: a lock consumes window headroom; an over-cap lock is rejected")}`, + async () => { + const freePlan = products.base({ + id: "uw-check-lock", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-check-lock-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + await deleteLock({ ctx, lockId: `${customerId}-a` }); + await deleteLock({ ctx, lockId: `${customerId}-b` }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Lock 4 of the 5-unit cap: granted, and counted at lock time. + const granted = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 4, + lock: { enabled: true, lock_id: `${customerId}-a` }, + }); + expect(granted.allowed).toBe(true); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 96, + usage: 4, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 4, + limit: 5, + }); + + // A second lock of 2 exceeds the remaining headroom of 1: rejected, and + // neither the balance nor the counter moves. + const rejected = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 2, + lock: { enabled: true, lock_id: `${customerId}-b` }, + }); + expect(rejected.allowed).toBe(false); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 96, + usage: 4, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 4, + limit: 5, + }); + }, +); + +// ── Contract: finalize at lock value does not double count ──────── +test.concurrent( + `${chalk.yellowBright("usage-window-lock2: finalize at the lock value leaves the counter unchanged")}`, + async () => { + const freePlan = products.base({ + id: "uw-check-confirm", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-check-confirm-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + await deleteLock({ ctx, lockId: customerId }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 3, + lock: { enabled: true, lock_id: customerId }, + }); + + await autumnV2_3.balances.finalize({ + lock_id: customerId, + action: "confirm", + }); + + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 97, + usage: 3, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + }, +); + +// ── Contract: finalize below the lock decrements the counter ────── +test.concurrent( + `${chalk.yellowBright("usage-window-lock3: finalize below the lock value frees window headroom")}`, + async () => { + const freePlan = products.base({ + id: "uw-check-unwind", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-check-unwind-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + await deleteLock({ ctx, lockId: customerId }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Lock 4, finalize at 1: the unwind must give 3 units of headroom back. + await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 4, + lock: { enabled: true, lock_id: customerId }, + }); + await autumnV2_3.balances.finalize({ + lock_id: customerId, + action: "confirm", + override_value: 1, + }); + + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 99, + usage: 1, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 1, + limit: 5, + }); + + // The freed headroom (4) is consumable: a track of 5 clamps to 4. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 95, + usage: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); + +// ── Contract: finalize above the lock is capped at the window limit ── +test.concurrent( + `${chalk.yellowBright("usage-window-lock4: finalize above the lock value is capped at the window limit")}`, + async () => { + const freePlan = products.base({ + id: "uw-lock-overfinal", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-lock-overfinal-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + await deleteLock({ ctx, lockId: customerId }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Lock 3 of the 5-unit cap (counter 3, headroom 2)... + const granted = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 3, + lock: { enabled: true, lock_id: customerId }, + }); + expect(granted.allowed).toBe(true); + + // ...then finalize at 6: the extra 3 must clamp to the remaining headroom + // of 2, landing the final usage exactly at the cap. + await autumnV2_3.balances.finalize({ + lock_id: customerId, + action: "confirm", + override_value: 6, + }); + + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 95, + usage: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // The cap is exhausted: a further track fully clamps. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 95, + usage: 5, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-multi-feature-caps.test.ts b/server/tests/integration/balances/usage-windows/usage-window-multi-feature-caps.test.ts new file mode 100644 index 000000000..71b0bd95f --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-multi-feature-caps.test.ts @@ -0,0 +1,177 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, +} from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +/** + * TDD test for INDIVIDUAL usage limits on two member features (action1 + + * action2) of one credit system: each cap gates only its own feature while + * both features drain the shared credits pool. + * Credit costs: 1 action1 = 0.2 credits, 1 action2 = 0.6 credits. + * + * Contract under test: + * - caps armed together: action1 -> 5 units, action2 -> 4 units + * - each feature's checks/tracks are gated by ITS cap only; exhausting + * action1's cap leaves action2 open + * - both drain shared credits: final usage = 5*0.2 + 4*0.6 = 3.4 credits + * - each usage_limits entry reports its own window usage + * - a direct credits check is not gated by either member cap + */ + +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +test.concurrent( + `${chalk.yellowBright("uw-multi-cap1: individual caps on action1 and action2 over shared credits")}`, + async () => { + const freePlan = products.base({ + id: "uw-multi-cap-credits", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-multi-cap-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + // Arm BOTH caps in one update (billing_controls replaces the array). + await timeout(2000); + await autumnV2_3.customers.update(customerId, { + billing_controls: { + usage_limits: [ + { + feature_id: TestFeature.Action1, + limit: 5, + interval: ResetInterval.Month, + }, + { + feature_id: TestFeature.Action2, + limit: 4, + interval: ResetInterval.Month, + }, + ], + }, + }); + await timeout(3000); + + // ── action1: 3 of 5 used (0.6 credits) ── + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 3, + }); + + const action1Within = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 2, + }); + expect(action1Within.allowed).toBe(true); + + const action1Beyond = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 3, + }); + expect(action1Beyond.allowed).toBe(false); + + // ── action2 is untouched by action1's usage: its own cap (4) gates it ── + const action2Fresh = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action2, + required_balance: 4, + }); + expect(action2Fresh.allowed).toBe(true); + + const action2FreshBeyond = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action2, + required_balance: 5, + }); + expect(action2FreshBeyond.allowed).toBe(false); + + // ── action2: 2 of 4 used (1.2 credits) ── + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action2, + value: 2, + }); + + // ── Exhaust action1 (track 5, clamps to 2 -> 5 total = 1 credit) ── + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + const action1Exhausted = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action1, + required_balance: 1, + }); + expect(action1Exhausted.allowed).toBe(false); + + // action2 still open for its remaining 2 units. + const action2StillOpen = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Action2, + required_balance: 2, + }); + expect(action2StillOpen.allowed).toBe(true); + + // ── Exhaust action2 (track 5, clamps to 2 -> 4 total = 2.4 credits) ── + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action2, + value: 5, + }); + + // ── Shared pool drained by both: 5*0.2 + 4*0.6 = 3.4 credits ── + await timeout(3000); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 96.6, + usage: 3.4, + }); + + // ── Each entry reports its own window usage ── + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action1, + usage: 5, + limit: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Action2, + usage: 4, + limit: 4, + }); + + // ── Neither member cap gates a direct credits check ── + const creditsCheck = await autumnV2_3.check({ + customer_id: customerId, + feature_id: TestFeature.Credits, + required_balance: 50, + }); + expect(creditsCheck.allowed).toBe(true); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-own-feature.test.ts b/server/tests/integration/balances/usage-windows/usage-window-own-feature.test.ts new file mode 100644 index 000000000..4baca374d --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-own-feature.test.ts @@ -0,0 +1,310 @@ +import { test } from "bun:test"; +import type { ApiCustomerV5 } from "@autumn/shared"; +import { ApiVersion, ResetInterval } from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { expectUsageLimitCorrect } from "@tests/integration/utils/expectUsageLimitCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +// Usage windows where the cap is set on the TRACKED feature's own id (no +// credit-system indirection): the customer holds cusEnts of the capped +// feature directly, including MULTIPLE cusEnts whose drains must aggregate +// onto one customer-scoped counter, and interval mismatches between the +// cusEnt's reset and the cap's window. + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// Same feature, same interval (credits/mo cusEnt + credits 5/mo cap), tracked +// directly: the cap binds independently of the 100-credit balance. +test.concurrent( + `${chalk.yellowBright("usage-window-own-feature1: cap on the tracked feature itself clamps at the cap")}`, + async () => { + const customerProduct = products.base({ + id: "uw-own-same-interval", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-own-same-interval-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + }); + + // Consume exactly to the cap, tracking the capped feature directly. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 5, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 95, + usage: 5, + }); + + // Over the cap: clamps to 0 with 95 credits still in the balance. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 95, + usage: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 5, + limit: 5, + }); + }, +); + +// Sub-interval cap: the cusEnt resets monthly but the cap windows daily. +test.concurrent( + `${chalk.yellowBright("usage-window-own-feature2: daily cap on a monthly cusEnt clamps while balance remains")}`, + async () => { + const customerProduct = products.base({ + id: "uw-own-day-cap", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-own-day-cap-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 2, + interval: ResetInterval.Day, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 2, + }); + + // The day window is exhausted; the next track clamps to 0 against the 98 + // remaining monthly credits. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + granted: 100, + remaining: 98, + usage: 2, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + usage: 2, + limit: 2, + }); + }, +); + +// MULTIPLE cusEnts of the capped feature (monthly 1 + lifetime 10): one +// direct track spans both, and both drains must land on the same counter. +test.concurrent( + `${chalk.yellowBright("usage-window-own-feature3: direct track across two cusEnts aggregates onto one counter")}`, + async () => { + const monthlyCreditsItem = items.monthlyCredits({ includedUsage: 1 }); + const lifetimeCreditsItem = constructFeatureItem({ + featureId: TestFeature.Credits, + includedUsage: 10, + interval: null, + }); + const freePlan = products.base({ + id: "uw-own-multi-credit", + items: [monthlyCreditsItem, lifetimeCreditsItem], + }); + + const customerId = "uw-own-multi-credit-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 2, + interval: ResetInterval.Day, + }); + + // Track 2 credits directly: 1 drains the monthly cusEnt, 1 spills into + // lifetime. The counter must see the SUM (2), not one cusEnt's drain. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 2, + }); + const atCap = await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: atCap, + featureId: TestFeature.Credits, + granted: 11, + remaining: 9, + usage: 2, + breakdown: { + [ResetInterval.Month]: { remaining: 0, usage: 1 }, + [ResetInterval.OneOff]: { remaining: 9, usage: 1 }, + }, + }); + + // Cap exhausted: clamps to 0 instead of draining the 9 lifetime credits. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 1, + }); + const afterClamp = + await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: afterClamp, + featureId: TestFeature.Credits, + granted: 11, + remaining: 9, + usage: 2, + }); + + expectUsageLimitCorrect({ + customer: afterClamp, + featureId: TestFeature.Credits, + usage: 2, + limit: 2, + }); + }, +); + +// Metered (non-credit) own-feature cap across two cusEnts: messages monthly +// 100 + lifetime 100, cap 150/mo. The second track must clamp to the exact +// remaining headroom after the first track spanned both cusEnts. +test.concurrent( + `${chalk.yellowBright("usage-window-own-feature4: metered cap aggregates across two cusEnts and clamps to headroom")}`, + async () => { + const monthlyMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const lifetimeMessagesItem = items.lifetimeMessages({ + includedUsage: 100, + }); + const freePlan = products.base({ + id: "uw-own-multi-messages", + items: [monthlyMessagesItem, lifetimeMessagesItem], + }); + + const customerId = "uw-own-multi-messages-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 150, + }); + + // Track 120: drains the monthly cusEnt (100) then 20 from lifetime. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + const afterSpan = await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: afterSpan, + featureId: TestFeature.Messages, + granted: 200, + remaining: 80, + usage: 120, + breakdown: { + [ResetInterval.Month]: { remaining: 0, usage: 100 }, + [ResetInterval.OneOff]: { remaining: 80, usage: 20 }, + }, + }); + + // Counter = 120 across both cusEnts, so headroom is 30: track 50 applies + // exactly 30 from lifetime. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + const atCap = await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: atCap, + featureId: TestFeature.Messages, + granted: 200, + remaining: 50, + usage: 150, + breakdown: { + [ResetInterval.Month]: { remaining: 0, usage: 100 }, + [ResetInterval.OneOff]: { remaining: 50, usage: 50 }, + }, + }); + + expectUsageLimitCorrect({ + customer: atCap, + featureId: TestFeature.Messages, + usage: 150, + limit: 150, + }); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-persistence.test.ts b/server/tests/integration/balances/usage-windows/usage-window-persistence.test.ts new file mode 100644 index 000000000..b98990bd6 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-persistence.test.ts @@ -0,0 +1,416 @@ +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + ApiVersion, + EntInterval, + ResetInterval, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { expectUsageLimitCorrect } from "@tests/integration/utils/expectUsageLimitCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { sql } from "drizzle-orm"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; +import { + expectCustomerBalance, + expectCustomerUsageLimit, + setCustomerUsageLimit, +} from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { expireUsageWindowForReset } from "../utils/usage-limit-utils/expireUsageWindowForReset.js"; + +// Usage-window PERSISTENCE: the counter's fate across cache-invalidating +// events (config changes, re-grants, plan replacement) and cache loss. The +// counter survives via the batched Redis->PG sync + rebuild rehydration, so +// these tests wait ~4s after capped tracks before invalidating mutations. + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped +const queryRows = (result: unknown): any[] => + // biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped + Array.isArray(result) ? result : ((result as { rows?: any[] })?.rows ?? []); + +// No manual sync flush: the counter must survive the mutation's cache +// invalidation on its own, else the cap silently resets and hands out fresh +// headroom. +test.concurrent( + `${chalk.yellowBright("usage-window-persistence1: lowering the cap below current usage keeps the counter (clamps, no reset)")}`, + async () => { + const customerProduct = products.base({ + id: "uw-persist-lowercap", + items: [items.monthlyMessages({ includedUsage: 1000 })], + }); + + const customerId = "uw-persist-lowercap-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 10, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 8, + }); + + // Let the batched sync flush the counter to Postgres before the + // cache-invalidating mutation (the rebuild rehydrates from PG). + await timeout(4000); + + await autumnV2_3.customers.update(customerId, { + billing_controls: { + usage_limits: [ + { + feature_id: TestFeature.Messages, + limit: 3, + interval: ResetInterval.Month, + }, + ], + }, + }); + + // The counter survived the cap change, so the next track fully clamps. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + remaining: 992, + usage: 8, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 8, + limit: 3, + }); + + // Write-through: after the sync flush, the same state must come back from + // Postgres (skip_cache bypasses Redis entirely). + await timeout(4000); + const fromDb = await autumnV2_3.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + remaining: 992, + usage: 8, + }); + expectUsageLimitCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + usage: 8, + limit: 3, + }); + }, +); + +// A second balance grant (balances.create) is a cache-invalidating mutation; +// the cap counter must survive it. It used to reset to 0, opening fresh +// headroom. +test.concurrent( + `${chalk.yellowBright("usage-window-persistence2: the cap counter survives a re-grant (clamps)")}`, + async () => { + const customerProduct = products.base({ + id: "uw-persist-regrant", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-persist-regrant-1"; + await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + }); + + // Let the batched sync flush the counter to Postgres before the + // cache-invalidating mutation (the rebuild rehydrates from PG). + await timeout(4000); + + // Re-grant a second balance for the same feature while at the cap. + await autumnV2_3.post("/balances.create", { + customer_id: customerId, + feature_id: TestFeature.Messages, + included_grant: 100, + reset: { interval: EntInterval.Month }, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + await expectCustomerBalance({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + }); + await expectCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // Write-through: the post-regrant counter must come back from Postgres. + await timeout(4000); + const fromDb = await autumnV2_3.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + granted: 200, + remaining: 195, + usage: 5, + }); + expectUsageLimitCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); + +// A missing '_usage_windows' field FAILS OPEN (deliberate for v1): the track +// succeeds and the window simply restarts from zero. This documents the +// accepted trade-off -- a lost counter field grants fresh headroom rather than +// erroring. Stale-cache guards may return in a future iteration. +test.concurrent( + `${chalk.yellowBright("usage-window-persistence4: missing _usage_windows field fails open (counter restarts)")}`, + async () => { + const freePlan = products.base({ + id: "uw-persist-failopen", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-persist-failopen-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Establish a counter. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2, + }); + + // Simulate a stale/partial cache: the counter field vanishes while the + // subject view stays valid. + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Messages, + }); + await ctx.redisV2.hdel(balanceKey, "_usage_windows"); + + // Fail open: the track succeeds; the window restarted, so only this track + // counts toward the cap (balance itself is unaffected: 3 tracked total). + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 1, + }); + const afterRestart = + await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: afterRestart, + featureId: TestFeature.Messages, + remaining: 97, + usage: 3, + }); + expectUsageLimitCorrect({ + customer: afterRestart, + featureId: TestFeature.Messages, + usage: 1, + limit: 5, + }); + + // Enforcement continues from the restarted counter: headroom is 4, so a + // track of 5 clamps to 4. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 5, + }); + const atCap = await autumnV2_3.customers.get(customerId); + expectBalanceCorrect({ + customer: atCap, + featureId: TestFeature.Messages, + remaining: 93, + usage: 7, + }); + expectUsageLimitCorrect({ + customer: atCap, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + + // Write-through: the restarted counter upserts over the pre-restart row + // (same logical window key) and must come back from Postgres. + await timeout(4000); + const fromDb = await autumnV2_3.customers.get(customerId, { + skip_cache: "true", + }); + expectBalanceCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + remaining: 93, + usage: 7, + }); + expectUsageLimitCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + usage: 5, + limit: 5, + }); + }, +); + +// Lazy roll: a counter whose stored window closed must zero IN PLACE on any +// subject read -- and two CONCURRENT reads must both succeed (the roll is +// idempotent: PG update by id, atomic Lua cache patch). +test.concurrent( + `${chalk.yellowBright("usage-window-persistence5: an expired counter rolls to zero on (concurrent) reads")}`, + async () => { + const freePlan = products.base({ + id: "uw-persist-lazyreset", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-persist-lazyreset-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2, + }); + + // Flush, then close the counter's window in both stores. + await timeout(4000); + await expireUsageWindowForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Concurrent reads: both succeed and both report the rolled count. + const [customer, concurrentCustomer] = await Promise.all([ + autumnV2_3.customers.get(customerId), + autumnV2_3.customers.get(customerId), + ]); + expectUsageLimitCorrect({ + customer, + featureId: TestFeature.Messages, + usage: 0, + limit: 5, + }); + expectUsageLimitCorrect({ + customer: concurrentCustomer, + featureId: TestFeature.Messages, + usage: 0, + limit: 5, + }); + + // The row persists, zeroed, with bounds advanced to the live cycle. + const rows = queryRows( + await ctx.db.execute(sql` + SELECT usage, window_end_at FROM usage_windows + WHERE feature_id = ${TestFeature.Messages} + AND internal_customer_id = ( + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + ) + `), + ); + expect(rows).toHaveLength(1); + expect(Number(rows[0].usage)).toBe(0); + expect(Number(rows[0].window_end_at)).toBeGreaterThan(Date.now()); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-reset.test.ts b/server/tests/integration/balances/usage-windows/usage-window-reset.test.ts new file mode 100644 index 000000000..163aba43b --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-reset.test.ts @@ -0,0 +1,383 @@ +import { expect, test } from "bun:test"; +import { type ApiCustomerV5, ApiVersion } from "@autumn/shared"; +import { expectUsageLimitCorrect } from "@tests/integration/utils/expectUsageLimitCorrect.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { sql } from "drizzle-orm"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; +import { setCustomerUsageLimit } from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; +import { expireUsageWindowForReset } from "../utils/usage-limit-utils/expireUsageWindowForReset.js"; +import { fetchActivePlanCusEnt } from "../utils/usage-limit-utils/usageWindowDbTestUtils.js"; + +// Usage-window LAZY ROLL, per read path (mirrors reset/get-customer-reset): +// once a counter's stored window closes, ANY subject read -- DB (skip_cache), +// cached, or entity-scoped -- must report usage 0 and ROLL the row in place +// (usage zeroed, bounds advanced to the current derivation) in PG + cache. + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped +const queryRows = (result: unknown): any[] => + // biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped + Array.isArray(result) ? result : ((result as { rows?: any[] })?.rows ?? []); + +const HOUR_MS = 60 * 60 * 1000; + +const fetchWindowRows = async ({ + ctx, + customerId, + featureId, +}: { + ctx: TestContext; + customerId: string; + featureId: string; +}) => + queryRows( + await ctx.db.execute(sql` + SELECT id, window_end_at, usage FROM usage_windows + WHERE feature_id = ${featureId} + AND internal_customer_id = ( + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + ) + `), + ); + +// ───────────────────────────────────────────────────────────────── +// GET /customers (skip_cache) — DB path lazy reset +// ───────────────────────────────────────────────────────────────── + +test.concurrent( + `${chalk.yellowBright("usage-window-reset1 (DB): skip_cache GET prunes an expired window")}`, + async () => { + const freePlan = products.base({ + id: "uw-reset-db", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-reset-db-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + // Flush the counter to Postgres, then close its window in both stores. + await timeout(4000); + expect( + await fetchWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }), + ).toHaveLength(1); + await expireUsageWindowForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // DB-path read: reports a fresh window and prunes the expired row. + const fromDb = await autumnV2_3.customers.get(customerId, { + skip_cache: "true", + }); + expectUsageLimitCorrect({ + customer: fromDb, + featureId: TestFeature.Messages, + usage: 0, + limit: 5, + }); + + // The row PERSISTS, rolled in place: usage zeroed, bounds advanced to the + // current derivation (the messages ent's cycle). + const messagesEnt = await fetchActivePlanCusEnt({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + const rolledRows = await fetchWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rolledRows).toHaveLength(1); + expect(Number(rolledRows[0].usage)).toBe(0); + expect(Number(rolledRows[0].window_end_at)).toBe( + Number(messagesEnt.next_reset_at), + ); + + // The cache field is rolled too (the DB-path roll patches both stores). + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Messages, + }); + const rolledJson = await ctx.redisV2.hget(balanceKey, "_usage_windows"); + expect(rolledJson).toBeTruthy(); + const rolledCache = JSON.parse(rolledJson as string); + expect(rolledCache).toHaveLength(1); + expect(Number(rolledCache[0].usage)).toBe(0); + }, +); + +// ───────────────────────────────────────────────────────────────── +// GET /customers (cached) — cache path lazy reset +// ───────────────────────────────────────────────────────────────── + +test.concurrent( + `${chalk.yellowBright("usage-window-reset2 (cache): cached GET prunes an expired window")}`, + async () => { + const freePlan = products.base({ + id: "uw-reset-cache", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-reset-cache-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 3, + }); + + // Warm the cache with the live counter visible. + const warm = await autumnV2_3.customers.get(customerId); + expectUsageLimitCorrect({ + customer: warm, + featureId: TestFeature.Messages, + usage: 3, + limit: 5, + }); + + // Flush to Postgres, then close the window in both stores. + await timeout(4000); + await expireUsageWindowForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Cache-path read: reports a fresh window and prunes the expired row. + const cached = await autumnV2_3.customers.get(customerId); + expectUsageLimitCorrect({ + customer: cached, + featureId: TestFeature.Messages, + usage: 0, + limit: 5, + }); + + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Messages, + }); + const rolledJson = await ctx.redisV2.hget(balanceKey, "_usage_windows"); + expect(rolledJson).toBeTruthy(); + const rolledCache = JSON.parse(rolledJson as string); + expect(rolledCache).toHaveLength(1); + expect(Number(rolledCache[0].usage)).toBe(0); + + const rolledRows = await fetchWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(rolledRows).toHaveLength(1); + expect(Number(rolledRows[0].usage)).toBe(0); + }, +); + +// ───────────────────────────────────────────────────────────────── +// GET /entities — entity-subject path lazy reset +// ───────────────────────────────────────────────────────────────── + +// Entity-scoped usage limits aren't writable in v1, but the roll machinery +// must already handle entity-scoped counter rows (seeded here directly) so the +// future entity path inherits a working lazy roll. The customer-scoped live +// counter must survive the entity-scoped zeroing. +test.concurrent( + `${chalk.yellowBright("usage-window-reset3 (entity): an entity read zeroes its expired window, customer counter untouched")}`, + async () => { + const freePlan = products.base({ + id: "uw-reset-entity", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyUsers({ includedUsage: 5 }), + ], + }); + + const customerId = "uw-reset-entity-1"; + const { ctx, entities } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freePlan] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [s.billing.attach({ productId: freePlan.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Messages, + limit: 5, + }); + + // Live CUSTOMER-scoped counter in the current window; flush it to + // Postgres so the end-state assertion can see it. + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 2, + }); + await timeout(4000); + + const customerRow = queryRows( + await ctx.db.execute(sql` + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + `), + )[0]; + expect(customerRow?.internal_id).toBeTruthy(); + const entityRow = queryRows( + await ctx.db.execute(sql` + SELECT internal_id FROM entities + WHERE internal_customer_id = ${customerRow.internal_id} + AND id = ${entities[0].id} + LIMIT 1 + `), + )[0]; + expect(entityRow?.internal_id).toBeTruthy(); + const messagesEnt = queryRows( + await ctx.db.execute(sql` + SELECT internal_feature_id FROM customer_entitlements + WHERE customer_id = ${customerId} AND feature_id = ${TestFeature.Messages} + LIMIT 1 + `), + )[0]; + expect(messagesEnt?.internal_feature_id).toBeTruthy(); + + // Seed a CLOSED, ENTITY-scoped window row into both stores. + const now = Date.now(); + const closedEntityWindow = { + id: "uw_test_entity_closed", + internal_customer_id: customerRow.internal_id, + internal_entity_id: entityRow.internal_id, + feature_id: TestFeature.Messages, + internal_feature_id: messagesEnt.internal_feature_id, + anchor_customer_entitlement_id: null, + window_start_at: now - 3 * HOUR_MS, + window_end_at: now - HOUR_MS, + usage: 4, + updated_at: now, + }; + await ctx.db.execute(sql` + INSERT INTO usage_windows ( + id, internal_customer_id, internal_entity_id, feature_id, + internal_feature_id, anchor_customer_entitlement_id, + window_start_at, window_end_at, usage, updated_at + ) VALUES ( + ${closedEntityWindow.id}, ${closedEntityWindow.internal_customer_id}, + ${closedEntityWindow.internal_entity_id}, ${closedEntityWindow.feature_id}, + ${closedEntityWindow.internal_feature_id}, NULL, + ${closedEntityWindow.window_start_at}, ${closedEntityWindow.window_end_at}, + ${closedEntityWindow.usage}, ${closedEntityWindow.updated_at} + ) + `); + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Messages, + }); + const liveJson = await ctx.redisV2.hget(balanceKey, "_usage_windows"); + expect(liveJson).toBeTruthy(); + await ctx.redisV2.hset( + balanceKey, + "_usage_windows", + JSON.stringify([...JSON.parse(liveJson as string), closedEntityWindow]), + ); + + // An ENTITY read runs the lazy reset on the entity subject, which carries + // the entity-scoped rows. + await autumnV2_3.entities.get(customerId, entities[0].id); + + // The expired entity row persists, ZEROED in place... + const entityRows = queryRows( + await ctx.db.execute(sql` + SELECT id, usage FROM usage_windows WHERE id = ${closedEntityWindow.id} + `), + ); + expect(entityRows).toHaveLength(1); + expect(Number(entityRows[0].usage)).toBe(0); + + // ...and in the cache field, while the live customer-scoped counter + // survives untouched. + const rolledJson = await ctx.redisV2.hget(balanceKey, "_usage_windows"); + expect(rolledJson).toBeTruthy(); + const rolledWindows = JSON.parse(rolledJson as string) as { + internal_entity_id: string | null; + usage: number; + }[]; + expect(rolledWindows).toHaveLength(2); + const customerRowCached = rolledWindows.find( + (w) => w.internal_entity_id == null, + ); + const entityRowCached = rolledWindows.find( + (w) => w.internal_entity_id != null, + ); + expect(Number(customerRowCached?.usage)).toBe(2); + expect(Number(entityRowCached?.usage)).toBe(0); + + const allRows = await fetchWindowRows({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(allRows).toHaveLength(2); + }, +); diff --git a/server/tests/integration/balances/usage-windows/usage-window-sync.test.ts b/server/tests/integration/balances/usage-windows/usage-window-sync.test.ts new file mode 100644 index 000000000..a9efc0c58 --- /dev/null +++ b/server/tests/integration/balances/usage-windows/usage-window-sync.test.ts @@ -0,0 +1,392 @@ +import { expect, test } from "bun:test"; +import { ApiVersion, ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { sql } from "drizzle-orm"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { syncItemV4 } from "@/internal/balances/utils/sync/syncItemV4.js"; +import type { UsageWindowUpdate } from "@/internal/balances/utils/types/usageWindowUpdate.js"; +import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; +import { setCustomerUsageLimit } from "../utils/usage-limit-utils/customerUsageLimitUtils.js"; + +// Usage-window SYNC & STORAGE: infrastructure state, not track responses. +// Where the counter lives in Redis (the reserved '_usage_windows' field), how +// it writes through to the customer-scoped usage_windows Postgres table, and +// the race-safety contract of that mirror (upsert on the logical key). + +// initScenario only exposes clients up to V2_2; build the latest-version client +// directly (same pattern as the v2.2-vs-v2.3 parity tests). +const autumnV2_3 = new AutumnInt({ version: ApiVersion.V2_3 }); + +// biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped +const queryRows = (result: unknown): any[] => + // biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped + Array.isArray(result) ? result : ((result as { rows?: any[] })?.rows ?? []); + +const HOUR_MS = 60 * 60 * 1000; + +// Storage shape: the counter lives in the capped feature's balance hash under +// the reserved '_usage_windows' field (customer-scoped rows), NOT inside any +// customer-entitlement blob. +test.concurrent( + `${chalk.yellowBright("usage-window-sync1: counter lives in the _usage_windows hash field, not the cus-ent blob")}`, + async () => { + const customerProduct = products.base({ + id: "uw-sync-storage", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-sync-storage-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + interval: ResetInterval.Day, + }); + + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 1, + }); + + const creditsEnt = queryRows( + await ctx.db.execute(sql` + SELECT id FROM customer_entitlements + WHERE customer_id = ${customerId} AND feature_id = ${TestFeature.Credits} + LIMIT 1 + `), + )[0]; + expect(creditsEnt?.id).toBeTruthy(); + + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Credits, + }); + + // Counter: one customer-scoped row in the reserved field, in credits units + // (1 action1 = 0.2 credits). + const usageWindowsJson = await ctx.redisV2.hget( + balanceKey, + "_usage_windows", + ); + expect(usageWindowsJson).toBeTruthy(); + const usageWindows = JSON.parse(usageWindowsJson as string); + expect(Array.isArray(usageWindows)).toBe(true); + expect(usageWindows).toHaveLength(1); + expect(usageWindows[0].feature_id).toBe(TestFeature.Credits); + expect(usageWindows[0].internal_entity_id).toBeNull(); + expect(typeof usageWindows[0].id).toBe("string"); + expect(Number(usageWindows[0].usage)).toBe(0.2); + + // The cus-ent blob no longer embeds windows. + const blobJson = await ctx.redisV2.hget(balanceKey, creditsEnt.id); + expect(blobJson).toBeTruthy(); + const blob = JSON.parse(blobJson as string); + expect(blob.usage_windows).toBeUndefined(); + + // The hash must carry a TTL (counters never outlive the cache contract). + const ttl = await ctx.redisV2.ttl(balanceKey); + expect(ttl).toBeGreaterThan(0); + }, +); + +// Write-through: the Redis counter must reach the customer-scoped +// usage_windows table via the shared sync. +test.concurrent( + `${chalk.yellowBright("usage-window-sync2: window counter writes through to the usage_windows table")}`, + async () => { + const customerProduct = products.base({ + id: "uw-sync-write", + items: [items.monthlyCredits({ includedUsage: 100 })], + }); + + const customerId = "uw-sync-write-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + await setCustomerUsageLimit({ + autumn: autumnV2_3, + customerId, + featureId: TestFeature.Credits, + limit: 5, + interval: ResetInterval.Day, + }); + + // 5 action1 = 1 credit; under the 5-credit/day cap. The counter is a + // customer-scoped row on the credits feature (balance dimension). + await autumnV2_3.track({ + customer_id: customerId, + feature_id: TestFeature.Action1, + value: 5, + }); + + const customerRow = queryRows( + await ctx.db.execute(sql` + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + `), + )[0]; + expect(customerRow?.internal_id).toBeTruthy(); + + const creditsEnt = queryRows( + await ctx.db.execute(sql` + SELECT id, internal_feature_id FROM customer_entitlements + WHERE customer_id = ${customerId} AND feature_id = ${TestFeature.Credits} + LIMIT 1 + `), + )[0]; + expect(creditsEnt?.id).toBeTruthy(); + + // Build the typed update from the live counter field (production hands + // this down from the Lua result), then drive the write-through. + const usageWindowsJson = await ctx.redisV2.hget( + buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId: TestFeature.Credits, + }), + "_usage_windows", + ); + expect(usageWindowsJson).toBeTruthy(); + + await syncItemV4({ + ctx, + payload: { + customerId, + orgId: ctx.org.id, + env: ctx.env, + timestamp: Date.now(), + modifiedCusEntIdsByFeatureId: { + [TestFeature.Credits]: [creditsEnt.id], + }, + usageWindowUpdates: [ + { + internal_customer_id: customerRow.internal_id, + feature_id: TestFeature.Credits, + usage_windows: JSON.parse(usageWindowsJson as string), + }, + ], + }, + }); + + const windowRows = queryRows( + await ctx.db.execute(sql` + SELECT feature_id, internal_feature_id, internal_entity_id, + anchor_customer_entitlement_id, usage + FROM usage_windows + WHERE internal_customer_id = ${customerRow.internal_id} + AND feature_id = ${TestFeature.Credits} + `), + ); + expect(windowRows).toHaveLength(1); + expect(windowRows[0].feature_id).toBe(TestFeature.Credits); + expect(windowRows[0].internal_feature_id).toBe( + creditsEnt.internal_feature_id, + ); + // Customer scope (no entity) with bounds provenance from the credits ent. + expect(windowRows[0].internal_entity_id).toBeNull(); + expect(windowRows[0].anchor_customer_entitlement_id).toBe(creditsEnt.id); + expect(Number(windowRows[0].usage)).toBe(1); + }, +); + +/** + * Race-safety contract of the PG mirror (sync_balances_v2 STEP 4): + * - CREATE: syncing a snapshot for a scope with no existing row inserts it + * with the snapshot's id. + * - CONCURRENT CREATE: two parallel syncs for the same scope key + * (internal_customer_id, feature_id, entity-nullsafe) with DIFFERENT + * candidate ids both succeed -- no unique-violation abort -- and exactly + * one row exists, id = one of the candidates. + * - LAST-WRITE-WINS: the row's usage ends at the snapshot with the highest + * updated_at; a stale snapshot synced later never clobbers a newer value. + * - ID STABILITY: a newer snapshot with a different id updates the row but + * never changes the stored id (DO UPDATE excludes id). + * - ROLL FORWARD: a snapshot with advanced bounds moves the SAME row's + * window_start_at/window_end_at in place (one mutable row per scope). + */ +test.concurrent( + `${chalk.yellowBright("usage-window-sync3: PG mirror upserts on the scope key, race-safe on create")}`, + async () => { + const customerProduct = products.base({ + id: "uw-sync-upsert", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const customerId = "uw-sync-upsert-1"; + const { ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProduct] }), + ], + actions: [s.billing.attach({ productId: customerProduct.id })], + }); + + const customerRow = queryRows( + await ctx.db.execute(sql` + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + `), + )[0]; + expect(customerRow?.internal_id).toBeTruthy(); + const internalCustomerId = customerRow.internal_id as string; + + const messagesFeature = ctx.features.find( + (feature) => feature.id === TestFeature.Messages, + ); + expect(messagesFeature?.internal_id).toBeTruthy(); + + const now = Date.now(); + const activeWindowStart = now - HOUR_MS; + const activeWindowEnd = now + HOUR_MS; + + const buildWindowRow = ({ + id, + usage, + updatedAt, + windowStartAt = activeWindowStart, + windowEndAt = activeWindowEnd, + }: { + id: string; + usage: number; + updatedAt: number; + windowStartAt?: number; + windowEndAt?: number; + }) => ({ + id, + internal_customer_id: internalCustomerId, + internal_entity_id: null, + feature_id: TestFeature.Messages, + internal_feature_id: messagesFeature?.internal_id as string, + anchor_customer_entitlement_id: null, + window_start_at: windowStartAt, + window_end_at: windowEndAt, + usage, + updated_at: updatedAt, + }); + + const syncSnapshot = (usageWindows: UsageWindowUpdate["usage_windows"]) => + syncItemV4({ + ctx, + payload: { + customerId, + orgId: ctx.org.id, + env: ctx.env, + timestamp: Date.now(), + modifiedCusEntIdsByFeatureId: {}, + usageWindowUpdates: [ + { + internal_customer_id: internalCustomerId, + feature_id: TestFeature.Messages, + usage_windows: usageWindows, + }, + ], + }, + }); + + const fetchWindowRows = async () => + queryRows( + await ctx.db.execute(sql` + SELECT id, window_start_at, window_end_at, usage, updated_at + FROM usage_windows + WHERE internal_customer_id = ${internalCustomerId} + AND feature_id = ${TestFeature.Messages} + ORDER BY window_start_at ASC + `), + ); + + // ── CONCURRENT CREATE ──────────────────────────────────────────────── + // Two parallel syncs race to create the same logical window with + // different candidate ids: no unique-violation abort; one row, id from + // whichever inserted first. + const candidateA = buildWindowRow({ + id: "uw_test_a", + usage: 5, + updatedAt: now - 2000, + }); + const candidateB = buildWindowRow({ + id: "uw_test_b", + usage: 7, + updatedAt: now - 1000, + }); + + await Promise.all([syncSnapshot([candidateA]), syncSnapshot([candidateB])]); + + let windowRows = await fetchWindowRows(); + expect(windowRows).toHaveLength(1); + expect(["uw_test_a", "uw_test_b"]).toContain(windowRows[0].id); + const createdId = windowRows[0].id as string; + + // ── LAST-WRITE-WINS across orderings ───────────────────────────────── + // Whichever commit order the race produced, the surviving usage must be + // the snapshot with the HIGHEST updated_at (candidateB: 7). + expect(Number(windowRows[0].usage)).toBe(7); + + // A stale snapshot (older updated_at) synced afterwards must not clobber. + await syncSnapshot([ + buildWindowRow({ id: "uw_test_stale", usage: 6, updatedAt: now - 5000 }), + ]); + windowRows = await fetchWindowRows(); + expect(windowRows).toHaveLength(1); + expect(Number(windowRows[0].usage)).toBe(7); + + // ── ID STABILITY ───────────────────────────────────────────────────── + // A newer snapshot carrying a DIFFERENT candidate id (e.g. a fail-open + // counter restart minted a fresh ksuid) updates usage on the logical key + // but never replaces the stored id. + await syncSnapshot([ + buildWindowRow({ id: "uw_test_c", usage: 9, updatedAt: now }), + ]); + windowRows = await fetchWindowRows(); + expect(windowRows).toHaveLength(1); + expect(Number(windowRows[0].usage)).toBe(9); + expect(windowRows[0].id).toBe(createdId); + + // ── ROLL FORWARD ───────────────────────────────────────────────────── + // A newer snapshot with ADVANCED bounds moves the same row in place: + // still exactly one row per scope, same id, new window. + const rolledStart = now + HOUR_MS; + const rolledEnd = now + 2 * HOUR_MS; + await syncSnapshot([ + buildWindowRow({ + id: "uw_test_rolled", + usage: 0, + updatedAt: now + 1000, + windowStartAt: rolledStart, + windowEndAt: rolledEnd, + }), + ]); + windowRows = await fetchWindowRows(); + expect(windowRows).toHaveLength(1); + expect(windowRows[0].id).toBe(createdId); + expect(Number(windowRows[0].usage)).toBe(0); + expect(Number(windowRows[0].window_start_at)).toBe(rolledStart); + expect(Number(windowRows[0].window_end_at)).toBe(rolledEnd); + }, +); diff --git a/server/tests/integration/balances/utils/usage-limit-utils/customerUsageLimitUtils.ts b/server/tests/integration/balances/utils/usage-limit-utils/customerUsageLimitUtils.ts new file mode 100644 index 000000000..9a5cfca58 --- /dev/null +++ b/server/tests/integration/balances/utils/usage-limit-utils/customerUsageLimitUtils.ts @@ -0,0 +1,90 @@ +import { + type ApiCustomerV5, + type CustomerBillingControls, + ResetInterval, +} from "@autumn/shared"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js"; +import { expectUsageLimitCorrect } from "@tests/integration/utils/expectUsageLimitCorrect.js"; +import type { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; + +/** + * Arms a windowed hard usage cap via the customer's `usage_limits` billing + * control; `interval` sets the window. + */ +export const setCustomerUsageLimit = async ({ + autumn, + customerId, + featureId, + limit, + interval = ResetInterval.Month, +}: { + autumn: AutumnInt; + customerId: string; + featureId: string; + limit: number; + interval?: ResetInterval; +}) => { + const billingControls: CustomerBillingControls = { + usage_limits: [ + { + feature_id: featureId, + limit, + interval, + }, + ], + }; + + await timeout(2000); + await autumn.customers.update(customerId, { + billing_controls: billingControls, + }); + await timeout(3000); +}; + +/** Fetches the customer (cached read) and asserts a feature balance. */ +export const expectCustomerBalance = async ({ + autumn, + customerId, + featureId, + granted, + remaining, + usage, +}: { + autumn: AutumnInt; + customerId: string; + featureId: string; + granted?: number; + remaining?: number; + usage?: number; +}) => { + const customer = await autumn.customers.get(customerId); + expectBalanceCorrect({ customer, featureId, granted, remaining, usage }); +}; + +/** + * Fetches the customer and asserts the usage_limits entry's current window + * `usage` (and optionally the configured limit). `skipCache` reads through to + * Postgres, verifying the synced counter rather than the Redis one. + */ +export const expectCustomerUsageLimit = async ({ + autumn, + customerId, + featureId, + usage, + limit, + skipCache = false, +}: { + autumn: AutumnInt; + customerId: string; + featureId: string; + usage?: number; + limit?: number; + skipCache?: boolean; +}) => { + const customer = await autumn.customers.get( + customerId, + skipCache ? { skip_cache: "true" } : undefined, + ); + expectUsageLimitCorrect({ customer, featureId, usage, limit }); +}; diff --git a/server/tests/integration/balances/utils/usage-limit-utils/entityUsageLimitUtils.ts b/server/tests/integration/balances/utils/usage-limit-utils/entityUsageLimitUtils.ts new file mode 100644 index 000000000..bc19abbad --- /dev/null +++ b/server/tests/integration/balances/utils/usage-limit-utils/entityUsageLimitUtils.ts @@ -0,0 +1,87 @@ +import { expect } from "bun:test"; +import { + type ApiEntityV2, + type EntityBillingControls, + ResetInterval, +} from "@autumn/shared"; +import type { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; + +/** + * Arms a windowed hard usage cap via the ENTITY's `usage_limits` billing + * control (sibling of setCustomerUsageLimit / setEntitySpendLimit). + */ +export const setEntityUsageLimit = async ({ + autumn, + customerId, + entityId, + featureId, + limit, + interval = ResetInterval.Month, +}: { + autumn: AutumnInt; + customerId: string; + entityId: string; + featureId: string; + limit: number; + interval?: ResetInterval; +}) => { + const billingControls: EntityBillingControls = { + usage_limits: [ + { + feature_id: featureId, + limit, + interval, + }, + ], + }; + + await timeout(2000); + await autumn.entities.update(customerId, entityId, { + billing_controls: billingControls, + }); + await timeout(3000); +}; + +/** + * Fetches the entity and asserts its own `billing_controls.usage_limits` + * entry: configured `limit` and the current window's `usage`. + */ +export const expectEntityUsageLimit = async ({ + autumn, + customerId, + entityId, + featureId, + usage, + limit, + skipCache = false, +}: { + autumn: AutumnInt; + customerId: string; + entityId: string; + featureId: string; + usage?: number; + limit?: number; + skipCache?: boolean; +}) => { + const entity = await autumn.entities.get( + customerId, + entityId, + skipCache ? { skip_cache: "true" } : undefined, + ); + const usageLimit = entity.billing_controls?.usage_limits?.find( + (entry) => entry.feature_id === featureId, + ); + expect( + usageLimit, + `Missing entity usage_limits entry for ${featureId}`, + ).toBeDefined(); + + if (typeof limit !== "undefined") { + expect(usageLimit?.limit).toBe(limit); + } + + if (typeof usage !== "undefined") { + expect(usageLimit?.usage ?? 0).toBe(usage); + } +}; diff --git a/server/tests/integration/balances/utils/usage-limit-utils/expireUsageWindowForReset.ts b/server/tests/integration/balances/utils/usage-limit-utils/expireUsageWindowForReset.ts new file mode 100644 index 000000000..cc51fd718 --- /dev/null +++ b/server/tests/integration/balances/utils/usage-limit-utils/expireUsageWindowForReset.ts @@ -0,0 +1,59 @@ +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { sql } from "drizzle-orm"; +import { buildSharedFullSubjectBalanceKey } from "@/internal/customers/cache/fullSubject/builders/buildSharedFullSubjectBalanceKey.js"; + +const THIRTY_FIVE_DAYS_MS = 35 * 24 * 60 * 60 * 1000; + +/** + * Backdates a feature's usage-window counters in BOTH stores so the window is + * wall-clock closed -- the windows analog of expireCusEntForReset. The next + * subject read should lazily prune the rows. + */ +export const expireUsageWindowForReset = async ({ + ctx, + customerId, + featureId, + shiftMs = THIRTY_FIVE_DAYS_MS, +}: { + ctx: TestContext; + customerId: string; + featureId: string; + shiftMs?: number; +}): Promise => { + await ctx.db.execute(sql` + UPDATE usage_windows + SET window_start_at = window_start_at - ${shiftMs}, + window_end_at = window_end_at - ${shiftMs} + WHERE feature_id = ${featureId} + AND internal_customer_id = ( + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + ) + `); + + const balanceKey = buildSharedFullSubjectBalanceKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + featureId, + }); + const rawWindows = await ctx.redisV2.hget(balanceKey, "_usage_windows"); + if (!rawWindows) return; + + // biome-ignore lint/suspicious/noExplicitAny: raw cached rows are untyped + const backdated = (JSON.parse(rawWindows) as any[]).map((usageWindow) => + usageWindow.feature_id === featureId + ? { + ...usageWindow, + window_start_at: Number(usageWindow.window_start_at) - shiftMs, + window_end_at: Number(usageWindow.window_end_at) - shiftMs, + } + : usageWindow, + ); + await ctx.redisV2.hset( + balanceKey, + "_usage_windows", + JSON.stringify(backdated), + ); +}; diff --git a/server/tests/integration/balances/utils/usage-limit-utils/usageWindowDbTestUtils.ts b/server/tests/integration/balances/utils/usage-limit-utils/usageWindowDbTestUtils.ts new file mode 100644 index 000000000..4370c6918 --- /dev/null +++ b/server/tests/integration/balances/utils/usage-limit-utils/usageWindowDbTestUtils.ts @@ -0,0 +1,88 @@ +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { sql } from "drizzle-orm"; + +// biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped +export const queryRows = (result: unknown): any[] => + // biome-ignore lint/suspicious/noExplicitAny: raw SQL rows are untyped + Array.isArray(result) ? result : ((result as { rows?: any[] })?.rows ?? []); + +/** The feature's cusEnt on the customer's ACTIVE plan (excludes loose grants). */ +export const fetchActivePlanCusEnt = async ({ + ctx, + customerId, + featureId, +}: { + ctx: TestContext; + customerId: string; + featureId: string; +}) => { + const rows = queryRows( + await ctx.db.execute(sql` + SELECT ce.id, ce.internal_feature_id, ce.next_reset_at + FROM customer_entitlements ce + JOIN customer_products cp ON cp.id = ce.customer_product_id + WHERE ce.internal_customer_id = ( + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + ) + AND ce.feature_id = ${featureId} + AND cp.status = 'active' + LIMIT 1 + `), + ); + return rows[0]; +}; + +/** The feature's loose (product-less) cusEnt, e.g. a top-up grant. */ +export const fetchLooseCusEnt = async ({ + ctx, + customerId, + featureId, +}: { + ctx: TestContext; + customerId: string; + featureId: string; +}) => { + const rows = queryRows( + await ctx.db.execute(sql` + SELECT id, internal_feature_id, next_reset_at + FROM customer_entitlements + WHERE internal_customer_id = ( + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + ) + AND feature_id = ${featureId} + AND customer_product_id IS NULL + ORDER BY created_at ASC + LIMIT 1 + `), + ); + return rows[0]; +}; + +/** All usage-window counter rows for a (customer, feature). */ +export const fetchUsageWindowRows = async ({ + ctx, + customerId, + featureId, +}: { + ctx: TestContext; + customerId: string; + featureId: string; +}) => + queryRows( + await ctx.db.execute(sql` + SELECT id, anchor_customer_entitlement_id, internal_entity_id, + window_start_at, window_end_at, usage + FROM usage_windows + WHERE feature_id = ${featureId} + AND internal_customer_id = ( + SELECT internal_id FROM customers + WHERE id = ${customerId} AND org_id = ${ctx.org.id} AND env = ${ctx.env} + LIMIT 1 + ) + ORDER BY window_start_at ASC + `), + ); diff --git a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts index c007c2710..ff470263b 100644 --- a/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts +++ b/server/tests/integration/db/full-subject/utils/fullSubjectScenarioBuilders.ts @@ -93,6 +93,7 @@ const buildCustomer = ({ send_email_receipts: false, auto_topups: null, spend_limits: null, + usage_limits: null, usage_alerts: null, overage_allowed: null, }); diff --git a/server/tests/integration/utils/expectUsageLimitCorrect.ts b/server/tests/integration/utils/expectUsageLimitCorrect.ts new file mode 100644 index 000000000..781bc097a --- /dev/null +++ b/server/tests/integration/utils/expectUsageLimitCorrect.ts @@ -0,0 +1,39 @@ +import { expect } from "bun:test"; +import type { ApiCustomerV5, ResetInterval } from "@autumn/shared"; + +const roundTo8Dp = (value: number) => Math.round(value * 1e8) / 1e8; + +/** Asserts the customer's `billing_controls.usage_limits` entry for a feature. */ +export const expectUsageLimitCorrect = ({ + customer, + featureId, + usage, + limit, + interval, +}: { + customer: ApiCustomerV5; + featureId: string; + usage?: number; + limit?: number; + interval?: ResetInterval; +}) => { + const usageLimit = customer.billing_controls?.usage_limits?.find( + (entry) => entry.feature_id === featureId, + ); + expect( + usageLimit, + `Missing usage_limits entry for ${featureId}`, + ).toBeDefined(); + + if (typeof limit !== "undefined") { + expect(usageLimit?.limit).toBe(limit); + } + + if (typeof interval !== "undefined") { + expect(usageLimit?.interval).toBe(interval); + } + + if (typeof usage !== "undefined") { + expect(roundTo8Dp(usageLimit?.usage ?? 0)).toBe(roundTo8Dp(usage)); + } +}; diff --git a/server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-eom-clamp.test.ts b/server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-eom-clamp.test.ts new file mode 100644 index 000000000..cd0374b95 --- /dev/null +++ b/server/tests/unit/billing/interval/get-cycle-end/get-cycle-end-eom-clamp.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { BillingInterval, getCycleEnd, getCycleStart } from "@autumn/shared"; +import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils"; + +/** + * TDD test for the date-fns clamp-shave bug: when `now` sits on a clamped + * end-of-month boundary 2+ cycles from the anchor, differenceInMonths + * under-counts by one (e.g. differenceInMonths(Apr 30, Jan 31) === 2, not 3), + * and getCycleEnd's single overshoot correction cannot recover. + * + * Red-failure mode (pre-fix): for a few hours after the clamped boundary, + * getCycleEnd returns the boundary itself — a cycle end AT OR BEFORE `now`. + * + * Green-success criteria: getCycleEnd is always strictly after `now`, and + * [getCycleStart, getCycleEnd) always brackets `now`. + * + * date-fns special-cases the 1-month clamp (isLastDayOfMonth && difference + * === 1), which the existing "31 Mar -> 30 Apr 12:01" tests exercise; these + * tests pin the multi-month clamps that the special case does not cover. + * + * Test suite (anchor 31 Jan 2026 10:00 unless noted): + * 1. now 30 Apr 09:00 (before boundary) -> 30 Apr 10:00 (control, passes pre-fix) + * 2. now 30 Apr 10:00 (boundary instant) -> 31 May 10:00 + * 3. now 30 Apr 11:00 (after boundary) -> 31 May 10:00 + * 4. (intervalCount = 3) now 30 Apr 11:00 -> 31 Jul 10:00 + * 5. anchor 31 Mar 12:00, now 30 Jun 12:01 (3 clamped months) -> 31 Jul 12:00 + * 6. annual leap anchor 29 Feb 2024, now 28 Feb 2025 22:00 -> 28 Feb 2026 + * 7. invariant: start <= now < end across the clamped boundary day + */ +describe("get-cycle-end-eom-clamp: clamped EOM boundary 2+ cycles from anchor", () => { + const anchor = toUnix({ year: 2026, month: 1, day: 31, hour: 10 }); + + test("anchor: 31 Jan 10:00, now: 30 Apr 09:00 -> end of cycle should be 30 Apr 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 9 }); + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(4); + expect(day).toBe(30); + expect(hour).toBe(10); + }); + + test("anchor: 31 Jan 10:00, now: 30 Apr 10:00 (boundary instant) -> end of cycle should be 31 May 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 10 }); + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(5); + expect(day).toBe(31); + expect(hour).toBe(10); + expect(result).toBeGreaterThan(now); + }); + + test("anchor: 31 Jan 10:00, now: 30 Apr 11:00 -> end of cycle should be 31 May 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 11 }); + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(5); + expect(day).toBe(31); + expect(hour).toBe(10); + expect(result).toBeGreaterThan(now); + }); + + test("(intervalCount = 3) anchor: 31 Jan 10:00, now: 30 Apr 11:00 -> end of cycle should be 31 Jul 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 11 }); + const result = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 3, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(7); + expect(day).toBe(31); + expect(hour).toBe(10); + expect(result).toBeGreaterThan(now); + }); + + test("anchor: 31 Mar 12:00, now: 30 Jun 12:01 (3 clamped months) -> end of cycle should be 31 Jul 12:00", () => { + const marchAnchor = toUnix({ year: 2026, month: 3, day: 31, hour: 12 }); + const now = toUnix({ + year: 2026, + month: 6, + day: 30, + hour: 12, + minute: 1, + }); + const result = getCycleEnd({ + anchor: marchAnchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(7); + expect(day).toBe(31); + expect(hour).toBe(12); + expect(result).toBeGreaterThan(now); + }); + + test("annual, leap anchor: 29 Feb 2024 12:00, now: 28 Feb 2025 22:00 -> end of cycle should be 28 Feb 2026 12:00", () => { + const leapAnchor = toUnix({ year: 2024, month: 2, day: 29, hour: 12 }); + const now = toUnix({ year: 2025, month: 2, day: 28, hour: 22 }); + const result = getCycleEnd({ + anchor: leapAnchor, + interval: BillingInterval.Year, + intervalCount: 1, + now, + }); + + const { year, month, day, hour } = fromUnix(result); + expect(year).toBe(2026); + expect(month).toBe(2); + expect(day).toBe(28); + expect(hour).toBe(12); + expect(result).toBeGreaterThan(now); + }); + + test("invariant: cycleStart <= now < cycleEnd across the clamped boundary day", () => { + const nows = [ + toUnix({ year: 2026, month: 4, day: 30, hour: 9 }), + toUnix({ year: 2026, month: 4, day: 30, hour: 10 }), + toUnix({ year: 2026, month: 4, day: 30, hour: 11 }), + toUnix({ year: 2026, month: 4, day: 30, hour: 23, minute: 59 }), + toUnix({ year: 2026, month: 5, day: 1, hour: 0, minute: 1 }), + ]; + + for (const now of nows) { + const start = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + const end = getCycleEnd({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + expect(start).toBeLessThanOrEqual(now); + expect(end).toBeGreaterThan(now); + } + }); +}); diff --git a/server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-eom-clamp.test.ts b/server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-eom-clamp.test.ts new file mode 100644 index 000000000..8f0ec9131 --- /dev/null +++ b/server/tests/unit/billing/interval/get-cycle-start/get-cycle-start-eom-clamp.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { BillingInterval, getCycleStart } from "@autumn/shared"; +import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils"; + +/** + * TDD test for the date-fns clamp-shave bug: when `now` sits on a clamped + * end-of-month boundary 2+ cycles from the anchor, differenceInMonths + * under-counts by one (e.g. differenceInMonths(Apr 30, Jan 31) === 2, not 3). + * + * Red-failure mode (pre-fix): getCycleStart only corrects overshoot + * (estimate > now), never undershoot, so for the rest of the clamped + * boundary day it returns the PREVIOUS cycle's start (one cycle stale). + * + * Green-success criteria: getCycleStart returns the latest boundary <= now. + * + * Test suite (anchor 31 Jan 2026 10:00): + * 1. now 30 Apr 09:00 (before boundary) -> 31 Mar 10:00 (control, passes pre-fix) + * 2. now 30 Apr 10:00 (boundary instant) -> 30 Apr 10:00 + * 3. now 30 Apr 11:00 (after boundary) -> 30 Apr 10:00 + * 4. annual leap anchor 29 Feb 2024, now 28 Feb 2025 22:00 -> 28 Feb 2025 + * 5. (intervalCount = 3) now 30 Apr 11:00 -> 30 Apr 10:00 (clamped quarter boundary) + */ +describe("get-cycle-start-eom-clamp: clamped EOM boundary 2+ cycles from anchor", () => { + const anchor = toUnix({ year: 2026, month: 1, day: 31, hour: 10 }); + + test("anchor: 31 Jan 10:00, now: 30 Apr 09:00 -> cycle start should be 31 Mar 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 9 }); + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(3); + expect(day).toBe(31); + expect(hour).toBe(10); + }); + + test("anchor: 31 Jan 10:00, now: 30 Apr 10:00 (boundary instant) -> cycle start should be 30 Apr 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 10 }); + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(4); + expect(day).toBe(30); + expect(hour).toBe(10); + expect(result).toBeLessThanOrEqual(now); + }); + + test("anchor: 31 Jan 10:00, now: 30 Apr 11:00 -> cycle start should be 30 Apr 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 11 }); + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 1, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(4); + expect(day).toBe(30); + expect(hour).toBe(10); + }); + + test("annual, leap anchor: 29 Feb 2024 12:00, now: 28 Feb 2025 22:00 -> cycle start should be 28 Feb 2025 12:00", () => { + const leapAnchor = toUnix({ year: 2024, month: 2, day: 29, hour: 12 }); + const now = toUnix({ year: 2025, month: 2, day: 28, hour: 22 }); + const result = getCycleStart({ + anchor: leapAnchor, + interval: BillingInterval.Year, + intervalCount: 1, + now, + }); + + const { year, month, day, hour } = fromUnix(result); + expect(year).toBe(2025); + expect(month).toBe(2); + expect(day).toBe(28); + expect(hour).toBe(12); + expect(result).toBeLessThanOrEqual(now); + }); + + test("(intervalCount = 3) anchor: 31 Jan 10:00, now: 30 Apr 11:00 -> cycle start should be 30 Apr 10:00", () => { + const now = toUnix({ year: 2026, month: 4, day: 30, hour: 11 }); + const result = getCycleStart({ + anchor, + interval: BillingInterval.Month, + intervalCount: 3, + now, + }); + + const { month, day, hour } = fromUnix(result); + expect(month).toBe(4); + expect(day).toBe(30); + expect(hour).toBe(10); + }); +}); diff --git a/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts index 59a9bbb0d..bbcf88558 100644 --- a/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts +++ b/server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { BillingContext, DbInvoiceLineItem } from "@autumn/shared"; +import type { BillingContext, DbInvoiceLineItem, LineItem } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import { prices } from "@tests/utils/fixtures/db/prices"; @@ -266,6 +266,28 @@ describe(chalk.yellowBright("invoiceCreditFromStoredLineItems"), () => { expect(result.resolvedPriceIds).toEqual(["price_pro", "price_addon"]); expect(result.lineItems).toHaveLength(2); }); + + test("matches charge rows that start exactly at the current timestamp", () => { + const { ctx, customerProduct, billingContext } = buildMultiPriceContext({ + storedChargeLineItems: [ + makeChargeRow({ + id: "li_charge_boundary", + price_id: "price_pro", + effective_period_start: MID_CYCLE, + effective_period_end: PERIOD_END, + }), + ], + }); + + const result = invoiceCreditFromStoredLineItems({ + ctx, + customerProduct, + billingContext, + }); + + expect(result.resolvedPriceIds).toEqual(["price_pro"]); + expect(result.lineItems).toHaveLength(1); + }); }); describe(chalk.yellowBright("getRefundLineItemsForPrice"), () => { @@ -314,4 +336,48 @@ describe(chalk.yellowBright("getRefundLineItemsForPrice"), () => { expect(lineItem.amount).toBeLessThan(0); } }); + + test("keeps refunds for deferred stored charges deferred", () => { + const { ctx, customerProduct, billingContext } = buildSinglePriceContext({ + storedChargeLineItems: [ + makeChargeRow({ id: "li_charge_deferred", invoice_id: null }), + ], + }); + + const result = getRefundLineItemsForPrice({ + ctx, + customerProduct, + billingContext, + priceId: "price_pro", + catalogFallback: undefined, + }); + + expect(result).toHaveLength(1); + expect(result[0].chargeImmediately).toBe(false); + }); + + test("uses the explicit fallback when no stored credit matches the price", () => { + const { ctx, customerProduct, billingContext } = buildSinglePriceContext({ + storedChargeLineItems: [], + }); + const price = customerProduct.customer_prices[0].price; + const catalogFallback = { + id: "invoice_li_explicit_fallback", + amount: -100, + amountAfterDiscounts: -100, + context: { price }, + } as LineItem; + + const result = getRefundLineItemsForPrice({ + ctx, + customerProduct, + billingContext, + priceId: price.id, + catalogFallback, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(catalogFallback.id); + expect(result[0].amountAfterDiscounts).toBe(-100); + }); }); diff --git a/server/tests/unit/full-subject-cache/setSharedFullSubjectBalances.test.ts b/server/tests/unit/full-subject-cache/setSharedFullSubjectBalances.test.ts index 0afd22cb4..af1d0aba6 100644 --- a/server/tests/unit/full-subject-cache/setSharedFullSubjectBalances.test.ts +++ b/server/tests/unit/full-subject-cache/setSharedFullSubjectBalances.test.ts @@ -84,7 +84,6 @@ const buildNormalized = (): NormalizedFullSubject => }, }, rollovers: [], - usage_windows: [], replaceables: [], customerPrice: null, customerProductOptions: null, @@ -93,6 +92,7 @@ const buildNormalized = (): NormalizedFullSubject => }, ], customer_prices: [], + usage_windows: [], flags: {}, products: [], entitlements: [], @@ -128,4 +128,35 @@ describe("setSharedFullSubjectBalances", () => { cus_ent_1: JSON.stringify(normalized.customer_entitlements[0]), }); }); + + test("always writes _usage_windows for capped features, even with no entitlements", () => { + const normalized = buildNormalized(); + + const writes = buildSharedBalanceWrites({ + orgId: "org_1", + env: AppEnv.Live, + customerId: "cus_1", + customerEntitlements: normalized.customer_entitlements, + aggregatedCustomerEntitlements: [], + usageWindows: [], + usageWindowFeatureIds: ["messages", "action1"], + }); + + expect(writes).toHaveLength(2); + + const messagesWrite = writes.find((write) => + write.balanceKey.endsWith(":messages"), + ); + expect(messagesWrite?.fields).toEqual({ + cus_ent_1: JSON.stringify(normalized.customer_entitlements[0]), + _usage_windows: "[]", + }); + + // action1 has no entitlements: the write exists purely to seed the + // fail-closed `_usage_windows` field. + const actionWrite = writes.find((write) => + write.balanceKey.endsWith(":action1"), + ); + expect(actionWrite?.fields).toEqual({ _usage_windows: "[]" }); + }); }); diff --git a/server/tests/unit/usage-windows/computeUsageWindowRolls.test.ts b/server/tests/unit/usage-windows/computeUsageWindowRolls.test.ts new file mode 100644 index 000000000..e51f4e29d --- /dev/null +++ b/server/tests/unit/usage-windows/computeUsageWindowRolls.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import type { UsageWindow, UsageWindowLimit } from "@autumn/shared"; +import { computeUsageWindowRolls } from "@/internal/customers/actions/resetUsageWindows/computeUsageWindowRolls.js"; + +const NOW = Date.UTC(2026, 5, 15, 12, 0, 0); +const HOUR = 60 * 60 * 1000; + +const row = (overrides: Partial): UsageWindow => + ({ + id: "uw_1", + internal_customer_id: "cus_int_1", + internal_entity_id: null, + feature_id: "messages", + internal_feature_id: "imessages", + anchor_customer_entitlement_id: "ce_old", + window_start_at: NOW - HOUR, + window_end_at: NOW + HOUR, + usage: 3, + updated_at: NOW - HOUR, + ...overrides, + }) as UsageWindow; + +const limit = (overrides: Partial): UsageWindowLimit => + ({ + feature_id: "messages", + internal_entity_id: null, + window_start_at: NOW - HOUR, + window_end_at: NOW + HOUR, + anchor_customer_entitlement_id: "ce_old", + ...overrides, + }) as UsageWindowLimit; + +describe("computeUsageWindowRolls", () => { + test("live row matching its limit's derivation: no roll", () => { + const rolls = computeUsageWindowRolls({ + usageWindows: [row({})], + limits: [limit({})], + now: NOW, + }); + expect(rolls).toHaveLength(0); + }); + + test("plan change (bounds moved, not expired): re-bound, count zeroed", () => { + const rolls = computeUsageWindowRolls({ + usageWindows: [row({})], + limits: [ + limit({ + window_start_at: NOW - 2 * HOUR, + window_end_at: NOW + 5 * HOUR, + anchor_customer_entitlement_id: "ce_new", + }), + ], + now: NOW, + }); + expect(rolls).toHaveLength(1); + expect(rolls[0]).toMatchObject({ + id: "uw_1", + zero_usage: true, + window_start_at: NOW - 2 * HOUR, + window_end_at: NOW + 5 * HOUR, + anchor_customer_entitlement_id: "ce_new", + }); + }); + + test("anchor-only re-point (same window, ent recreated): count kept", () => { + const rolls = computeUsageWindowRolls({ + usageWindows: [row({})], + limits: [limit({ anchor_customer_entitlement_id: "ce_recreated" })], + now: NOW, + }); + expect(rolls).toHaveLength(1); + expect(rolls[0]).toMatchObject({ + zero_usage: false, + window_start_at: NOW - HOUR, + window_end_at: NOW + HOUR, + anchor_customer_entitlement_id: "ce_recreated", + }); + }); + + test("expired row: re-bound to the current derivation, count zeroed", () => { + const rolls = computeUsageWindowRolls({ + usageWindows: [ + row({ window_start_at: NOW - 3 * HOUR, window_end_at: NOW - HOUR }), + ], + limits: [limit({})], + now: NOW, + }); + expect(rolls).toHaveLength(1); + expect(rolls[0]).toMatchObject({ + zero_usage: true, + window_start_at: NOW - HOUR, + window_end_at: NOW + HOUR, + }); + }); + + test("expired row with no resolvable limit (entity scope, v1): zero-only, bounds kept", () => { + const rolls = computeUsageWindowRolls({ + usageWindows: [ + row({ + internal_entity_id: "ient_1", + window_start_at: NOW - 3 * HOUR, + window_end_at: NOW - HOUR, + }), + ], + limits: [limit({})], // customer-scope limit doesn't match the entity row + now: NOW, + }); + expect(rolls).toHaveLength(1); + expect(rolls[0]).toMatchObject({ + zero_usage: true, + internal_entity_id: "ient_1", + window_start_at: NOW - 3 * HOUR, + window_end_at: NOW - HOUR, + anchor_customer_entitlement_id: "ce_old", + }); + }); +}); diff --git a/server/tests/unit/usage-windows/fullSubjectToUsageWindowLimits.test.ts b/server/tests/unit/usage-windows/fullSubjectToUsageWindowLimits.test.ts index a0c7e97b9..30dc545da 100644 --- a/server/tests/unit/usage-windows/fullSubjectToUsageWindowLimits.test.ts +++ b/server/tests/unit/usage-windows/fullSubjectToUsageWindowLimits.test.ts @@ -3,12 +3,14 @@ import { buildUsageWindowKey, CusProductStatus, type DbSpendLimit, + type DbUsageLimit, EntInterval, type Feature, FeatureType, type FullSubject, fullSubjectToUsageWindowLimits, getUsageWindowBounds, + ResetInterval, } from "@autumn/shared"; const NOW = Date.UTC(2026, 5, 15, 12, 0, 0); @@ -37,33 +39,14 @@ const credits2ContainingAction1 = { config: { schema: [{ metered_feature_id: "action1", credit_amount: 3 }] }, } as unknown as Feature; -// Test-input shape for a windowed usage cap. usage_limit arms the cap; `interval` -// is the optional override (omit it to test inheriting from the entitlement). -type UsageCap = { - feature_id: string; - limit: number; - interval?: EntInterval; -}; - -const toSpendLimit = (cap: UsageCap): DbSpendLimit => ({ - feature_id: cap.feature_id, - // Entry-level enabled gates the (absent) overage cap, not the usage window. - enabled: false, - usage_limit: cap.limit, - usage_limit_interval: cap.interval, -}); - -// Minimal loose (product-less) customer entitlement for anchor/inherit tests. -// `interval` is the entitlement's reset interval (the inherited window source). +// Minimal loose (product-less) customer entitlement for anchor tests. const looseEntitlement = ({ id, featureId, - usageLimit, interval = EntInterval.Month, }: { id: string; featureId: string; - usageLimit?: number; interval?: EntInterval | null; }) => ({ @@ -80,7 +63,6 @@ const looseEntitlement = ({ id: `ent_${id}`, feature_id: featureId, interval, - usage_limit: usageLimit ?? null, feature: { id: featureId, internal_id: featureId }, }, rollovers: [], @@ -94,13 +76,13 @@ const looseEntitlement = ({ const customerProductWithEntitlement = ({ id, featureId, - usageLimit, cycleAnchor, + nextResetAt, }: { id: string; featureId: string; - usageLimit?: number; cycleAnchor?: number; + nextResetAt?: number; }) => ({ id: `cusprod_${id}`, @@ -119,11 +101,11 @@ const customerProductWithEntitlement = ({ created_at: 1000, balance: 0, expires_at: null, + next_reset_at: nextResetAt ?? null, entitlement: { id: `ent_${id}`, feature_id: featureId, interval: EntInterval.Month, - usage_limit: usageLimit ?? null, feature: { id: featureId, internal_id: featureId }, }, rollovers: [], @@ -133,43 +115,32 @@ const customerProductWithEntitlement = ({ }) as unknown as FullSubject["customer_products"][number]; const buildSubject = ({ - customerLimits = [], - entityLimits, + usageLimits = [], + spendLimits = [], looseEntitlements = [], - extraCustomerSpendLimits = [], customerProducts = [], }: { - customerLimits?: UsageCap[]; - entityLimits?: UsageCap[]; + // Entries injected as-is; pass Partial shapes to simulate stale stored data. + usageLimits?: Partial[]; + spendLimits?: DbSpendLimit[]; looseEntitlements?: FullSubject["extra_customer_entitlements"]; - // Raw spend-limit entries (e.g. overage-only or both-cap) injected as-is. - extraCustomerSpendLimits?: DbSpendLimit[]; customerProducts?: FullSubject["customer_products"]; }): FullSubject => ({ customer: { - spend_limits: [ - ...customerLimits.map(toSpendLimit), - ...extraCustomerSpendLimits, - ], + usage_limits: usageLimits, + spend_limits: spendLimits, }, customer_products: customerProducts, extra_customer_entitlements: looseEntitlements, - entity: entityLimits - ? { - id: "ent_1", - internal_id: "ient_1", - spend_limits: entityLimits.map(toSpendLimit), - } - : undefined, }) as unknown as FullSubject; describe("fullSubjectToUsageWindowLimits", () => { test("resolves a customer-level metered-feature cap", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, ], }), featureIds: ["action1"], @@ -208,8 +179,8 @@ describe("fullSubjectToUsageWindowLimits", () => { test("skips a cap whose feature is absent from the catalog (unresolvable internal_feature_id)", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, ], }), featureIds: ["action1"], @@ -223,8 +194,8 @@ describe("fullSubjectToUsageWindowLimits", () => { test("a credit-system feature resolves to the balance dimension", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "credits", limit: 3, interval: EntInterval.Day }, + usageLimits: [ + { feature_id: "credits", limit: 3, interval: ResetInterval.Day }, ], }), featureIds: ["credits"], @@ -239,34 +210,12 @@ describe("fullSubjectToUsageWindowLimits", () => { }); }); - test("inherits the interval from the anchor entitlement when no override", () => { + test("the window interval comes from the entry, independent of the entitlement's reset interval", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - // No `interval` => inherit the entitlement's reset interval (Month). - customerLimits: [{ feature_id: "credits", limit: 5 }], - looseEntitlements: [ - looseEntitlement({ id: "ce_credits", featureId: "credits" }), - ], - }), - featureIds: ["credits"], - features: [creditsFeature], - now: NOW, - }); - - expect(limits).toHaveLength(1); - expect(limits[0]).toMatchObject({ - limit: 5, - interval: EntInterval.Month, - anchor_customer_entitlement_id: "ce_credits", - }); - }); - - test("an explicit usage_limit_interval overrides the inherited interval", () => { - const limits = fullSubjectToUsageWindowLimits({ - fullSubject: buildSubject({ - // Entitlement interval is Month; the cap overrides to Day. - customerLimits: [ - { feature_id: "credits", limit: 3, interval: EntInterval.Day }, + // Entitlement resets monthly; the cap windows daily. + usageLimits: [ + { feature_id: "credits", limit: 3, interval: ResetInterval.Day }, ], looseEntitlements: [ looseEntitlement({ id: "ce_credits", featureId: "credits" }), @@ -281,16 +230,12 @@ describe("fullSubjectToUsageWindowLimits", () => { expect(limits[0]).toMatchObject({ limit: 3, interval: EntInterval.Day }); }); - test("a usage_limit with no override and a null-interval entitlement resolves nothing", () => { + test("a stale entry missing its interval resolves nothing (fail-safe)", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [{ feature_id: "credits", limit: 3 }], + usageLimits: [{ feature_id: "credits", limit: 3 }], looseEntitlements: [ - looseEntitlement({ - id: "ce_credits", - featureId: "credits", - interval: null, - }), + looseEntitlement({ id: "ce_credits", featureId: "credits" }), ], }), featureIds: ["credits"], @@ -301,11 +246,11 @@ describe("fullSubjectToUsageWindowLimits", () => { expect(limits).toHaveLength(0); }); - test("a usage_limit of 0 is a valid hard cap (blocks all usage)", () => { + test("a limit of 0 is a valid hard cap (blocks all usage)", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "credits", limit: 0, interval: EntInterval.Day }, + usageLimits: [ + { feature_id: "credits", limit: 0, interval: ResetInterval.Day }, ], }), featureIds: ["credits"], @@ -322,8 +267,8 @@ describe("fullSubjectToUsageWindowLimits", () => { const cycleAnchor = Date.UTC(2026, 0, 9, 15, 30, 0); const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "credits", limit: 3, interval: EntInterval.Day }, + usageLimits: [ + { feature_id: "credits", limit: 3, interval: ResetInterval.Day }, ], customerProducts: [ customerProductWithEntitlement({ @@ -355,29 +300,51 @@ describe("fullSubjectToUsageWindowLimits", () => { expect(aligned.windowStartAt).not.toBe(calendar.windowStartAt); }); - test("a spend_limit with usage_limit_interval but no usage_limit is not armed", () => { + test("the anchor ent's next_reset_at outranks the billing-cycle anchor for bounds", () => { + // Non-calendar, non-cycle-anchor timestamps so all three alignments differ. + const cycleAnchor = Date.UTC(2026, 0, 9, 15, 30, 0); + const nextResetAt = Date.UTC(2026, 5, 22, 8, 45, 0); const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - extraCustomerSpendLimits: [ - { - feature_id: "action1", - enabled: false, - usage_limit_interval: EntInterval.Month, - }, + usageLimits: [ + { feature_id: "credits", limit: 3, interval: ResetInterval.Day }, + ], + customerProducts: [ + customerProductWithEntitlement({ + id: "ce_credits", + featureId: "credits", + cycleAnchor, + nextResetAt, + }), ], }), - featureIds: ["action1"], - features: [meteredAction1], + featureIds: ["credits"], + features: [creditsFeature], now: NOW, }); - expect(limits).toHaveLength(0); + const resetAligned = getUsageWindowBounds({ + interval: EntInterval.Day, + now: NOW, + anchor: nextResetAt, + }); + const cycleAligned = getUsageWindowBounds({ + interval: EntInterval.Day, + now: NOW, + anchor: cycleAnchor, + }); + + expect(limits).toHaveLength(1); + expect(limits[0].window_start_at).toBe(resetAligned.windowStartAt); + expect(limits[0].window_end_at).toBe(resetAligned.windowEndAt); + // Sanity: the reset-cycle window genuinely differs from the cycle-anchor one. + expect(resetAligned.windowStartAt).not.toBe(cycleAligned.windowStartAt); }); - test("ignores an overage-only spend_limit (no usage_limit)", () => { + test("an overage spend_limit does not arm a usage window", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - extraCustomerSpendLimits: [ + spendLimits: [ { feature_id: "action1", enabled: true, overage_limit: 20 }, ], }), @@ -389,17 +356,14 @@ describe("fullSubjectToUsageWindowLimits", () => { expect(limits).toHaveLength(0); }); - test("resolves the window when one entry carries both overage and usage caps", () => { + test("a usage limit coexists with an overage spend_limit on the same feature", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - extraCustomerSpendLimits: [ - { - feature_id: "action1", - enabled: true, - overage_limit: 20, - usage_limit: 5, - usage_limit_interval: EntInterval.Month, - }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, + ], + spendLimits: [ + { feature_id: "action1", enabled: true, overage_limit: 20 }, ], }), featureIds: ["action1"], @@ -411,48 +375,9 @@ describe("fullSubjectToUsageWindowLimits", () => { expect(limits[0]).toMatchObject({ feature_id: "action1", limit: 5 }); }); - test("ignores entity-scoped usage windows in v1; the customer cap applies", () => { - const limits = fullSubjectToUsageWindowLimits({ - fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, - ], - entityLimits: [ - { feature_id: "action1", limit: 2, interval: EntInterval.Month }, - ], - }), - featureIds: ["action1"], - features: [meteredAction1], - now: NOW, - }); - - expect(limits).toHaveLength(1); - expect(limits[0]).toMatchObject({ - scope_type: "customer", - entity_id: null, - internal_entity_id: null, - limit: 5, - }); - }); - - test("an entity-only usage window resolves nothing in v1", () => { - const limits = fullSubjectToUsageWindowLimits({ - fullSubject: buildSubject({ - entityLimits: [ - { feature_id: "action1", limit: 2, interval: EntInterval.Month }, - ], - }), - featureIds: ["action1"], - features: [meteredAction1], - now: NOW, - }); - - expect(limits).toHaveLength(0); - }); - test("returns nothing when no cap matches the feature", () => { const limits = fullSubjectToUsageWindowLimits({ - fullSubject: buildSubject({ customerLimits: [] }), + fullSubject: buildSubject({ usageLimits: [] }), featureIds: ["action1"], features: [meteredAction1], now: NOW, @@ -469,9 +394,9 @@ describe("fullSubjectToUsageWindowLimits", () => { } as Feature; const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, - { feature_id: "action2", limit: 9, interval: EntInterval.Day }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, + { feature_id: "action2", limit: 9, interval: ResetInterval.Day }, ], }), featureIds: ["action1", "action2"], @@ -489,8 +414,8 @@ describe("fullSubjectToUsageWindowLimits", () => { test("resolves the anchor to the owning entitlement (balance dim)", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "credits", limit: 3, interval: EntInterval.Day }, + usageLimits: [ + { feature_id: "credits", limit: 3, interval: ResetInterval.Day }, ], looseEntitlements: [ looseEntitlement({ id: "ce_credits", featureId: "credits" }), @@ -504,15 +429,14 @@ describe("fullSubjectToUsageWindowLimits", () => { expect(limits).toHaveLength(1); expect(limits[0]).toMatchObject({ anchor_customer_entitlement_id: "ce_credits", - anchor_feature_id: "credits", }); }); test("metered cap with no native entitlement anchors to the containing credit system", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, ], looseEntitlements: [ looseEntitlement({ id: "ce_credits", featureId: "credits" }), @@ -528,15 +452,14 @@ describe("fullSubjectToUsageWindowLimits", () => { dimension_type: "metered_feature", dimension_feature_id: "action1", anchor_customer_entitlement_id: "ce_credits", - anchor_feature_id: "credits", }); }); - test("anchor is null when no owning entitlement exists (fail-closed signal)", () => { + test("anchor is null when no reference entitlement exists (calendar bounds, no provenance)", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "credits", limit: 3, interval: EntInterval.Day }, + usageLimits: [ + { feature_id: "credits", limit: 3, interval: ResetInterval.Day }, ], }), featureIds: ["credits"], @@ -551,8 +474,8 @@ describe("fullSubjectToUsageWindowLimits", () => { test("metered cap with a containing credit system but no entitlement resolves a null anchor", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, ], }), featureIds: ["action1"], @@ -567,8 +490,8 @@ describe("fullSubjectToUsageWindowLimits", () => { test("metered cap contained by two credit systems anchors deterministically", () => { const limits = fullSubjectToUsageWindowLimits({ fullSubject: buildSubject({ - customerLimits: [ - { feature_id: "action1", limit: 5, interval: EntInterval.Month }, + usageLimits: [ + { feature_id: "action1", limit: 5, interval: ResetInterval.Month }, ], looseEntitlements: [ looseEntitlement({ id: "ce_credits", featureId: "credits" }), diff --git a/server/tests/unit/usage-windows/pickAnchorCustomerEntitlementId.test.ts b/server/tests/unit/usage-windows/pickAnchorCustomerEntitlementId.test.ts index 6fea01f1b..a32dcb9a4 100644 --- a/server/tests/unit/usage-windows/pickAnchorCustomerEntitlementId.test.ts +++ b/server/tests/unit/usage-windows/pickAnchorCustomerEntitlementId.test.ts @@ -8,12 +8,25 @@ const candidate = (overrides: Partial): AnchorCandidate => ({ id: "ce_1", is_entity_scoped: false, is_add_on: false, + is_plan_backed: true, status_rank: 0, created_at: 1000, ...overrides, }); describe("pickAnchorCustomerEntitlementId", () => { + test("a plan-backed ent outranks an older loose/top-up ent", () => { + const id = pickAnchorCustomerEntitlementId({ + candidates: [ + candidate({ id: "ce_topup", is_plan_backed: false, created_at: 500 }), + candidate({ id: "ce_plan", is_plan_backed: true, created_at: 2000 }), + ], + scopeType: "customer", + }); + + expect(id).toBe("ce_plan"); + }); + test("returns null when there are no candidates", () => { expect( pickAnchorCustomerEntitlementId({ diff --git a/shared/api/billingControls/customerBillingControls.ts b/shared/api/billingControls/customerBillingControls.ts new file mode 100644 index 000000000..d2f5c9504 --- /dev/null +++ b/shared/api/billingControls/customerBillingControls.ts @@ -0,0 +1,39 @@ +import { z } from "zod/v4"; +import { + AutoTopupResponseSchema, + DbOverageAllowedSchema, + DbUsageAlertSchema, +} from "../../models/cusModels/billingControls/customerBillingControls.js"; +import { ApiSpendLimitSchema } from "./spendLimit.js"; +import { ApiUsageLimitSchema } from "./usageLimit.js"; + +/** + * Response-only variant of CustomerBillingControlsSchema: `auto_topups` may + * carry the expanded runtime purchase-limit shape, and `usage_limits` carry + * the current window `usage`. Input/params validation continues to use + * `CustomerBillingControlsParamsSchema` (models), which remains strict. + */ +export const CustomerBillingControlsResponseSchema = z.object({ + auto_topups: z.array(AutoTopupResponseSchema).optional().meta({ + description: "List of auto top-up configurations per feature.", + }), + spend_limits: z.array(ApiSpendLimitSchema).optional().meta({ + description: + "List of overage spend limits per feature (caps overage spend).", + }), + usage_limits: z.array(ApiUsageLimitSchema).optional().meta({ + description: + "List of windowed hard usage caps per feature, with current window usage.", + }), + usage_alerts: z.array(DbUsageAlertSchema).optional().meta({ + description: "List of usage alert configurations per feature.", + }), + overage_allowed: z.array(DbOverageAllowedSchema).optional().meta({ + description: + "List of overage allowed controls per feature. When enabled, usage can exceed balance.", + }), +}); + +export type CustomerBillingControlsResponse = z.infer< + typeof CustomerBillingControlsResponseSchema +>; diff --git a/shared/api/billingControls/entityBillingControls.ts b/shared/api/billingControls/entityBillingControls.ts index b267214dd..2e65759bd 100644 --- a/shared/api/billingControls/entityBillingControls.ts +++ b/shared/api/billingControls/entityBillingControls.ts @@ -1,14 +1,20 @@ import { z } from "zod/v4"; import { DbSpendLimitSchema } from "../../models/cusModels/billingControls/spendLimit.js"; +import { DbUsageLimitSchema } from "../../models/cusModels/billingControls/usageLimit.js"; import { ApiOverageAllowedSchema } from "./overageAllowed.js"; import { ApiSpendLimitSchema } from "./spendLimit.js"; import { ApiUsageAlertSchema } from "./usageAlert.js"; +import { ApiUsageLimitSchema } from "./usageLimit.js"; export const ApiEntityBillingControlsSchema = z.object({ spend_limits: z.array(ApiSpendLimitSchema).optional().meta({ description: "List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).", }), + usage_limits: z.array(ApiUsageLimitSchema).optional().meta({ + description: + "List of windowed hard usage caps per feature for this entity. An entity entry overrides the customer's for that feature.", + }), usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({ description: "List of usage alert configurations per feature.", }), @@ -23,6 +29,10 @@ const ApiEntityBillingControlsParamsBaseSchema = z.object({ description: "List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).", }), + usage_limits: z.array(DbUsageLimitSchema).optional().meta({ + description: + "List of windowed hard usage caps per feature for this entity. An entity entry overrides the customer's for that feature.", + }), usage_alerts: z.array(ApiUsageAlertSchema).optional().meta({ description: "List of usage alert configurations per feature.", }), @@ -57,6 +67,24 @@ export const ApiEntityBillingControlsParamsSchema = spendLimitFeatureIds.add(spendLimit.feature_id); } + const usageLimitFeatureIds = new Set(); + + for (const [index, usageLimit] of ( + billingControls.usage_limits ?? [] + ).entries()) { + if (usageLimitFeatureIds.has(usageLimit.feature_id)) { + ctx.issues.push({ + code: "custom", + message: "Only one usage limit entry is allowed per feature_id", + input: usageLimit.feature_id, + path: ["usage_limits", index, "feature_id"], + }); + return; + } + + usageLimitFeatureIds.add(usageLimit.feature_id); + } + const overageAllowedFeatureIds = new Set(); for (const [index, overageAllowed] of ( diff --git a/shared/api/billingControls/index.ts b/shared/api/billingControls/index.ts index c179c7c84..4ada5d145 100644 --- a/shared/api/billingControls/index.ts +++ b/shared/api/billingControls/index.ts @@ -1,4 +1,6 @@ +export * from "./customerBillingControls.js"; export * from "./entityBillingControls.js"; export * from "./overageAllowed.js"; export * from "./spendLimit.js"; export * from "./usageAlert.js"; +export * from "./usageLimit.js"; diff --git a/shared/api/billingControls/spendLimit.ts b/shared/api/billingControls/spendLimit.ts index dc7d6d949..5cdcb828a 100644 --- a/shared/api/billingControls/spendLimit.ts +++ b/shared/api/billingControls/spendLimit.ts @@ -1,6 +1,8 @@ import type { z } from "zod/v4"; -import { SpendLimitResponseSchema } from "../../models/cusModels/billingControls/spendLimit.js"; +import { DbSpendLimitSchema } from "../../models/cusModels/billingControls/spendLimit.js"; -export const ApiSpendLimitSchema = SpendLimitResponseSchema; +// Spend limits (overage caps) carry no runtime state on responses; the API +// shape is the stored shape. +export const ApiSpendLimitSchema = DbSpendLimitSchema; export type ApiSpendLimit = z.infer; diff --git a/shared/api/billingControls/usageLimit.ts b/shared/api/billingControls/usageLimit.ts new file mode 100644 index 000000000..6183bb983 --- /dev/null +++ b/shared/api/billingControls/usageLimit.ts @@ -0,0 +1,15 @@ +import { z } from "zod/v4"; +import { DbUsageLimitSchema } from "../../models/cusModels/billingControls/usageLimit.js"; + +/** + * Response variant of a usage limit: the stored config plus the usage already + * consumed in the active window (read from the usage-window counter). + */ +export const ApiUsageLimitSchema = DbUsageLimitSchema.extend({ + usage: z.number().min(0).optional().meta({ + description: + "Current usage already consumed in the active window. Response-only; not stored on billing controls.", + }), +}); + +export type ApiUsageLimit = z.infer; diff --git a/shared/api/customers/baseApiCustomer.ts b/shared/api/customers/baseApiCustomer.ts index 4d51a1c25..046e38301 100644 --- a/shared/api/customers/baseApiCustomer.ts +++ b/shared/api/customers/baseApiCustomer.ts @@ -1,6 +1,6 @@ -import { CustomerBillingControlsResponseSchema } from "@models/cusModels/billingControls/customerBillingControls"; import { AppEnv } from "@models/genModels/genEnums"; import { z } from "zod/v4"; +import { CustomerBillingControlsResponseSchema } from "../billingControls/customerBillingControls.js"; export const BaseApiCustomerSchema = z.object({ autumn_id: z.string().optional().meta({ diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts index 2961a40ff..b289f2b62 100644 --- a/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceToAllowed.ts @@ -2,13 +2,14 @@ import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0"; import type { ApiBalanceV1 } from "@api/customers/cusFeatures/apiBalanceV1"; import { apiBalanceV1ToAvailableOverage } from "@api/customers/cusFeatures/utils/convert/apiBalanceV1ToAvailableOverage"; import { apiSubjectToOverageAllowedControl } from "@api/customers/utils/apiSubjectToOverageAllowed"; +import { apiSubjectToUsageLimitHeadroom } from "@api/customers/utils/apiSubjectToUsageLimitHeadroom"; import type { Feature } from "@models/featureModels/featureModels"; import { isBooleanFeature, notNullish } from "@utils/index"; import { Decimal } from "decimal.js"; export type AllowedResult = { allowed: boolean; - limitType?: "included" | "max_purchase" | "spend_limit"; + limitType?: "included" | "max_purchase" | "spend_limit" | "usage_limit"; }; export type ApiBalanceInput = { @@ -16,6 +17,9 @@ export type ApiBalanceInput = { apiSubject: ApiSubjectV0; feature: Feature; requiredBalance: number; + /** The checked feature when it differs from the evaluated one (credit + * system member), so metered usage caps on it can gate the check. */ + originalFeature?: Feature; }; export const apiBalanceToAllowed = ({ @@ -23,6 +27,7 @@ export const apiBalanceToAllowed = ({ apiSubject, feature, requiredBalance, + originalFeature, }: ApiBalanceInput): AllowedResult => { if (!apiBalance) return { allowed: false }; @@ -32,6 +37,19 @@ export const apiBalanceToAllowed = ({ if (requiredBalance < 0) return { allowed: true }; + // Windowed usage caps gate regardless of balance or overage availability. + const usageLimitHeadroom = apiSubjectToUsageLimitHeadroom({ + apiSubject, + feature, + originalFeature, + }); + if ( + notNullish(usageLimitHeadroom) && + new Decimal(requiredBalance).gt(usageLimitHeadroom) + ) { + return { allowed: false, limitType: "usage_limit" }; + } + const overageAllowedControl = apiSubjectToOverageAllowedControl({ subject: apiSubject, feature, diff --git a/shared/api/customers/utils/apiSubjectToUsageLimitHeadroom.ts b/shared/api/customers/utils/apiSubjectToUsageLimitHeadroom.ts new file mode 100644 index 000000000..0b9ffb05b --- /dev/null +++ b/shared/api/customers/utils/apiSubjectToUsageLimitHeadroom.ts @@ -0,0 +1,63 @@ +import type { ApiSubjectV0 } from "@api/customers/apiSubjectV0"; +import type { Feature } from "@models/featureModels/featureModels"; +import { Decimal } from "decimal.js"; + +/** + * Remaining usage-window headroom for a check, in the EVALUATED feature's + * units (credits when the evaluated feature is a credit system). Considers + * both the cap on the evaluated feature itself and -- when checking a + * credit-system member -- the metered cap on the original feature, converted + * via its credit cost. Null when no armed cap applies. + */ +export const apiSubjectToUsageLimitHeadroom = ({ + apiSubject, + feature, + originalFeature, +}: { + apiSubject: ApiSubjectV0; + feature: Feature; + originalFeature?: Feature; +}): number | null => { + // Entity subjects see inherited customer entries via + // mergeCustomerBillingControlsForCheck; entity's own entry wins per feature. + const billingControls = apiSubject.billing_controls; + const usageLimits = + billingControls && "usage_limits" in billingControls + ? billingControls.usage_limits + : undefined; + if (!usageLimits || usageLimits.length === 0) return null; + + const headrooms: Decimal[] = []; + + const capOnEvaluated = usageLimits.find( + (usageLimit) => usageLimit.feature_id === feature.id, + ); + if (capOnEvaluated) { + headrooms.push( + Decimal.max( + 0, + new Decimal(capOnEvaluated.limit).sub(capOnEvaluated.usage ?? 0), + ), + ); + } + + if (originalFeature && originalFeature.id !== feature.id) { + const capOnOriginal = usageLimits.find( + (usageLimit) => usageLimit.feature_id === originalFeature.id, + ); + const schemaItem = feature.config?.schema?.find( + (item: { metered_feature_id: string }) => + item.metered_feature_id === originalFeature.id, + ); + if (capOnOriginal && schemaItem) { + const headroomUnits = Decimal.max( + 0, + new Decimal(capOnOriginal.limit).sub(capOnOriginal.usage ?? 0), + ); + headrooms.push(headroomUnits.mul(schemaItem.credit_amount ?? 1)); + } + } + + if (headrooms.length === 0) return null; + return Decimal.min(...headrooms).toNumber(); +}; diff --git a/shared/drizzle/0009_usage_windows.sql b/shared/drizzle/0009_usage_windows.sql index 3f83055fb..14b675abb 100644 --- a/shared/drizzle/0009_usage_windows.sql +++ b/shared/drizzle/0009_usage_windows.sql @@ -1,8 +1,10 @@ CREATE TABLE "usage_windows" ( "id" text PRIMARY KEY NOT NULL, - "customer_entitlement_id" text NOT NULL, + "internal_customer_id" text NOT NULL, + "internal_entity_id" text, "feature_id" text NOT NULL, "internal_feature_id" text NOT NULL, + "anchor_customer_entitlement_id" text, "window_start_at" numeric NOT NULL, "window_end_at" numeric NOT NULL, "usage" numeric DEFAULT 0 NOT NULL, @@ -10,7 +12,9 @@ CREATE TABLE "usage_windows" ( ); --> statement-breakpoint ALTER TABLE "usage_windows" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -ALTER TABLE "usage_windows" ADD CONSTRAINT "usage_windows_customer_entitlement_id_fkey" FOREIGN KEY ("customer_entitlement_id") REFERENCES "public"."customer_entitlements"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "usage_windows" ADD CONSTRAINT "usage_windows_internal_customer_id_fkey" FOREIGN KEY ("internal_customer_id") REFERENCES "public"."customers"("internal_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "usage_windows" ADD CONSTRAINT "usage_windows_internal_entity_id_fkey" FOREIGN KEY ("internal_entity_id") REFERENCES "public"."entities"("internal_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "usage_windows" ADD CONSTRAINT "usage_windows_internal_feature_id_fkey" FOREIGN KEY ("internal_feature_id") REFERENCES "public"."features"("internal_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX CONCURRENTLY "idx_usage_windows_customer_entitlement_id" ON "usage_windows" USING btree ("customer_entitlement_id");--> statement-breakpoint -CREATE UNIQUE INDEX CONCURRENTLY "idx_usage_windows_cus_ent_feature_window" ON "usage_windows" USING btree ("customer_entitlement_id","feature_id","window_start_at"); \ No newline at end of file +ALTER TABLE "usage_windows" ADD CONSTRAINT "usage_windows_anchor_customer_entitlement_id_fkey" FOREIGN KEY ("anchor_customer_entitlement_id") REFERENCES "public"."customer_entitlements"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX CONCURRENTLY "idx_usage_windows_internal_customer_id" ON "usage_windows" USING btree ("internal_customer_id");--> statement-breakpoint +CREATE UNIQUE INDEX CONCURRENTLY "idx_usage_windows_customer_feature_scope" ON "usage_windows" USING btree ("internal_customer_id","internal_feature_id",COALESCE("internal_entity_id", '')); diff --git a/shared/drizzle/0010_usage_limits_control.sql b/shared/drizzle/0010_usage_limits_control.sql new file mode 100644 index 000000000..d14b0dded --- /dev/null +++ b/shared/drizzle/0010_usage_limits_control.sql @@ -0,0 +1,2 @@ +ALTER TABLE "customers" ADD COLUMN "usage_limits" jsonb;--> statement-breakpoint +ALTER TABLE "entities" ADD COLUMN "usage_limits" jsonb; diff --git a/shared/drizzle/meta/_journal.json b/shared/drizzle/meta/_journal.json index f114f8bf1..62287812d 100644 --- a/shared/drizzle/meta/_journal.json +++ b/shared/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1780916308859, "tag": "0009_usage_windows", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1781100000000, + "tag": "0010_usage_limits_control", + "breakpoints": true } ] } \ No newline at end of file diff --git a/shared/index.ts b/shared/index.ts index d1ec8aca1..1f302b57b 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -16,6 +16,7 @@ export * from "./api/billing/createSchedule/createScheduleResponse"; export * from "./api/billing/openBillingPortal/openBillingPortalParamsV1"; export * from "./api/billing/openBillingPortal/openBillingPortalResponse"; export * from "./api/billing/updateSubscription/previewUpdateSubscriptionResponse"; +export * from "./api/billingControls/index"; // Cursor pagination utilities export * from "./api/common/cursorPaginationSchemas"; export * from "./api/common/paginationConfigs"; @@ -212,10 +213,6 @@ export * from "./utils/cusEntUtils/balanceUtils/cusEntsToUsage"; export * from "./utils/cusEntUtils/balanceUtils/cusEntToMinBalance"; export * from "./utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed"; export * from "./utils/cusEntUtils/index"; -// Utils -export * from "./utils/usageWindowUtils/buildUsageWindowKey"; -export * from "./utils/usageWindowUtils/getUsageWindowBounds"; -export * from "./utils/usageWindowUtils/pickAnchorCustomerEntitlementId"; export * from "./utils/displayUtils"; export * from "./utils/fullSubjectUtils"; export * from "./utils/index"; @@ -244,3 +241,15 @@ export * from "./utils/productV3Utils/productItemUtils/productV3ItemUtils"; export * from "./utils/rewardUtils/rewardFilterUtils"; export * from "./utils/rewardUtils/rewardMigrationUtils"; export * from "./utils/scopeDefinitions"; +// Utils +export * from "./utils/usageWindowUtils/buildUsageWindowKey"; +export * from "./utils/usageWindowUtils/classifyUsageWindow/usageWindowMatchesLimit"; +export * from "./utils/usageWindowUtils/convertUsageWindow/getUsageWindowDimension"; +export * from "./utils/usageWindowUtils/convertUsageWindow/usageLimitToUsageWindowLimit"; +export * from "./utils/usageWindowUtils/findUsageWindow/findUsageWindowByLimit"; +export * from "./utils/usageWindowUtils/findUsageWindow/findUsageWindowLimitByWindow"; +export * from "./utils/usageWindowUtils/findUsageWindowAnchor/findUsageWindowAnchor"; +export * from "./utils/usageWindowUtils/findUsageWindowAnchor/pickAnchorCustomerEntitlementId"; +export * from "./utils/usageWindowUtils/getCurrentUsageWindowUsage"; +export * from "./utils/usageWindowUtils/getUsageWindowAnchorTimestamp"; +export * from "./utils/usageWindowUtils/getUsageWindowBounds"; diff --git a/shared/models/cusModels/billingControls/customerBillingControls.ts b/shared/models/cusModels/billingControls/customerBillingControls.ts index 182bf98dc..45e9d1dd9 100644 --- a/shared/models/cusModels/billingControls/customerBillingControls.ts +++ b/shared/models/cusModels/billingControls/customerBillingControls.ts @@ -9,12 +9,9 @@ import { DbOverageAllowedSchema, } from "./overageAllowed.js"; import { PurchaseLimitIntervalEnum } from "./purchaseLimitInterval.js"; -import { - type DbSpendLimit, - DbSpendLimitSchema, - SpendLimitResponseSchema, -} from "./spendLimit.js"; +import { type DbSpendLimit, DbSpendLimitSchema } from "./spendLimit.js"; import { type DbUsageAlert, DbUsageAlertSchema } from "./usageAlert.js"; +import { type DbUsageLimit, DbUsageLimitSchema } from "./usageLimit.js"; export const AutoTopupPurchaseLimitSchema = z.object({ interval: PurchaseLimitIntervalEnum.meta({ @@ -105,33 +102,11 @@ export const CustomerBillingControlsSchema = z.object({ }), spend_limits: z.array(DbSpendLimitSchema).optional().meta({ description: - "List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).", + "List of overage spend limits per feature (caps overage spend).", }), - usage_alerts: z.array(DbUsageAlertSchema).optional().meta({ - description: "List of usage alert configurations per feature.", - }), - overage_allowed: z.array(DbOverageAllowedSchema).optional().meta({ + usage_limits: z.array(DbUsageLimitSchema).optional().meta({ description: - "List of overage allowed controls per feature. When enabled, usage can exceed balance.", - }), -}); - -/** - * Response-only variant of CustomerBillingControlsSchema that uses - * `AutoTopupResponseSchema` for `auto_topups` so the `purchase_limit` field - * may be either the static config shape or the expanded runtime shape (when - * expand=billing_controls.auto_topups.purchase_limit is requested). - * - * Input/params validation continues to use `CustomerBillingControlsSchema` / - * `CustomerBillingControlsParamsSchema`, which remain strict. - */ -export const CustomerBillingControlsResponseSchema = z.object({ - auto_topups: z.array(AutoTopupResponseSchema).optional().meta({ - description: "List of auto top-up configurations per feature.", - }), - spend_limits: z.array(SpendLimitResponseSchema).optional().meta({ - description: - "List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).", + "List of windowed hard usage caps per feature (max units per interval window).", }), usage_alerts: z.array(DbUsageAlertSchema).optional().meta({ description: "List of usage alert configurations per feature.", @@ -167,6 +142,24 @@ export const CustomerBillingControlsParamsSchema = spendLimitFeatureIds.add(spendLimit.feature_id); } + const usageLimitFeatureIds = new Set(); + + for (const [index, usageLimit] of ( + billingControls.usage_limits ?? [] + ).entries()) { + if (usageLimitFeatureIds.has(usageLimit.feature_id)) { + ctx.issues.push({ + code: "custom", + message: "Only one usage limit entry is allowed per feature_id", + input: usageLimit.feature_id, + path: ["usage_limits", index, "feature_id"], + }); + return; + } + + usageLimitFeatureIds.add(usageLimit.feature_id); + } + const overageAllowedFeatureIds = new Set(); for (const [index, overageAllowed] of ( @@ -195,9 +188,6 @@ export type AutoTopupResponse = z.infer; export type CustomerBillingControls = z.infer< typeof CustomerBillingControlsSchema >; -export type CustomerBillingControlsResponse = z.infer< - typeof CustomerBillingControlsResponseSchema ->; export type CustomerBillingControlsParams = z.input< typeof CustomerBillingControlsParamsSchema @@ -207,6 +197,7 @@ export type { DbOverageAllowed, DbSpendLimit, DbUsageAlert, + DbUsageLimit, EntityBillingControls, EntityBillingControlsParams, }; @@ -214,5 +205,6 @@ export { DbOverageAllowedSchema, DbSpendLimitSchema, DbUsageAlertSchema, + DbUsageLimitSchema, EntityBillingControlsSchema, }; diff --git a/shared/models/cusModels/billingControls/entityBillingControls.ts b/shared/models/cusModels/billingControls/entityBillingControls.ts index 58716f0b2..985dd285e 100644 --- a/shared/models/cusModels/billingControls/entityBillingControls.ts +++ b/shared/models/cusModels/billingControls/entityBillingControls.ts @@ -2,12 +2,17 @@ import { z } from "zod/v4"; import { DbOverageAllowedSchema } from "./overageAllowed.js"; import { DbSpendLimitSchema } from "./spendLimit.js"; import { DbUsageAlertSchema } from "./usageAlert.js"; +import { DbUsageLimitSchema } from "./usageLimit.js"; export const EntityBillingControlsSchema = z.object({ spend_limits: z.array(DbSpendLimitSchema).optional().meta({ description: "List of spend limits per feature. Each entry caps overage (overage_limit) and/or windowed usage (usage_limit).", }), + usage_limits: z.array(DbUsageLimitSchema).optional().meta({ + description: + "List of windowed hard usage caps per feature for this entity (max units per interval window). An entity entry overrides the customer's for that feature.", + }), usage_alerts: z.array(DbUsageAlertSchema).optional().meta({ description: "List of usage alert configurations per feature.", }), diff --git a/shared/models/cusModels/billingControls/spendLimit.ts b/shared/models/cusModels/billingControls/spendLimit.ts index a429db15e..68d9bb6d8 100644 --- a/shared/models/cusModels/billingControls/spendLimit.ts +++ b/shared/models/cusModels/billingControls/spendLimit.ts @@ -1,5 +1,4 @@ import { z } from "zod/v4"; -import { EntInterval } from "../../productModels/intervals/entitlementInterval.js"; export const DbSpendLimitSchema = z .object({ @@ -12,33 +11,13 @@ export const DbSpendLimitSchema = z overage_limit: z.number().min(0).optional().meta({ description: "Maximum allowed overage spend for the target feature.", }), - usage_limit: z.number().min(0).optional().meta({ - description: - "Windowed usage cap: max units allowed per window. Its presence arms the cap (hard pre-write reject); absent means no usage cap.", - }), - usage_limit_interval: z.enum(EntInterval).optional().meta({ - description: - "Optional window/reset interval for the usage cap, aligned to the customer's billing cycle. When omitted, defaults to the feature entitlement's own reset interval. Only meaningful with usage_limit set.", - }), }) .refine( - (data) => - !(data.overage_limit !== undefined || data.usage_limit !== undefined) || - data.feature_id !== undefined, + (data) => data.overage_limit === undefined || data.feature_id !== undefined, { - message: - "feature_id is required when overage_limit or usage_limit is provided", + message: "feature_id is required when overage_limit is provided", path: ["feature_id"], }, ); export type DbSpendLimit = z.infer; - -export const SpendLimitResponseSchema = DbSpendLimitSchema.extend({ - usage_limit_used: z.number().min(0).optional().meta({ - description: - "Current usage already consumed in the active usage_limit window. Response-only; not stored on billing controls.", - }), -}); - -export type SpendLimitResponse = z.infer; diff --git a/shared/models/cusModels/billingControls/usageLimit.ts b/shared/models/cusModels/billingControls/usageLimit.ts new file mode 100644 index 000000000..6b843569b --- /dev/null +++ b/shared/models/cusModels/billingControls/usageLimit.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; +import { ResetInterval } from "../../productModels/intervals/resetInterval.js"; + +/** + * A windowed hard usage cap on one feature: at most `limit` units per + * `interval` window. Stored on the customer's `usage_limits` billing-control + * column; an entry's presence arms the cap. Enforcement happens in the + * deduction script against customer-scoped usage-window counters. + */ +export const DbUsageLimitSchema = z + .object({ + feature_id: z.string().meta({ + description: "The feature this usage limit applies to.", + }), + limit: z.number().min(0).meta({ + description: "Maximum units allowed per window.", + }), + interval: z.enum(ResetInterval).meta({ + description: + "Window interval for the cap, aligned to the customer's billing cycle.", + }), + }) + .refine((data) => data.interval !== ResetInterval.OneOff, { + message: "interval cannot be one_off for a usage limit", + path: ["interval"], + }); + +export type DbUsageLimit = z.infer; diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index 9412359ef..dd9a8077e 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -6,6 +6,7 @@ import { DbOverageAllowedSchema, DbSpendLimitSchema, DbUsageAlertSchema, + DbUsageLimitSchema, } from "./billingControls/customerBillingControls.js"; export const CustomerSchema = z.object({ @@ -25,6 +26,7 @@ export const CustomerSchema = z.object({ send_email_receipts: z.boolean().default(false), auto_topups: z.array(AutoTopupSchema).nullish(), spend_limits: z.array(DbSpendLimitSchema).nullish(), + usage_limits: z.array(DbUsageLimitSchema).nullish(), usage_alerts: z.array(DbUsageAlertSchema).nullish(), overage_allowed: z.array(DbOverageAllowedSchema).nullish(), config: z diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index c0be9f954..81db0e112 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -18,6 +18,7 @@ import type { DbOverageAllowed, DbSpendLimit, DbUsageAlert, + DbUsageLimit, } from "./billingControls/customerBillingControls.js"; export type CustomerConfig = { @@ -48,6 +49,7 @@ export const customers = pgTable( send_email_receipts: boolean("send_email_receipts").default(false), auto_topups: jsonb().$type(), spend_limits: jsonb().$type(), + usage_limits: jsonb().$type(), usage_alerts: jsonb().$type(), overage_allowed: jsonb().$type(), config: jsonb().$type().default({}), diff --git a/shared/models/cusModels/entityModels/entityModels.ts b/shared/models/cusModels/entityModels/entityModels.ts index a6bca9b4f..92b41df27 100644 --- a/shared/models/cusModels/entityModels/entityModels.ts +++ b/shared/models/cusModels/entityModels/entityModels.ts @@ -4,6 +4,7 @@ import { DbOverageAllowedSchema, DbSpendLimitSchema, DbUsageAlertSchema, + DbUsageLimitSchema, } from "../billingControls/customerBillingControls.js"; export const EntitySchema = z.object({ @@ -18,6 +19,7 @@ export const EntitySchema = z.object({ feature_id: z.string(), internal_feature_id: z.string(), spend_limits: z.array(DbSpendLimitSchema).nullish(), + usage_limits: z.array(DbUsageLimitSchema).nullish(), usage_alerts: z.array(DbUsageAlertSchema).nullish(), overage_allowed: z.array(DbOverageAllowedSchema).nullish(), }); diff --git a/shared/models/cusModels/entityModels/entityTable.ts b/shared/models/cusModels/entityModels/entityTable.ts index 307216ce4..777107226 100644 --- a/shared/models/cusModels/entityModels/entityTable.ts +++ b/shared/models/cusModels/entityModels/entityTable.ts @@ -15,6 +15,7 @@ import type { DbOverageAllowed, DbSpendLimit, DbUsageAlert, + DbUsageLimit, } from "../billingControls/customerBillingControls.js"; import { customers } from "../cusTable.js"; @@ -31,6 +32,7 @@ export const entities = pgTable( deleted: boolean().default(false).notNull(), internal_feature_id: text("internal_feature_id"), spend_limits: jsonb().$type(), + usage_limits: jsonb().$type(), usage_alerts: jsonb().$type(), overage_allowed: jsonb().$type(), diff --git a/shared/models/cusModels/fullSubject/fullSubjectModel.ts b/shared/models/cusModels/fullSubject/fullSubjectModel.ts index 17899c6ea..624f6c7d0 100644 --- a/shared/models/cusModels/fullSubject/fullSubjectModel.ts +++ b/shared/models/cusModels/fullSubject/fullSubjectModel.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { FullAggregatedFeatureBalanceSchema } from "../../cusProductModels/cusEntModels/aggregatedCusEnt.js"; import { FullCustomerEntitlementSchema } from "../../cusProductModels/cusEntModels/cusEntModels.js"; +import { UsageWindowSchema } from "../../cusProductModels/cusEntModels/usageWindowTable.js"; import { FullCusProductSchema } from "../../cusProductModels/cusProductModels.js"; import { MigrationItemRunSchema } from "../../migrationV2Models/migrationItemRunSchema.js"; import { SubscriptionSchema } from "../../subModels/subModels.js"; @@ -29,6 +30,12 @@ export const FullSubjectSchema = z.object({ customer_products: z.array(FullCusProductSchema), extra_customer_entitlements: z.array(FullCustomerEntitlementSchema), + // Customer- or entity-scoped windowed-cap counters (one row per capped + // feature + window; internal_entity_id null = customer scope). On an entity + // subject this carries ONLY that entity's rows. Live data read from the + // per-feature balance hashes, never from the cached subject view. + usage_windows: z.array(UsageWindowSchema).optional(), + subscriptions: z.array(SubscriptionSchema).optional(), invoices: z.array(InvoiceSchema), diff --git a/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts b/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts index 2e0736244..f7aec95ef 100644 --- a/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts +++ b/shared/models/cusModels/fullSubject/normalizedFullSubjectModel.ts @@ -7,9 +7,9 @@ import { type EntityBalance, FullCustomerEntitlementSchema, } from "../../cusProductModels/cusEntModels/cusEntModels.js"; -import type { UsageWindow } from "../../cusProductModels/cusEntModels/usageWindowTable.js"; import type { Replaceable } from "../../cusProductModels/cusEntModels/replaceableTable.js"; import type { DbRollover } from "../../cusProductModels/cusEntModels/rolloverModels/rolloverTable.js"; +import type { UsageWindow } from "../../cusProductModels/cusEntModels/usageWindowTable.js"; import type { FullCustomerPrice } from "../../cusProductModels/cusPriceModels/cusPriceModels.js"; import { FullCustomerPriceSchema } from "../../cusProductModels/cusPriceModels/cusPriceModels.js"; import type { DbCustomerPrice } from "../../cusProductModels/cusPriceModels/cusPriceTable.js"; @@ -90,7 +90,6 @@ export type SubjectBalance = { expires_at: number | null; external_id: string | null; entities: Record | null; - usage_windows: UsageWindow[]; cache_version: number | null; created_at: number; customer_id?: string | null; @@ -170,6 +169,12 @@ export type NormalizedFullSubject = { customer_entitlements: SubjectBalance[]; customer_prices: DbCustomerPrice[]; + /** Windowed-cap counter rows for ALL scopes (customer + entity; + * internal_entity_id null = customer scope), live-read from the + * per-feature balance hashes' `_usage_windows` field — never the cached + * subject view. `normalizedToFullSubject` narrows to the subject's scope. */ + usage_windows: UsageWindow[]; + flags: Record; products: DbProduct[]; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index 1bdf6005c..32c01941b 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -3,7 +3,6 @@ import { EntitlementWithFeatureSchema } from "../../productModels/entModels/entM import { EntInterval } from "../../productModels/intervals/entitlementInterval.js"; import { ReplaceableSchema } from "./replaceableSchema.js"; import { RolloverSchema } from "./rolloverModels/rolloverTable.js"; -import { UsageWindowSchema } from "./usageWindowTable.js"; export const CustomerEntitlementFiltersSchema = z.object({ cusEntIds: z.array(z.string()).optional(), @@ -56,10 +55,6 @@ export const FullCustomerEntitlementSchema = CustomerEntitlementSchema.extend({ entitlement: EntitlementWithFeatureSchema, replaceables: z.array(ReplaceableSchema), rollovers: z.array(RolloverSchema), - - // Windowed usage-limit counters, persisted as their own rows (second limit - // dimension on top of balance). - usage_windows: z.array(UsageWindowSchema).nullish(), }); export type CustomerEntitlementFilters = z.infer< diff --git a/shared/models/cusProductModels/cusEntModels/usageWindowModels.ts b/shared/models/cusProductModels/cusEntModels/usageWindowModels.ts index c0bf88d91..01d5cd1c9 100644 --- a/shared/models/cusProductModels/cusEntModels/usageWindowModels.ts +++ b/shared/models/cusProductModels/cusEntModels/usageWindowModels.ts @@ -28,6 +28,7 @@ export type UsageWindowScope = z.infer; export const UsageWindowLimitSchema = z.object({ feature_id: z.string(), internal_feature_id: z.string(), + internal_customer_id: z.string(), key: z.string(), dimension_type: UsageWindowDimensionSchema, dimension_feature_id: z.string().nullable(), @@ -38,12 +39,15 @@ export const UsageWindowLimitSchema = z.object({ window_start_at: z.number(), window_end_at: z.number(), limit: z.number(), - // The single entitlement that owns this counter, resolved in TS so it is - // deduction-order-independent. Null when no eligible owner exists (e.g. a - // customer-scope cap with only entity-scoped entitlements) -> enforcement - // must fail closed rather than split or silently allow. + // Bounds/interval provenance: the entitlement whose reset interval and + // billing-cycle anchor shaped this window. Stamped onto the counter row at + // creation; storage no longer depends on it, so null just means calendar + // bounds with no provenance. anchor_customer_entitlement_id: z.string().nullable(), - anchor_feature_id: z.string().nullable(), + // Candidate row id (ksuid) minted server-side per request. Lua uses it ONLY + // when this request creates the counter row; lookups match the logical key + // (window_start_at + entity), never the id. + new_window_id: z.string().optional(), }); export type UsageWindowLimit = z.infer; diff --git a/shared/models/cusProductModels/cusEntModels/usageWindowTable.ts b/shared/models/cusProductModels/cusEntModels/usageWindowTable.ts index dd40f5a4e..2c96b607b 100644 --- a/shared/models/cusProductModels/cusEntModels/usageWindowTable.ts +++ b/shared/models/cusProductModels/cusEntModels/usageWindowTable.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { foreignKey, index, @@ -7,20 +8,30 @@ import { uniqueIndex, } from "drizzle-orm/pg-core"; import { z } from "zod/v4"; +import { customers } from "../../cusModels/cusTable.js"; +import { entities } from "../../cusModels/entityModels/entityTable.js"; import { features } from "../../featureModels/featureTable.js"; import { customerEntitlements } from "./cusEntTable.js"; /** - * A single windowed usage counter, persisted as its own row beneath a customer - * entitlement. `usage` is the running total consumed within [window_start_at, - * window_end_at). The enforced limit is resolved at deduction time, so it is not - * stored here. + * A single windowed usage counter, persisted as its own row scoped to the + * CUSTOMER (not an entitlement): one row per (customer, capped feature, + * window). `usage` is the running total consumed within [window_start_at, + * window_end_at). The enforced limit is resolved at deduction time, so it is + * not stored here. + * + * `internal_entity_id` is NULL for customer-scope counters; entity-scoped + * windows (v2) will set it. `anchor_customer_entitlement_id` records which + * entitlement supplied the window bounds at initialization (provenance only -- + * deleting that entitlement must never erase usage, hence ON DELETE SET NULL). */ export const UsageWindowSchema = z.object({ id: z.string(), - customer_entitlement_id: z.string(), + internal_customer_id: z.string(), + internal_entity_id: z.string().nullable(), feature_id: z.string(), internal_feature_id: z.string(), + anchor_customer_entitlement_id: z.string().nullable(), window_start_at: z.number(), window_end_at: z.number(), usage: z.number(), @@ -31,9 +42,11 @@ export const usageWindows = pgTable( "usage_windows", { id: text("id").primaryKey().notNull(), - customer_entitlement_id: text("customer_entitlement_id").notNull(), + internal_customer_id: text("internal_customer_id").notNull(), + internal_entity_id: text("internal_entity_id"), feature_id: text("feature_id").notNull(), internal_feature_id: text("internal_feature_id").notNull(), + anchor_customer_entitlement_id: text("anchor_customer_entitlement_id"), window_start_at: numeric({ mode: "number" }).notNull(), window_end_at: numeric({ mode: "number" }).notNull(), usage: numeric({ mode: "number" }).notNull().default(0), @@ -41,25 +54,40 @@ export const usageWindows = pgTable( }, (table) => [ foreignKey({ - columns: [table.customer_entitlement_id], - foreignColumns: [customerEntitlements.id], - name: "usage_windows_customer_entitlement_id_fkey", - }) - .onUpdate("cascade") - .onDelete("cascade"), + columns: [table.internal_customer_id], + foreignColumns: [customers.internal_id], + name: "usage_windows_internal_customer_id_fkey", + }).onDelete("cascade"), + foreignKey({ + columns: [table.internal_entity_id], + foreignColumns: [entities.internal_id], + name: "usage_windows_internal_entity_id_fkey", + }).onDelete("cascade"), foreignKey({ columns: [table.internal_feature_id], foreignColumns: [features.internal_id], name: "usage_windows_internal_feature_id_fkey", }).onDelete("cascade"), + // Provenance only: the anchor supplied the window bounds at init; its + // deletion must not erase accumulated usage. + foreignKey({ + columns: [table.anchor_customer_entitlement_id], + foreignColumns: [customerEntitlements.id], + name: "usage_windows_anchor_customer_entitlement_id_fkey", + }) + .onUpdate("cascade") + .onDelete("set null"), - index("idx_usage_windows_customer_entitlement_id").on( - table.customer_entitlement_id, + index("idx_usage_windows_internal_customer_id").on( + table.internal_customer_id, ), - uniqueIndex("idx_usage_windows_cus_ent_feature_window").on( - table.customer_entitlement_id, - table.feature_id, - table.window_start_at, + // ONE mutable counter row per scope: bounds roll forward in place, usage + // zeroes when its window closes. NULL internal_entity_id = customer + // scope; COALESCE makes the key unique across both scopes. + uniqueIndex("idx_usage_windows_customer_feature_scope").on( + table.internal_customer_id, + table.internal_feature_id, + sql`COALESCE(${table.internal_entity_id}, '')`, ), ], ).enableRLS(); diff --git a/shared/utils/fullSubjectUtils/fullSubjectToApiSpendLimits.ts b/shared/utils/fullSubjectUtils/fullSubjectToApiSpendLimits.ts deleted file mode 100644 index 890821688..000000000 --- a/shared/utils/fullSubjectUtils/fullSubjectToApiSpendLimits.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { SpendLimitResponse } from "../../models/cusModels/billingControls/spendLimit.js"; -import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js"; -import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; -import type { Feature } from "../../models/featureModels/featureModels.js"; -import { fullSubjectToUsageWindowLimits } from "./fullSubjectToUsageWindowLimits.js"; - -const fullSubjectToAllCustomerEntitlements = ({ - fullSubject, -}: { - fullSubject: FullSubject; -}): FullCustomerEntitlement[] => [ - ...fullSubject.customer_products.flatMap( - (customerProduct) => customerProduct.customer_entitlements, - ), - ...(fullSubject.extra_customer_entitlements ?? []), -]; - -/** - * Response decorator for customer spend limits. `usage_limit_used` is runtime - * state read from the current usage-window counter, not stored billing config. - */ -export const fullSubjectToApiSpendLimits = ({ - fullSubject, - features, - now = Date.now(), - inStatuses, -}: { - fullSubject: FullSubject; - features: Feature[]; - now?: number; - inStatuses?: CusProductStatus[]; -}): SpendLimitResponse[] | undefined => { - const spendLimits = fullSubject.customer.spend_limits; - if (spendLimits == null) return undefined; - - const usageLimitFeatureIds = spendLimits - .filter( - (spendLimit) => - spendLimit.feature_id != null && spendLimit.usage_limit != null, - ) - .map((spendLimit) => spendLimit.feature_id!); - - const usageWindowLimits = - usageLimitFeatureIds.length > 0 - ? fullSubjectToUsageWindowLimits({ - fullSubject, - featureIds: usageLimitFeatureIds, - features, - now, - inStatuses, - }) - : []; - - const allCustomerEntitlements = fullSubjectToAllCustomerEntitlements({ - fullSubject, - }); - const usageLimitUsedByFeatureId = new Map(); - - for (const limit of usageWindowLimits) { - if (limit.anchor_customer_entitlement_id == null) continue; - - const anchorCustomerEntitlement = allCustomerEntitlements.find( - (customerEntitlement) => - customerEntitlement.id === limit.anchor_customer_entitlement_id, - ); - const usageWindow = anchorCustomerEntitlement?.usage_windows?.find( - (window) => - window.feature_id === limit.feature_id && - Number(window.window_start_at) === limit.window_start_at, - ); - const usage = Number(usageWindow?.usage ?? 0); - - usageLimitUsedByFeatureId.set( - limit.feature_id, - Number.isFinite(usage) ? Math.max(0, usage) : 0, - ); - } - - return spendLimits.map((spendLimit) => { - if (spendLimit.usage_limit == null) return spendLimit; - - return { - ...spendLimit, - usage_limit_used: - spendLimit.feature_id == null - ? 0 - : (usageLimitUsedByFeatureId.get(spendLimit.feature_id) ?? 0), - }; - }); -}; diff --git a/shared/utils/fullSubjectUtils/fullSubjectToApiUsageLimits.ts b/shared/utils/fullSubjectUtils/fullSubjectToApiUsageLimits.ts new file mode 100644 index 000000000..810102840 --- /dev/null +++ b/shared/utils/fullSubjectUtils/fullSubjectToApiUsageLimits.ts @@ -0,0 +1,53 @@ +import type { ApiUsageLimit } from "../../api/billingControls/usageLimit.js"; +import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js"; +import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; +import type { Feature } from "../../models/featureModels/featureModels.js"; +import { getCurrentUsageWindowUsage } from "../usageWindowUtils/getCurrentUsageWindowUsage.js"; +import { fullSubjectToUsageWindowLimits } from "./fullSubjectToUsageWindowLimits.js"; + +/** + * Response decorator for one arm's stored usage limits: each entry plus + * `usage` -- the amount already consumed in the active window, read from the + * subject's usage-window counters. `source` is explicit (not inferred from + * subjectType) because check builds the CUSTOMER arm from an entity subject. + */ +export const fullSubjectToApiUsageLimits = ({ + fullSubject, + features, + now = Date.now(), + inStatuses, + source = "customer", +}: { + fullSubject: FullSubject; + features: Feature[]; + now?: number; + inStatuses?: CusProductStatus[]; + source?: "customer" | "entity"; +}): ApiUsageLimit[] | undefined => { + const usageLimits = + source === "entity" + ? fullSubject.entity?.usage_limits + : fullSubject.customer.usage_limits; + if (usageLimits == null) return undefined; + + const resolvedLimits = fullSubjectToUsageWindowLimits({ + fullSubject, + featureIds: usageLimits.map((usageLimit) => usageLimit.feature_id), + features, + now, + inStatuses, + }); + const usageWindows = fullSubject.usage_windows ?? []; + + return usageLimits.map((usageLimit) => { + const resolved = resolvedLimits.find( + (limit) => limit.feature_id === usageLimit.feature_id, + ); + if (!resolved) return usageLimit; + + return { + ...usageLimit, + usage: getCurrentUsageWindowUsage({ usageWindows, limit: resolved, now }), + }; + }); +}; diff --git a/shared/utils/fullSubjectUtils/fullSubjectToUsageWindowLimits.ts b/shared/utils/fullSubjectUtils/fullSubjectToUsageWindowLimits.ts index c29bd7f89..d35926266 100644 --- a/shared/utils/fullSubjectUtils/fullSubjectToUsageWindowLimits.ts +++ b/shared/utils/fullSubjectUtils/fullSubjectToUsageWindowLimits.ts @@ -1,76 +1,15 @@ import type { FullSubject } from "../../models/cusModels/fullSubject/fullSubjectModel.js"; -import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import type { - UsageWindowDimension, - UsageWindowLimit, -} from "../../models/cusProductModels/cusEntModels/usageWindowModels.js"; -import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; -import { FeatureType } from "../../models/featureModels/featureEnums.js"; +import type { UsageWindowLimit } from "../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; import type { Feature } from "../../models/featureModels/featureModels.js"; -import { getRelevantFeatures } from "../featureUtils.js"; -import { buildUsageWindowKey } from "../usageWindowUtils/buildUsageWindowKey.js"; -import { getUsageWindowBounds } from "../usageWindowUtils/getUsageWindowBounds.js"; -import { - type AnchorCandidate, - pickAnchorCustomerEntitlementId, -} from "../usageWindowUtils/pickAnchorCustomerEntitlementId.js"; -import { fullSubjectToCustomerEntitlements } from "./fullSubjectToCustomerEntitlements.js"; - -// Lower rank wins in the anchor tie-break. Loose entitlements (no product) are -// treated as active grants. -const customerProductStatusToAnchorRank = ( - status: CusProductStatus | undefined, -): number => { - switch (status) { - case undefined: - case CusProductStatus.Active: - return 0; - case CusProductStatus.PastDue: - return 1; - case CusProductStatus.Scheduled: - return 2; - case CusProductStatus.Trialing: - return 3; - default: - return 999; - } -}; - -const toAnchorCandidate = ( - customerEntitlement: FullCusEntWithFullCusProduct, -): AnchorCandidate => ({ - id: customerEntitlement.id, - is_entity_scoped: customerEntitlement.internal_entity_id !== null, - is_add_on: customerEntitlement.customer_product?.product.is_add_on ?? false, - status_rank: customerProductStatusToAnchorRank( - customerEntitlement.customer_product?.status, - ), - created_at: - customerEntitlement.customer_product?.created_at ?? - customerEntitlement.created_at, -}); +import { usageLimitToUsageWindowLimit } from "../usageWindowUtils/convertUsageWindow/usageLimitToUsageWindowLimit.js"; /** - * Resolves the enforceable usage-window limits for the requested features from a - * FullSubject. A windowed cap is armed by setting `usage_limit` on a customer - * `spend_limit` entry (flat config; its presence is the switch, independent of the - * entry-level `enabled` which gates the overage cap). - * v1 reads ONLY customer-scoped spend_limits; entity usage windows are out of scope. - * - * The window interval is the entry's `usage_limit_interval` if set, else inherited - * from the anchor entitlement's reset interval (`entitlement.interval`) - so a cap - * defaults to the billing cycle and you usually set only `usage_limit`. No - * resolvable interval (e.g. a boolean entitlement with no interval and no override) - * means no enforceable cap, so the feature is skipped. - * - * A cap on a credit-system feature targets the credit pool (`balance` dimension); - * a cap on any other feature targets that feature's usage (`metered_feature`). - * Window bounds align to the customer's billing cycle (the anchor entitlement's - * `billing_cycle_anchor_resets_at`), falling back to UTC calendar when absent. - * - * Each limit gets a single owning `anchor_customer_entitlement_id`, resolved - * deduction-order-independently so one counter never splits across pools. Null - * anchor means no eligible owner; the enforcement layer fails closed. + * Resolves the enforceable usage-window limits for the requested features. + * Exactly ONE cap per feature per subject, mirroring spend-limit inheritance + * (fullSubjectToSpendLimitByFeatureId): the entity's own `usage_limits` entry + * wins (entity-scoped counter), else the customer's entry fills the gap at + * customer scope (the shared aggregate counter). */ export const fullSubjectToUsageWindowLimits = ({ fullSubject, @@ -84,110 +23,43 @@ export const fullSubjectToUsageWindowLimits = ({ features: Feature[]; now: number; // Status filter for entitlement lookups; pass the caller's orgToInStatuses so - // the cap's value/anchor resolution matches what the deduction can act on. + // the cap's anchor resolution matches what the deduction can act on. inStatuses?: CusProductStatus[]; }): UsageWindowLimit[] => { - // v1: customer-scoped caps only; entity-scoped usage windows are out of scope. - const customerSpendLimits = fullSubject.customer.spend_limits ?? []; + const entityUsageLimits = fullSubject.entity?.usage_limits ?? []; + const customerUsageLimits = fullSubject.customer.usage_limits ?? []; const limits: UsageWindowLimit[] = []; for (const featureId of [...new Set(featureIds)]) { - const spendLimit = customerSpendLimits.find( - (candidate) => - candidate.feature_id === featureId && candidate.usage_limit != null, + const entityUsageLimit = entityUsageLimits.find( + (candidate) => candidate.feature_id === featureId, ); - const limit = spendLimit?.usage_limit; - if (spendLimit == null || limit == null) continue; + const usageLimit = + entityUsageLimit ?? + customerUsageLimits.find( + (candidate) => candidate.feature_id === featureId, + ); + if (!usageLimit) continue; - const scopeType = "customer" as const; - const entityId = null; - const internalEntityId = null; + const feature = features.find((candidate) => candidate.id === featureId); + if (!feature) continue; - const featureObject = features.find((feature) => feature.id === featureId); - // No catalog feature => no internal_feature_id (a NOT NULL FK on the windows - // table); the cap is unenforceable and unstorable, so skip it. - if (featureObject?.internal_id == null) continue; - const isCreditSystem = featureObject.type === FeatureType.CreditSystem; - const dimensionType: UsageWindowDimension = isCreditSystem - ? "balance" - : "metered_feature"; - const dimensionFeatureId = isCreditSystem ? null : featureId; - - // Balance dim is owned by the credit-system entitlement. Metered dim - // prefers the member feature's own entitlement, then falls back to a - // credit system that contains it. - const containingCreditSystemFeatureIds = getRelevantFeatures({ + const limit = usageLimitToUsageWindowLimit({ + fullSubject, + usageLimit, + feature, features, - featureId, - }) - .map((feature) => feature.id) - .filter((relevantFeatureId) => relevantFeatureId !== featureId); - const ownerFeatureIdsByPreference = isCreditSystem - ? [[featureId]] - : [[featureId], containingCreditSystemFeatureIds]; - - let anchorId: string | null = null; - let anchorFeatureId: string | null = null; - let anchorCustomerEntitlement: FullCusEntWithFullCusProduct | undefined; - for (const ownerFeatureIds of ownerFeatureIdsByPreference) { - if (ownerFeatureIds.length === 0) continue; - const candidateEntitlements = fullSubjectToCustomerEntitlements({ - fullSubject, - featureIds: ownerFeatureIds, - inStatuses, - }); - anchorId = pickAnchorCustomerEntitlementId({ - candidates: candidateEntitlements.map(toAnchorCandidate), - scopeType, - }); - if (anchorId) { - anchorCustomerEntitlement = candidateEntitlements.find( - (customerEntitlement) => customerEntitlement.id === anchorId, - ); - anchorFeatureId = anchorCustomerEntitlement?.feature_id ?? null; - break; - } - } - - const interval = - spendLimit.usage_limit_interval ?? - anchorCustomerEntitlement?.entitlement.interval; - if (interval == null) continue; - - // Align window bounds to the customer's billing cycle when the anchor has a - // cycle anchor; otherwise getUsageWindowBounds falls back to UTC calendar. - const cycleAnchor = - anchorCustomerEntitlement?.customer_product - ?.billing_cycle_anchor_resets_at ?? null; - const { windowStartAt, windowEndAt } = getUsageWindowBounds({ - interval, now, - anchor: cycleAnchor, - }); - - limits.push({ - feature_id: featureId, - internal_feature_id: featureObject.internal_id, - key: buildUsageWindowKey({ - scopeType, - internalEntityId, - dimensionType, - dimensionFeatureId, - interval, - windowStartAt, - }), - dimension_type: dimensionType, - dimension_feature_id: dimensionFeatureId, - scope_type: scopeType, - entity_id: entityId, - internal_entity_id: internalEntityId, - interval, - window_start_at: windowStartAt, - window_end_at: windowEndAt, - limit, - anchor_customer_entitlement_id: anchorId, - anchor_feature_id: anchorFeatureId, + inStatuses, + entityScope: + entityUsageLimit && fullSubject.entity + ? { + entityId: fullSubject.entity.id, + internalEntityId: fullSubject.entity.internal_id, + } + : null, }); + if (limit) limits.push(limit); } return limits; diff --git a/shared/utils/fullSubjectUtils/index.ts b/shared/utils/fullSubjectUtils/index.ts index fafd46ba9..f9b5fbc87 100644 --- a/shared/utils/fullSubjectUtils/index.ts +++ b/shared/utils/fullSubjectUtils/index.ts @@ -2,7 +2,7 @@ export * from "./aggregatedUtils/index.js"; export { fullSubjectHasUsageBasedAllocated } from "./classifyFullSubject.js"; export { fullCustomerToFullSubject } from "./fullCustomerToFullSubject.js"; export { fullSubjectToApiCustomerProducts } from "./fullSubjectToApiCustomerProducts.js"; -export { fullSubjectToApiSpendLimits } from "./fullSubjectToApiSpendLimits.js"; +export { fullSubjectToApiUsageLimits } from "./fullSubjectToApiUsageLimits.js"; export { fullSubjectToCustomerEntitlements } from "./fullSubjectToCustomerEntitlements.js"; export { fullSubjectToFullCustomer } from "./fullSubjectToFullCustomer.js"; export { fullSubjectToOverageAllowedByFeatureId } from "./fullSubjectToOverageAllowed.js"; diff --git a/shared/utils/fullSubjectUtils/mergeCustomerBillingControlsForCheck.ts b/shared/utils/fullSubjectUtils/mergeCustomerBillingControlsForCheck.ts index d91ab54b7..a93907553 100644 --- a/shared/utils/fullSubjectUtils/mergeCustomerBillingControlsForCheck.ts +++ b/shared/utils/fullSubjectUtils/mergeCustomerBillingControlsForCheck.ts @@ -3,12 +3,12 @@ import type { ApiEntityV2 } from "../../api/entities/apiEntityV2.js"; /** * Build a new entity apiSubject whose billing_controls inherit the customer's - * spend_limits and overage_allowed entries per feature_id. Entity's own entry - * always wins per feature; customer's entries fill any gaps. + * spend_limits, usage_limits and overage_allowed entries per feature_id. + * Entity's own entry always wins per feature; customer's entries fill gaps. * - * Used at check time so `apiSubjectToSpendLimit` / `apiSubjectToOverageAllowedControl` - * (which read from `subject.billing_controls`) see the inherited controls - * without needing to know about the customer separately. + * Used at check time so `apiSubjectToSpendLimit` / `apiSubjectToUsageLimitHeadroom` + * / `apiSubjectToOverageAllowedControl` (which read from `subject.billing_controls`) + * see the inherited controls without needing to know about the customer separately. * * Pure — does not mutate inputs. */ @@ -19,11 +19,16 @@ export const mergeCustomerBillingControlsForCheck = ({ entityApiSubject: ApiEntityV2; customerApiSubject: ApiCustomerV5; }): ApiEntityV2 => { - const entitySpendLimits = entityApiSubject.billing_controls?.spend_limits ?? []; + const entitySpendLimits = + entityApiSubject.billing_controls?.spend_limits ?? []; + const entityUsageLimits = + entityApiSubject.billing_controls?.usage_limits ?? []; const entityOverageAllowed = entityApiSubject.billing_controls?.overage_allowed ?? []; const customerSpendLimits = customerApiSubject.billing_controls?.spend_limits ?? []; + const customerUsageLimits = + customerApiSubject.billing_controls?.usage_limits ?? []; const customerOverageAllowed = customerApiSubject.billing_controls?.overage_allowed ?? []; @@ -32,6 +37,9 @@ export const mergeCustomerBillingControlsForCheck = ({ .map((entry) => entry.feature_id) .filter((id): id is string => !!id), ); + const entityUsageLimitFeatureIds = new Set( + entityUsageLimits.map((entry) => entry.feature_id), + ); const entityOverageAllowedFeatureIds = new Set( entityOverageAllowed.map((entry) => entry.feature_id), ); @@ -40,12 +48,18 @@ export const mergeCustomerBillingControlsForCheck = ({ (entry) => !!entry.feature_id && !entitySpendLimitFeatureIds.has(entry.feature_id), ); + // Inherited entries keep the CUSTOMER-window `usage`: the gap-filling cap is + // the shared aggregate window, not a per-entity copy. + const inheritedUsageLimits = customerUsageLimits.filter( + (entry) => !entityUsageLimitFeatureIds.has(entry.feature_id), + ); const inheritedOverageAllowed = customerOverageAllowed.filter( (entry) => !entityOverageAllowedFeatureIds.has(entry.feature_id), ); if ( inheritedSpendLimits.length === 0 && + inheritedUsageLimits.length === 0 && inheritedOverageAllowed.length === 0 ) { return entityApiSubject; @@ -56,6 +70,7 @@ export const mergeCustomerBillingControlsForCheck = ({ billing_controls: { ...entityApiSubject.billing_controls, spend_limits: [...entitySpendLimits, ...inheritedSpendLimits], + usage_limits: [...entityUsageLimits, ...inheritedUsageLimits], overage_allowed: [...entityOverageAllowed, ...inheritedOverageAllowed], }, }; diff --git a/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts b/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts index 4f056aeaa..597339e6c 100644 --- a/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts +++ b/shared/utils/fullSubjectUtils/normalizedToFullSubject.ts @@ -48,11 +48,6 @@ const subjectBalanceToFullCustomerEntitlement = ({ const rollovers = getArrayEntries({ value: subjectBalance.rollovers, }); - const usageWindows = getArrayEntries< - SubjectBalance["usage_windows"][number] - >({ - value: subjectBalance.usage_windows, - }); return { id: subjectBalance.id, @@ -81,7 +76,6 @@ const subjectBalanceToFullCustomerEntitlement = ({ getRolloverSortValue({ rollover: left }) - getRolloverSortValue({ rollover: right }), ), - usage_windows: usageWindows, } as FullCustomerEntitlement; }; @@ -392,6 +386,19 @@ export const normalizedToFullSubject = ({ customer: normalized.customer, customer_products: customerProducts, extra_customer_entitlements: extraCustomerEntitlements, + // Normalized carries ALL scopes (the balance-hash field must stay + // complete); the subject view narrows to the rows that can gate it -- + // an entity sees its own rows plus the inheritable customer-scope ones. + usage_windows: getArrayEntries< + NormalizedFullSubject["usage_windows"][number] + >({ + value: normalized.usage_windows, + }).filter((usageWindow) => + normalized.internalEntityId + ? usageWindow.internal_entity_id === normalized.internalEntityId || + usageWindow.internal_entity_id == null + : true, + ), subscriptions, invoices, ...(aggregatedCustomerProducts diff --git a/shared/utils/usageWindowUtils/classifyUsageWindow/usageWindowMatchesLimit.ts b/shared/utils/usageWindowUtils/classifyUsageWindow/usageWindowMatchesLimit.ts new file mode 100644 index 000000000..ccbd305e4 --- /dev/null +++ b/shared/utils/usageWindowUtils/classifyUsageWindow/usageWindowMatchesLimit.ts @@ -0,0 +1,16 @@ +import type { UsageWindowLimit } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { UsageWindow } from "../../../models/cusProductModels/cusEntModels/usageWindowTable.js"; + +/** + * Whether a counter row and a resolved limit describe the same counter: + * same feature, same scope (null entity = customer scope). + */ +export const usageWindowMatchesLimit = ({ + usageWindow, + limit, +}: { + usageWindow: UsageWindow; + limit: UsageWindowLimit; +}): boolean => + usageWindow.feature_id === limit.feature_id && + (usageWindow.internal_entity_id ?? null) === limit.internal_entity_id; diff --git a/shared/utils/usageWindowUtils/convertUsageWindow/getUsageWindowDimension.ts b/shared/utils/usageWindowUtils/convertUsageWindow/getUsageWindowDimension.ts new file mode 100644 index 000000000..e1ebba4d5 --- /dev/null +++ b/shared/utils/usageWindowUtils/convertUsageWindow/getUsageWindowDimension.ts @@ -0,0 +1,26 @@ +import type { UsageWindowDimension } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import { FeatureType } from "../../../models/featureModels/featureEnums.js"; +import type { Feature } from "../../../models/featureModels/featureModels.js"; + +/** + * Which dimension a usage limit on `feature` counts against: + * - a credit-system feature caps the credit POOL (`balance` dimension, + * counted in credits drained); + * - any other feature caps that feature's own usage (`metered_feature` + * dimension, counted in tracked units). + */ +export const getUsageWindowDimension = ({ + feature, +}: { + feature: Feature; +}): { + dimensionType: UsageWindowDimension; + dimensionFeatureId: string | null; +} => { + const isCreditSystem = feature.type === FeatureType.CreditSystem; + + return { + dimensionType: isCreditSystem ? "balance" : "metered_feature", + dimensionFeatureId: isCreditSystem ? null : feature.id, + }; +}; diff --git a/shared/utils/usageWindowUtils/convertUsageWindow/usageLimitToUsageWindowLimit.ts b/shared/utils/usageWindowUtils/convertUsageWindow/usageLimitToUsageWindowLimit.ts new file mode 100644 index 000000000..0dd3c8f26 --- /dev/null +++ b/shared/utils/usageWindowUtils/convertUsageWindow/usageLimitToUsageWindowLimit.ts @@ -0,0 +1,94 @@ +import type { DbUsageLimit } from "../../../models/cusModels/billingControls/usageLimit.js"; +import type { FullSubject } from "../../../models/cusModels/fullSubject/fullSubjectModel.js"; +import type { UsageWindowLimit } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { CusProductStatus } from "../../../models/cusProductModels/cusProductEnums.js"; +import type { Feature } from "../../../models/featureModels/featureModels.js"; +import { resetIntvToEntIntv } from "../../productV2Utils/productItemUtils/convertProductItem/planItemIntervals.js"; +import { buildUsageWindowKey } from "../buildUsageWindowKey.js"; +import { findUsageWindowAnchor } from "../findUsageWindowAnchor/findUsageWindowAnchor.js"; +import { getUsageWindowAnchorTimestamp } from "../getUsageWindowAnchorTimestamp.js"; +import { getUsageWindowBounds } from "../getUsageWindowBounds.js"; +import { getUsageWindowDimension } from "./getUsageWindowDimension.js"; + +/** Non-null = the cap is the entity's own: its counter is entity-scoped. */ +export type UsageWindowEntityScope = { + entityId: string | null; + internalEntityId: string; +}; + +/** + * Resolves one stored usage-limit entry into the enforceable UsageWindowLimit + * handed to the deduction script. The entry's ResetInterval converts to the + * internal EntInterval here -- the single edge between the API/storage + * vocabulary and the window internals. Window bounds align to the customer's + * billing cycle via the anchor entitlement when one exists, else UTC calendar. + */ +export const usageLimitToUsageWindowLimit = ({ + fullSubject, + usageLimit, + feature, + features, + now, + inStatuses, + entityScope = null, +}: { + fullSubject: FullSubject; + usageLimit: DbUsageLimit; + feature: Feature; + features: Feature[]; + now: number; + inStatuses?: CusProductStatus[]; + entityScope?: UsageWindowEntityScope | null; +}): UsageWindowLimit | null => { + // No catalog internal_id => unstorable counter row (NOT NULL FK); the cap + // is unenforceable, so skip it. + if (feature.internal_id == null) return null; + + const interval = resetIntvToEntIntv({ resetIntv: usageLimit.interval }); + if (interval == null) return null; + + const { dimensionType, dimensionFeatureId } = getUsageWindowDimension({ + feature, + }); + + const scopeType = entityScope ? "entity" : "customer"; + const { anchorCustomerEntitlementId, anchorCustomerEntitlement } = + findUsageWindowAnchor({ + fullSubject, + featureId: feature.id, + features, + isCreditSystem: dimensionType === "balance", + inStatuses, + scopeType, + }); + + const { windowStartAt, windowEndAt } = getUsageWindowBounds({ + interval, + now, + anchor: getUsageWindowAnchorTimestamp({ anchorCustomerEntitlement }), + }); + + return { + feature_id: feature.id, + internal_feature_id: feature.internal_id, + internal_customer_id: fullSubject.internalCustomerId, + key: buildUsageWindowKey({ + scopeType, + internalEntityId: entityScope?.internalEntityId ?? null, + dimensionType, + dimensionFeatureId, + interval, + windowStartAt, + }), + dimension_type: dimensionType, + dimension_feature_id: dimensionFeatureId, + scope_type: scopeType, + entity_id: entityScope?.entityId ?? null, + internal_entity_id: entityScope?.internalEntityId ?? null, + interval, + window_start_at: windowStartAt, + window_end_at: windowEndAt, + limit: usageLimit.limit, + anchor_customer_entitlement_id: anchorCustomerEntitlementId, + }; +}; diff --git a/shared/utils/usageWindowUtils/findUsageWindow/findUsageWindowByLimit.ts b/shared/utils/usageWindowUtils/findUsageWindow/findUsageWindowByLimit.ts new file mode 100644 index 000000000..86cedc935 --- /dev/null +++ b/shared/utils/usageWindowUtils/findUsageWindow/findUsageWindowByLimit.ts @@ -0,0 +1,15 @@ +import type { UsageWindowLimit } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { UsageWindow } from "../../../models/cusProductModels/cusEntModels/usageWindowTable.js"; +import { usageWindowMatchesLimit } from "../classifyUsageWindow/usageWindowMatchesLimit.js"; + +/** The limit's counter row (one mutable row per scope), if it exists yet. */ +export const findUsageWindowByLimit = ({ + usageWindows, + limit, +}: { + usageWindows: UsageWindow[]; + limit: UsageWindowLimit; +}): UsageWindow | undefined => + usageWindows.find((usageWindow) => + usageWindowMatchesLimit({ usageWindow, limit }), + ); diff --git a/shared/utils/usageWindowUtils/findUsageWindow/findUsageWindowLimitByWindow.ts b/shared/utils/usageWindowUtils/findUsageWindow/findUsageWindowLimitByWindow.ts new file mode 100644 index 000000000..9da2748e4 --- /dev/null +++ b/shared/utils/usageWindowUtils/findUsageWindow/findUsageWindowLimitByWindow.ts @@ -0,0 +1,13 @@ +import type { UsageWindowLimit } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { UsageWindow } from "../../../models/cusProductModels/cusEntModels/usageWindowTable.js"; +import { usageWindowMatchesLimit } from "../classifyUsageWindow/usageWindowMatchesLimit.js"; + +/** The resolved limit governing a counter row, if one is armed for its scope. */ +export const findUsageWindowLimitByWindow = ({ + limits, + usageWindow, +}: { + limits: UsageWindowLimit[]; + usageWindow: UsageWindow; +}): UsageWindowLimit | undefined => + limits.find((limit) => usageWindowMatchesLimit({ usageWindow, limit })); diff --git a/shared/utils/usageWindowUtils/findUsageWindowAnchor/findUsageWindowAnchor.ts b/shared/utils/usageWindowUtils/findUsageWindowAnchor/findUsageWindowAnchor.ts new file mode 100644 index 000000000..62a7bbe64 --- /dev/null +++ b/shared/utils/usageWindowUtils/findUsageWindowAnchor/findUsageWindowAnchor.ts @@ -0,0 +1,112 @@ +import type { FullSubject } from "../../../models/cusModels/fullSubject/fullSubjectModel.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { UsageWindowScope } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import { CusProductStatus } from "../../../models/cusProductModels/cusProductEnums.js"; +import type { Feature } from "../../../models/featureModels/featureModels.js"; +import { getRelevantFeatures } from "../../featureUtils.js"; +import { fullSubjectToCustomerEntitlements } from "../../fullSubjectUtils/fullSubjectToCustomerEntitlements.js"; +import { + type AnchorCandidate, + pickAnchorCustomerEntitlementId, +} from "./pickAnchorCustomerEntitlementId.js"; + +// Lower rank wins in the anchor tie-break. Loose entitlements (no product) are +// treated as active grants. +const customerProductStatusToAnchorRank = ( + status: CusProductStatus | undefined, +): number => { + switch (status) { + case undefined: + case CusProductStatus.Active: + return 0; + case CusProductStatus.PastDue: + return 1; + case CusProductStatus.Scheduled: + return 2; + case CusProductStatus.Trialing: + return 3; + default: + return 999; + } +}; + +const toAnchorCandidate = ( + customerEntitlement: FullCusEntWithFullCusProduct, +): AnchorCandidate => ({ + id: customerEntitlement.id, + is_entity_scoped: customerEntitlement.internal_entity_id !== null, + is_add_on: customerEntitlement.customer_product?.product.is_add_on ?? false, + is_plan_backed: customerEntitlement.customer_product != null, + status_rank: customerProductStatusToAnchorRank( + customerEntitlement.customer_product?.status, + ), + created_at: + customerEntitlement.customer_product?.created_at ?? + customerEntitlement.created_at, +}); + +/** + * Finds the usage window's ANCHOR entitlement: a bounds/billing-cycle + * reference only (counters are customer-scoped rows, never entitlement-owned). + * It supplies billing-cycle alignment for the window bounds and is stamped on + * counter rows at creation as provenance. + * + * Owner preference: the capped feature's own entitlements first, then (for + * non-credit features) entitlements of credit systems that contain it. Null + * when no eligible entitlement exists -- the cap stays enforceable with + * calendar-aligned bounds. + */ +export const findUsageWindowAnchor = ({ + fullSubject, + featureId, + features, + isCreditSystem, + inStatuses, + scopeType = "customer", +}: { + fullSubject: FullSubject; + featureId: string; + features: Feature[]; + isCreditSystem: boolean; + inStatuses?: CusProductStatus[]; + scopeType?: UsageWindowScope; +}): { + anchorCustomerEntitlementId: string | null; + anchorCustomerEntitlement?: FullCusEntWithFullCusProduct; +} => { + const containingCreditSystemFeatureIds = getRelevantFeatures({ + features, + featureId, + }) + .map((feature) => feature.id) + .filter((relevantFeatureId) => relevantFeatureId !== featureId); + const ownerFeatureIdsByPreference = isCreditSystem + ? [[featureId]] + : [[featureId], containingCreditSystemFeatureIds]; + + for (const ownerFeatureIds of ownerFeatureIdsByPreference) { + if (ownerFeatureIds.length === 0) continue; + + const candidateEntitlements = fullSubjectToCustomerEntitlements({ + fullSubject, + featureIds: ownerFeatureIds, + inStatuses, + }); + const anchorCustomerEntitlementId = pickAnchorCustomerEntitlementId({ + candidates: candidateEntitlements.map(toAnchorCandidate), + scopeType, + }); + + if (anchorCustomerEntitlementId) { + return { + anchorCustomerEntitlementId, + anchorCustomerEntitlement: candidateEntitlements.find( + (customerEntitlement) => + customerEntitlement.id === anchorCustomerEntitlementId, + ), + }; + } + } + + return { anchorCustomerEntitlementId: null }; +}; diff --git a/shared/utils/usageWindowUtils/pickAnchorCustomerEntitlementId.ts b/shared/utils/usageWindowUtils/findUsageWindowAnchor/pickAnchorCustomerEntitlementId.ts similarity index 84% rename from shared/utils/usageWindowUtils/pickAnchorCustomerEntitlementId.ts rename to shared/utils/usageWindowUtils/findUsageWindowAnchor/pickAnchorCustomerEntitlementId.ts index 7baba13d0..ca0e291b0 100644 --- a/shared/utils/usageWindowUtils/pickAnchorCustomerEntitlementId.ts +++ b/shared/utils/usageWindowUtils/findUsageWindowAnchor/pickAnchorCustomerEntitlementId.ts @@ -1,4 +1,4 @@ -import type { UsageWindowScope } from "../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { UsageWindowScope } from "../../../models/cusProductModels/cusEntModels/usageWindowModels.js"; /** * A candidate customer entitlement for owning a usage-window counter, reduced to @@ -10,6 +10,9 @@ export type AnchorCandidate = { id: string; is_entity_scoped: boolean; is_add_on: boolean; + // Product-backed ents outrank loose/top-up grants: their reset cycle is + // what window bounds align to. + is_plan_backed: boolean; // Lower rank = higher priority (e.g. active before past_due). status_rank: number; created_at: number; @@ -46,6 +49,7 @@ export const pickAnchorCustomerEntitlementId = ({ const sorted = [...eligible].sort((a, b) => { if (a.status_rank !== b.status_rank) return a.status_rank - b.status_rank; + if (a.is_plan_backed !== b.is_plan_backed) return a.is_plan_backed ? -1 : 1; if (a.is_add_on !== b.is_add_on) return a.is_add_on ? 1 : -1; if (a.created_at !== b.created_at) return a.created_at - b.created_at; return a.id < b.id ? -1 : 1; diff --git a/shared/utils/usageWindowUtils/getCurrentUsageWindowUsage.ts b/shared/utils/usageWindowUtils/getCurrentUsageWindowUsage.ts new file mode 100644 index 000000000..f150096d2 --- /dev/null +++ b/shared/utils/usageWindowUtils/getCurrentUsageWindowUsage.ts @@ -0,0 +1,30 @@ +import type { UsageWindowLimit } from "../../models/cusProductModels/cusEntModels/usageWindowModels.js"; +import type { UsageWindow } from "../../models/cusProductModels/cusEntModels/usageWindowTable.js"; +import { findUsageWindowByLimit } from "./findUsageWindow/findUsageWindowByLimit.js"; + +/** + * Usage already consumed in the limit's current window: the scope's single + * mutable counter row, derived as 0 when its stored window closed OR no + * longer matches the current derivation (the lazy roll persists the zero; + * reads never trust a dead count). + */ +export const getCurrentUsageWindowUsage = ({ + usageWindows, + limit, + now = Date.now(), +}: { + usageWindows: UsageWindow[]; + limit: UsageWindowLimit; + now?: number; +}): number => { + const scopeRow = findUsageWindowByLimit({ usageWindows, limit }); + if ( + !scopeRow || + Number(scopeRow.window_end_at) <= now || + Number(scopeRow.window_start_at) !== limit.window_start_at + ) + return 0; + + const usage = Number(scopeRow.usage); + return Number.isFinite(usage) ? Math.max(0, usage) : 0; +}; diff --git a/shared/utils/usageWindowUtils/getUsageWindowAnchorTimestamp.ts b/shared/utils/usageWindowUtils/getUsageWindowAnchorTimestamp.ts new file mode 100644 index 000000000..e72f12e8c --- /dev/null +++ b/shared/utils/usageWindowUtils/getUsageWindowAnchorTimestamp.ts @@ -0,0 +1,16 @@ +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; + +/** + * The timestamp a usage window's bounds align to: the anchor entitlement's + * own reset cycle, falling back to the product's billing-cycle anchor, else + * null (UTC calendar). Windows therefore roll WITH the entitlement's cycle -- + * and a plan change that restarts the cycle restarts the window. + */ +export const getUsageWindowAnchorTimestamp = ({ + anchorCustomerEntitlement, +}: { + anchorCustomerEntitlement?: FullCusEntWithFullCusProduct; +}): number | null => + anchorCustomerEntitlement?.next_reset_at ?? + anchorCustomerEntitlement?.customer_product?.billing_cycle_anchor_resets_at ?? + null; diff --git a/statement-breakpoint b/statement-breakpoint new file mode 100644 index 000000000..e69de29bb diff --git a/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx b/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx index 0b748eab8..827b0e44c 100644 --- a/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx +++ b/vite/src/views/customers2/components/CustomerBillingControlsSection.tsx @@ -3,6 +3,7 @@ import type { DbOverageAllowed, DbSpendLimit, DbUsageAlert, + DbUsageLimit, Entity, Feature, FullCustomer, @@ -45,7 +46,9 @@ const StatusPill = ({ enabled }: { enabled: boolean }) => ( {enabled ? "Enabled" : "Disabled"} @@ -105,18 +108,20 @@ const AutoTopupRow = ({
Threshold: {autoTopup.threshold.toLocaleString()} Qty: {autoTopup.quantity.toLocaleString()} - {purchaseLimit && purchaseLimit.limit != null && purchaseLimit.interval != null && ( - - {hasExpandedLimit - ? `${purchaseLimit.count}/${purchaseLimit.limit} per ${purchaseLimit.interval}` - : `Limit: ${purchaseLimit.limit} per ${purchaseLimit.interval}`} - - )} - {hasExpandedLimit && purchaseLimit.next_reset_at && ( - - Resets {format(new Date(purchaseLimit.next_reset_at), "MMM d")} - - )} + {purchaseLimit && + purchaseLimit.limit != null && + purchaseLimit.interval != null && ( + + {hasExpandedLimit + ? `${purchaseLimit.count}/${purchaseLimit.limit} per ${purchaseLimit.interval}` + : `Limit: ${purchaseLimit.limit} per ${purchaseLimit.interval}`} + + )} + {hasExpandedLimit && purchaseLimit.next_reset_at && ( + + Resets {format(new Date(purchaseLimit.next_reset_at), "MMM d")} + + )}
); @@ -155,12 +160,13 @@ const UsageLimitRow = ({ featureNameById, onClick, }: { - usageLimit: DbSpendLimit; + usageLimit: DbUsageLimit; featureNameById: Map; onClick: () => void; }) => ( @@ -274,19 +278,15 @@ export function CustomerBillingControlsSection() { const allSpendLimits = selectedEntity ? (selectedEntity.spend_limits ?? []) : (fullCustomer?.spend_limits ?? []); - // Usage caps are folded into spend_limits (usage_limit set); surface them as a - // separate "Usage limits" control. Keep each entry's original index so edit/delete - // target the right slot in the full spend_limits array. - const indexedSpendLimits = allSpendLimits.map((item, index) => ({ + const spendLimits = allSpendLimits.map((item, index) => ({ item, index, })); - const spendLimits = indexedSpendLimits.filter( - ({ item }) => item.usage_limit == null, - ); - const usageLimits = indexedSpendLimits.filter( - ({ item }) => item.usage_limit != null, - ); + // Usage limits are their own customer-scoped billing control (no entity + // variant in v1). + const usageLimits = ( + selectedEntity ? [] : (fullCustomer?.usage_limits ?? []) + ).map((item: DbUsageLimit, index: number) => ({ item, index })); const usageAlerts = selectedEntity ? (selectedEntity.usage_alerts ?? []) : (fullCustomer?.usage_alerts ?? []); diff --git a/vite/src/views/customers2/components/sheets/BillingUsageLimitSheet.tsx b/vite/src/views/customers2/components/sheets/BillingUsageLimitSheet.tsx index 4165ac38e..74ff37539 100644 --- a/vite/src/views/customers2/components/sheets/BillingUsageLimitSheet.tsx +++ b/vite/src/views/customers2/components/sheets/BillingUsageLimitSheet.tsx @@ -1,9 +1,9 @@ import { - type DbSpendLimit, - EntInterval, + type DbUsageLimit, type Feature, FeatureType, type FullCustomer, + ResetInterval, } from "@autumn/shared"; import { useState } from "react"; import { toast } from "sonner"; @@ -30,43 +30,28 @@ import { CusService } from "@/services/customers/CusService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; -import { useCustomerContext } from "../../customer/CustomerContext"; - -// The empty value means "inherit the feature entitlement's reset interval" -// (usage_limit_interval omitted -> backend defaults to the billing cycle). -export const INHERIT_WINDOW = "inherit"; +// Interval is required (no inherit) and one_off windows are not supported. const WINDOW_OPTIONS: Record = { - [INHERIT_WINDOW]: "Inherit (billing cycle)", - [EntInterval.Day]: "Day", - [EntInterval.Week]: "Week", - [EntInterval.Month]: "Month", - [EntInterval.Year]: "Year", + [ResetInterval.Day]: "Day", + [ResetInterval.Week]: "Week", + [ResetInterval.Month]: "Month", + [ResetInterval.Year]: "Year", }; -/** - * Build the spend_limit entry for a usage cap. The cap is folded into spend_limits - * (presence of usage_limit arms it); window === INHERIT_WINDOW omits the interval so - * the backend inherits the entitlement's reset interval. Any co-located overage limit - * on an edited entry is preserved. - */ +/** Build the usage_limits entry for a windowed hard cap. */ export const buildUsageLimitItem = ({ - existing, featureId, - usageLimit, + limit, window, }: { - existing?: DbSpendLimit; featureId: string; - usageLimit: number; + limit: number; window: string; -}): DbSpendLimit => ({ - ...existing, - feature_id: featureId || undefined, - enabled: existing?.enabled ?? false, - usage_limit: usageLimit, - usage_limit_interval: - window === INHERIT_WINDOW ? undefined : (window as EntInterval), +}): DbUsageLimit => ({ + feature_id: featureId, + limit, + interval: window as ResetInterval, }); export function BillingUsageLimitSheet() { @@ -74,57 +59,42 @@ export function BillingUsageLimitSheet() { const sheetData = useSheetStore((s) => s.data); const sheetType = useSheetStore((s) => s.type); const { customer, refetch } = useCusQuery(); - const { entityId } = useCustomerContext(); const { features } = useFeaturesQuery(); const axiosInstance = useAxiosInstance(); const isEdit = sheetType === "billing-usage-limit-edit"; - const existingItem = sheetData?.item as DbSpendLimit | undefined; + const existingItem = sheetData?.item as DbUsageLimit | undefined; const existingIndex = sheetData?.index as number | undefined; + // v1: usage limits are customer-scoped only (no entity variant). const fullCustomer = customer as FullCustomer | undefined; - const selectedEntity = entityId - ? fullCustomer?.entities?.find( - (e) => e.id === entityId || e.internal_id === entityId, - ) - : null; const [isSaving, setIsSaving] = useState(false); const [featureId, setFeatureId] = useState(existingItem?.feature_id ?? ""); const [usageLimit, setUsageLimit] = useState( - existingItem?.usage_limit?.toString() ?? "", + existingItem?.limit?.toString() ?? "", ); const [windowInterval, setWindowInterval] = useState( - existingItem?.usage_limit_interval ?? INHERIT_WINDOW, + existingItem?.interval ?? ResetInterval.Month, ); const nonArchivedFeatures = (features ?? []).filter( (f: Feature) => !f.archived && f.type !== FeatureType.Boolean, ); - const getCurrentSpendLimits = (): DbSpendLimit[] => { - if (selectedEntity) return [...(selectedEntity.spend_limits ?? [])]; - return [...(fullCustomer?.spend_limits ?? [])]; - }; + const getCurrentUsageLimits = (): DbUsageLimit[] => [ + ...(fullCustomer?.usage_limits ?? []), + ]; - const saveBillingControls = async (spendLimits: DbSpendLimit[]) => { + const saveBillingControls = async (usageLimits: DbUsageLimit[]) => { const customerId = fullCustomer?.id || fullCustomer?.internal_id; if (!customerId) return; - if (selectedEntity) { - await CusService.updateEntity({ - axios: axiosInstance, - customerId, - entityId: selectedEntity.id || selectedEntity.internal_id, - billingControls: { spend_limits: spendLimits }, - }); - } else { - await CusService.updateCustomer({ - axios: axiosInstance, - customer_id: customerId, - data: { billing_controls: { spend_limits: spendLimits } }, - }); - } + await CusService.updateCustomer({ + axios: axiosInstance, + customer_id: customerId, + data: { billing_controls: { usage_limits: usageLimits } }, + }); }; const handleSave = async () => { @@ -140,22 +110,21 @@ export function BillingUsageLimitSheet() { } const item = buildUsageLimitItem({ - existing: existingItem, featureId, - usageLimit: parsedLimit, + limit: parsedLimit, window: windowInterval, }); - const spendLimits = getCurrentSpendLimits(); + const usageLimits = getCurrentUsageLimits(); if (isEdit && existingIndex !== undefined) { - spendLimits[existingIndex] = item; + usageLimits[existingIndex] = item; } else { - spendLimits.push(item); + usageLimits.push(item); } setIsSaving(true); try { - await saveBillingControls(spendLimits); + await saveBillingControls(usageLimits); await refetch(); closeSheet(); toast.success(isEdit ? "Usage limit updated" : "Usage limit added"); @@ -169,22 +138,12 @@ export function BillingUsageLimitSheet() { const handleDelete = async () => { if (existingIndex === undefined) return; - const spendLimits = getCurrentSpendLimits(); - const existing = spendLimits[existingIndex]; - // Preserve a co-located overage limit; otherwise drop the entry entirely. - if (existing?.overage_limit != null || existing?.enabled) { - spendLimits[existingIndex] = { - ...existing, - usage_limit: undefined, - usage_limit_interval: undefined, - }; - } else { - spendLimits.splice(existingIndex, 1); - } + const usageLimits = getCurrentUsageLimits(); + usageLimits.splice(existingIndex, 1); setIsSaving(true); try { - await saveBillingControls(spendLimits); + await saveBillingControls(usageLimits); await refetch(); closeSheet(); toast.success("Usage limit deleted"); diff --git a/vite/src/views/customers2/components/sheets/RecordUsageSheet.tsx b/vite/src/views/customers2/components/sheets/RecordUsageSheet.tsx index 65d6404e4..e1c8a011f 100644 --- a/vite/src/views/customers2/components/sheets/RecordUsageSheet.tsx +++ b/vite/src/views/customers2/components/sheets/RecordUsageSheet.tsx @@ -1,24 +1,33 @@ -import type { Entity, FullCustomer } from "@autumn/shared"; -import { LATEST_VERSION } from "@autumn/shared"; +import type { + CreditSystemConfig, + Entity, + Feature, + FullCustomer, +} from "@autumn/shared"; +import { FeatureType, LATEST_VERSION } from "@autumn/shared"; import { PlusIcon, TrashIcon } from "@phosphor-icons/react"; import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { CheckIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/v2/buttons/Button"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { Input } from "@/components/v2/inputs/Input"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; import { LayoutGroup, SheetFooter, SheetHeader, SheetSection, } from "@/components/v2/sheets/SharedSheetComponents"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useSheetScopeEntityId } from "@/hooks/useSheetScopeEntityId"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; +import { getFeatureIcon } from "@/views/products/features/utils/getFeatureIcon"; import { EntityScopeSelector } from "./EntityScopeSelector"; export function RecordUsageSheet() { @@ -40,6 +49,32 @@ export function RecordUsageSheet() { const featureId = sheetData?.featureId as string | undefined; const featureName = sheetData?.featureName as string | undefined; + const { features } = useFeaturesQuery(); + const creditSystem = features.find((f) => f.id === featureId); + const isCreditSystem = creditSystem?.type === FeatureType.CreditSystem; + + // Credit systems can deduct from the credit balance directly (default) or + // from any metered feature in their schema that still exists. + const featureOptions = useMemo(() => { + if (!(isCreditSystem && creditSystem)) return []; + const schema = + (creditSystem.config as CreditSystemConfig | undefined)?.schema ?? []; + const schemaFeatures = schema + .map((item) => features.find((f) => f.id === item.metered_feature_id)) + .filter((f): f is Feature => Boolean(f)); + return [creditSystem, ...schemaFeatures]; + }, [isCreditSystem, creditSystem, features]); + + const [selectedFeatureId, setSelectedFeatureId] = useState< + string | undefined + >(undefined); + const trackingFeatureId = selectedFeatureId ?? featureId; + const trackingFeatureName = + features.find((f) => f.id === trackingFeatureId)?.name ?? + featureName ?? + featureId; + const showFeatureSelect = isCreditSystem && featureOptions.length > 1; + const [isSubmitting, setIsSubmitting] = useState(false); const [value, setValue] = useState("1"); const [properties, setProperties] = useState< @@ -72,7 +107,7 @@ export function RecordUsageSheet() { const handleSubmit = async () => { const customerId = customer?.id || customer?.internal_id; - if (!customerId || !featureId) return; + if (!customerId || !trackingFeatureId) return; const parsedValue = value.trim() === "" ? 1 : Number.parseFloat(value); if (Number.isNaN(parsedValue)) { @@ -90,7 +125,7 @@ export function RecordUsageSheet() { const params: Record = { customer_id: customerId, - feature_id: featureId, + feature_id: trackingFeatureId, value: parsedValue, }; @@ -128,7 +163,7 @@ export function RecordUsageSheet() { description={ scopeEntityId ? `Tracking for entity ${fullEntity?.name || scopeEntityId}` - : `Record usage for ${featureName ?? featureId}` + : `Record usage for ${trackingFeatureName}` } /> @@ -140,6 +175,50 @@ export function RecordUsageSheet() { /> )} + {showFeatureSelect && ( + + Feature + + value={trackingFeatureId ?? null} + onValueChange={setSelectedFeatureId} + options={featureOptions} + getOptionValue={(feature) => feature.id} + getOptionLabel={(feature) => feature.name} + triggerClassName="w-full" + renderValue={(option) => + option ? ( + + + {getFeatureIcon({ feature: option })} + + {option.name} + + ) : ( + + Select feature + + ) + } + renderOption={(option, isSelected) => ( + <> +
+ + {getFeatureIcon({ feature: option })} + + {option.name} + {option.id === featureId && ( + + Credit system + + )} +
+ {isSelected && } + + )} + /> +
+ )} + Value { - test("new cap with inherited window omits the interval (cap armed by usage_limit)", () => { + test("builds a usage_limits entry (feature, limit, interval)", () => { const item = buildUsageLimitItem({ featureId: "credits", - usageLimit: 5, - window: INHERIT_WINDOW, + limit: 5, + window: ResetInterval.Month, }); expect(item.feature_id).toBe("credits"); - expect(item.usage_limit).toBe(5); - expect(item.usage_limit_interval).toBeUndefined(); - expect(item.enabled).toBe(false); + expect(item.limit).toBe(5); + expect(item.interval).toBe(ResetInterval.Month); }); - test("explicit window sets usage_limit_interval", () => { + test("window selection carries through as the interval", () => { const item = buildUsageLimitItem({ featureId: "credits", - usageLimit: 10, - window: EntInterval.Day, + limit: 10, + window: ResetInterval.Day, }); - expect(item.usage_limit).toBe(10); - expect(item.usage_limit_interval).toBe(EntInterval.Day); - }); - - test("editing preserves a co-located overage limit + enabled", () => { - const existing: DbSpendLimit = { - feature_id: "credits", - enabled: true, - overage_limit: 100, - }; - const item = buildUsageLimitItem({ - existing, - featureId: "credits", - usageLimit: 3, - window: EntInterval.Month, - }); - expect(item.overage_limit).toBe(100); - expect(item.enabled).toBe(true); - expect(item.usage_limit).toBe(3); - expect(item.usage_limit_interval).toBe(EntInterval.Month); - }); - - test("empty feature falls back to undefined feature_id", () => { - const item = buildUsageLimitItem({ - featureId: "", - usageLimit: 1, - window: INHERIT_WINDOW, - }); - expect(item.feature_id).toBeUndefined(); + expect(item.interval).toBe(ResetInterval.Day); }); });