cleaned up usage windows
This commit is contained in:
2
ai
2
ai
Submodule ai updated: 0e52f71fbd...bca809a307
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -92,6 +92,7 @@ declare module "ioredis" {
|
||||
balanceKey: string,
|
||||
paramsJson: string,
|
||||
): Promise<string>;
|
||||
rollUsageWindows(balanceKey: string, paramsJson: string): Promise<string>;
|
||||
deleteFullCustomerCache(
|
||||
cacheKey: string,
|
||||
testGuardKey: string,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,6 +55,7 @@ export const getCheckResponseV2 = async ({
|
||||
apiSubject: evaluationApiSubject,
|
||||
feature: featureToUse,
|
||||
requiredBalance,
|
||||
originalFeature,
|
||||
}).allowed
|
||||
: false;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, RolloverUpdate>;
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, UsageWindow[]> | 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,
|
||||
];
|
||||
};
|
||||
@@ -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<string, RolloverUpdate>;
|
||||
mutationLogs: MutationLogItem[];
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
usageWindowUpdates: UsageWindowUpdate[];
|
||||
usageWindowMutations: UsageWindowMutation[];
|
||||
}> => {
|
||||
const { org, env } = ctx;
|
||||
const oldFullSubject = structuredClone(fullSubject);
|
||||
@@ -95,7 +101,11 @@ export const executeRedisDeductionV2 = async ({
|
||||
let allUpdates: Record<string, DeductionUpdate> = {};
|
||||
let allRolloverUpdates: Record<string, RolloverUpdate> = {};
|
||||
let allMutationLogs: MutationLogItem[] = [];
|
||||
let allUsageWindowMutations: UsageWindowMutation[] = [];
|
||||
const allModifiedCusEntIdsByFeatureId: Record<string, string[]> = {};
|
||||
// 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<string, UsageWindowUpdate> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>;
|
||||
entityId?: string;
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
// 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<string, UsageWindowUpdate>;
|
||||
}
|
||||
|
||||
interface CustomerBatch {
|
||||
@@ -33,6 +38,7 @@ export type QueueSyncV4Payload = {
|
||||
rolloverIds: string[];
|
||||
entityId?: string;
|
||||
modifiedCusEntIdsByFeatureId: Record<string, string[]>;
|
||||
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<string, string[]>;
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
/** 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<string, EntityBalance> | 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<void> => {
|
||||
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)`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -43,9 +43,12 @@ export type PreparedFeatureDeduction = {
|
||||
customerEntitlementDeductions: CustomerEntitlementDeduction[];
|
||||
spendLimitByFeatureId?: Record<string, DbSpendLimit>;
|
||||
usageBasedCusEntIdsByFeatureId?: Record<string, string[]>;
|
||||
// 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[];
|
||||
|
||||
@@ -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<string, DeductionUpdate>;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<string, DeductionUpdate>;
|
||||
rollover_updates: Record<string, RolloverUpdate>;
|
||||
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<string, UsageWindow[]> | null;
|
||||
/** Per-window deltas applied by this deduction (sibling stream of
|
||||
* mutation_logs). */
|
||||
usage_window_mutations?: UsageWindowMutation[];
|
||||
remaining: number;
|
||||
error?: string;
|
||||
feature_id?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const getRefundLineItemsForPrice = ({
|
||||
ctx,
|
||||
customerProduct,
|
||||
billingContext,
|
||||
includeCatalogFallback: false,
|
||||
});
|
||||
|
||||
const matchedRefundsForPrice = matchedRefundLineItems.filter(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<boolean> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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<void> => {
|
||||
if (rolls.length === 0) return;
|
||||
|
||||
try {
|
||||
const { org, env, redisV2 } = ctx;
|
||||
|
||||
const rollsByFeatureId: Record<string, UsageWindowRoll[]> = {};
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<string, typeof customerEntitlements>();
|
||||
|
||||
@@ -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<string, UsageWindow[]>();
|
||||
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,
|
||||
|
||||
20
server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts
vendored
Normal file
20
server/src/internal/customers/cache/fullSubject/balances/applyLiveUsageWindows.ts
vendored
Normal file
@@ -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 ?? [],
|
||||
);
|
||||
};
|
||||
@@ -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<string, string[]>;
|
||||
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<string>;
|
||||
}): Promise<FeatureBalancesBatchOutcome> => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
/** 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: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
type ApiCusProcessors,
|
||||
type Customer,
|
||||
customerProductHasActiveStatus,
|
||||
filterCustomerProductsByProcessorType,
|
||||
type FullCusProduct,
|
||||
filterCustomerProductsByProcessorType,
|
||||
ProcessorType,
|
||||
} from "@autumn/shared";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, AggregatedSubjectFlag> =
|
||||
{};
|
||||
const aggregatedSubjectFlagByFeatureId: Record<
|
||||
string,
|
||||
AggregatedSubjectFlag
|
||||
> = {};
|
||||
|
||||
if (fullSubject.subjectType === "customer") {
|
||||
for (const aggregatedFeatureBalance of fullSubject.aggregated_customer_entitlements ??
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, DbUsageWindow[]>();
|
||||
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[],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { rollUsageWindows } from "./rollUsageWindows";
|
||||
|
||||
export const usageWindowRepo = {
|
||||
rollWindows: rollUsageWindows,
|
||||
};
|
||||
@@ -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<void> => {
|
||||
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));
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
210
server/tests/_temp/cycle-differential-sweep.ts
Normal file
210
server/tests/_temp/cycle-differential-sweep.ts
Normal file
@@ -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<string, Fns> = {
|
||||
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<string, BillingInterval | EntInterval> = {
|
||||
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`);
|
||||
@@ -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<ReturnType<typeof initScenario>>["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<ApiCustomerV5>(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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<ApiEntityV2>(
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(customerId),
|
||||
autumnV2_3.customers.get<ApiCustomerV5>(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());
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(
|
||||
customerId,
|
||||
skipCache ? { skip_cache: "true" } : undefined,
|
||||
);
|
||||
expectUsageLimitCorrect({ customer, featureId, usage, limit });
|
||||
};
|
||||
@@ -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<ApiEntityV2>(
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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<void> => {
|
||||
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),
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
`),
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
39
server/tests/integration/utils/expectUsageLimitCorrect.ts
Normal file
39
server/tests/integration/utils/expectUsageLimitCorrect.ts
Normal file
@@ -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));
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user