cleaned up usage windows

This commit is contained in:
johnyeo
2026-06-11 15:28:59 +01:00
parent e7fa94e428
commit db7088b4dd
152 changed files with 9203 additions and 2051 deletions

2
ai

Submodule ai updated: 0e52f71fbd...bca809a307

View File

@@ -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(

View File

@@ -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 })

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}`;

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -55,6 +55,7 @@ export const getCheckResponseV2 = async ({
apiSubject: evaluationApiSubject,
feature: featureToUse,
requiredBalance,
originalFeature,
}).allowed
: false;

View File

@@ -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,
});
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
},
});
}

View File

@@ -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,
},
});
}

View File

@@ -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,

View File

@@ -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,
];
};

View File

@@ -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,
};
};

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -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)`,
),
);

View File

@@ -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[];

View File

@@ -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>;

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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";
};

View File

@@ -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) => ({

View File

@@ -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,

View File

@@ -19,6 +19,7 @@ export const getRefundLineItemsForPrice = ({
ctx,
customerProduct,
billingContext,
includeCatalogFallback: false,
});
const matchedRefundsForPrice = matchedRefundLineItems.filter(

View File

@@ -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,
);

View File

@@ -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);
};

View File

@@ -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;
};

View File

@@ -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;
}
};

View File

@@ -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}`,
);
}
};

View File

@@ -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)

View File

@@ -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 }),

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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: () =>

View File

@@ -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 });
};

View File

@@ -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];

View File

@@ -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,

View 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 ?? [],
);
};

View File

@@ -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,
});
}

View File

@@ -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);

View File

@@ -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";

View File

@@ -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: [],
};
};

View File

@@ -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)) {

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -2,8 +2,8 @@ import {
type ApiCusProcessors,
type Customer,
customerProductHasActiveStatus,
filterCustomerProductsByProcessorType,
type FullCusProduct,
filterCustomerProductsByProcessorType,
ProcessorType,
} from "@autumn/shared";

View File

@@ -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 {

View File

@@ -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 ??

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -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 }),

View File

@@ -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,

View File

@@ -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[],

View File

@@ -0,0 +1,5 @@
import { rollUsageWindows } from "./rollUsageWindows";
export const usageWindowRepo = {
rollWindows: rollUsageWindows,
};

View File

@@ -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));
}
};

View File

@@ -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),

View File

@@ -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,
},

View File

@@ -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,

View 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`);

View File

@@ -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);
},
);

View File

@@ -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);
},
);

View File

@@ -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);
},
);

View File

@@ -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,
});
},
);

View File

@@ -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,
});
},
);

View File

@@ -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,
},
],
},
}),
});
},
);

View File

@@ -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),
);
},
);

View File

@@ -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());
},
);

View File

@@ -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);
},
);

View File

@@ -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,
});
},
);

View File

@@ -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,
});
},
);

View File

@@ -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,
}),
});
},
);

View File

@@ -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);
},
);

View File

@@ -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,
});
},
);

View File

@@ -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,
});
},
);

View File

@@ -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);
},
);

View File

@@ -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,
});
},
);

View File

@@ -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());
},
);

View File

@@ -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);
},
);

View File

@@ -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);
},
);

View File

@@ -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 });
};

View File

@@ -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);
}
};

View File

@@ -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),
);
};

View File

@@ -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
`),
);

View File

@@ -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,
});

View 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