writing tests

This commit is contained in:
John Yeo
2025-11-08 19:31:38 +00:00
parent 277fd8d6e8
commit 6517245c51
232 changed files with 5924 additions and 13704 deletions

View File

@@ -14,23 +14,26 @@ fi
# Run tests using TypeScript runner with compact mode
# Adjust --max to control concurrency (default: 6)
# BUN_PARALLEL_COMPACT \
# 'server/tests/balances/track/concurrency' \
# 'server/tests/balances/track/basic' \
# 'server/tests/balances/track/credit-systems' \
# 'server/tests/balances/track/legacy' \
# 'server/tests/balances/check/basic' \
# 'server/tests/balances/check/credit-systems' \
# 'server/tests/balances/check/misc' \
BUN_PARALLEL_COMPACT \
'server/tests/attach/basic' \
'server/tests/attach/entities' \
'server/tests/attach/upgrade' \
'server/tests/attach/downgrade' \
'server/tests/attach/free' \
'server/tests/attach/addOn' \
'server/tests/attach/entities' \
'server/tests/attach/checkout' \
'server/tests/attach/misc' \
--max=6 \
'server/tests/balances/track/basic' \
'server/tests/balances/track/concurrency' \
'server/tests/balances/track/allocated' \
'server/tests/balances/track/credit-systems' \
'server/tests/balances/track/entity-balances' \
'server/tests/balances/track/entity-products' \
'server/tests/balances/track/legacy' \
'server/tests/balances/check/basic' \
'server/tests/balances/check/credit-systems' \
'server/tests/balances/check/misc' \
# BUN_PARALLEL_COMPACT \
# 'server/tests/attach/basic' \
# 'server/tests/attach/entities' \
# 'server/tests/attach/upgrade' \
# 'server/tests/attach/downgrade' \
# 'server/tests/attach/free' \
# 'server/tests/attach/addOn' \
# 'server/tests/attach/entities' \
# 'server/tests/attach/checkout' \
# 'server/tests/attach/misc' \
# --max=6 \

View File

@@ -12,15 +12,17 @@ if [[ "$1" == *"setup"* ]]; then
BUN_SETUP
fi
BUN_PARALLEL_COMPACT \
'server/tests/attach/migrations' \
'server/tests/attach/others' \
# 'server/tests/attach/newVersion' \
# 'server/tests/attach/upgradeOld' \
# 'server/tests/attach/updateEnts' \
# 'server/tests/advanced/check' \
# 'server/tests/attach/prepaid' \
# 'server/tests/interval/upgrade' \
# 'server/tests/interval/multiSub' \
# --max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/attach/migrations' \
# 'server/tests/attach/others' \
# 'server/tests/attach/newVersion' \
# 'server/tests/attach/upgradeOld' \
# 'server/tests/attach/updateEnts' \
# 'server/tests/advanced/check' \
# 'server/tests/attach/prepaid' \
# 'server/tests/interval/upgrade' \
# 'server/tests/interval/multiSub' \
# --max=6

View File

@@ -13,9 +13,9 @@ if [[ "$1" == *"setup"* ]]; then
fi
BUN_PARALLEL_COMPACT \
'server/tests/contUse/entities' \
'server/tests/contUse/update' \
'server/tests/contUse/track' \
'server/tests/contUse/roles' \
'server/tests/contUse/update' \
'server/tests/contUse/entities' \
--max=6

View File

@@ -13,17 +13,21 @@ if [[ "$1" == *"setup"* ]]; then
fi
BUN_PARALLEL_COMPACT \
'server/tests/merged/group' \
'server/tests/merged/add' \
'server/tests/merged/downgrade' \
'server/tests/merged/prepaid' \
'server/tests/merged/separate' \
'server/tests/merged/downgrade' \
'server/tests/merged/add' \
'server/tests/merged/group' \
'server/tests/merged/prepaid' \
'server/tests/merged/upgrade' \
'server/tests/merged/trial' \
'server/tests/merged/addOn' \
'server/tests/merged/trial' \
'server/tests/core/cancel' \
'server/tests/core/multiAttach' \
'server/tests/core/multiAttach/multiInvoice' \
'server/tests/core/multiAttach/multiUpgrade' \
--max=6
# deprecated tests(?)
# 'server/tests/core/multiAttach' \
# 'server/tests/core/multiAttach/multiInvoice' \
# 'server/tests/core/multiAttach/multiUpgrade' \
# 'sever/tests/core/multiAttach/multiReward'

View File

@@ -15,12 +15,23 @@ fi
# Note: advanced/multiFeature, advanced/rollovers, advanced/customInterval,
# advanced/usageLimit still use Mocha (not migrated yet)
BUN_PARALLEL_COMPACT \
'server/tests/advanced/coupons' \
'server/tests/advanced/misc' \
'server/tests/attach/updateQuantity' \
'server/tests/advanced/referrals' \
'server/tests/advanced/referrals/paid' \
'server/tests/attach/multiProduct' \
'server/tests/advanced/usage' \
'server/tests/advanced/multiFeature' \
'server/tests/advanced/referrals' \
'server/tests/advanced/rollovers' \
'server/tests/advanced/customInterval' \
'server/tests/advanced/usageLimit' \
--max=6
# BUN_PARALLEL_COMPACT \
# 'server/tests/advanced/usage'
# 'server/tests/advanced/referrals/paid' \

View File

@@ -1,8 +1,8 @@
import { AppEnv } from "@autumn/shared";
import { globalBatchingManager } from "../src/internal/balances/track/redisTrackUtils/BatchingManager.js";
import {
buildCachedApiCustomerKey,
getCachedApiCustomer,
buildCachedApiCustomerKey,
getCachedApiCustomer,
} from "../src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js";
import { initDrizzle } from "../src/db/initDrizzle.js";
import { initScript } from "../src/utils/scriptUtils/scriptUtils.js";
@@ -12,7 +12,7 @@ const DEDUCTION_COUNT = 100_000;
const DEDUCTION_AMOUNT = 1;
const logCredits = (label: string, customer: Awaited<ReturnType<typeof getCachedApiCustomer>>) => {
const credits = customer?.features?.credits;
const credits = customer?.apiCustomer?.features?.credits;
console.log(`\n${label}`);
console.log(` Total Balance: ${credits?.balance ?? "N/A"}`);
console.log(` Monthly Credits: ${credits?.breakdown?.[0]?.balance ?? "N/A"}`);

View File

@@ -1,10 +1,11 @@
-- getCustomer.lua
-- Atomically retrieves a customer object from Redis, reconstructing from base JSON and feature HSETs
-- Merges master customer features with entity features
-- Merges master customer features with entity features (unless skipEntityMerge is true)
-- KEYS[1]: cache key (e.g., "org_id:env:customer:customer_id")
-- ARGV[1]: org_id (for building entity cache keys)
-- ARGV[2]: env (for building entity cache keys)
-- ARGV[3]: customer_id (for building entity cache keys)
-- ARGV[4]: skipEntityMerge (optional, "true" to skip merging with entities)
-- Helper function to merge products array by product ID and normalized status
-- Groups products by key (product_id:normalized_status) and merges quantities
@@ -96,9 +97,20 @@ local cacheKey = KEYS[1]
local orgId = ARGV[1]
local env = ARGV[2]
local customerId = ARGV[3]
local skipEntityMerge = ARGV[4] == "true"
-- Load features based on merge mode
-- If skipEntityMerge is true, only load customer's own features (no entity merging)
-- If skipEntityMerge is false, load merged features (customer + entities)
local features
if skipEntityMerge then
-- Load only customer's own features without entity merging
features = loadCusFeatures(cacheKey, orgId, env, customerId, "__CUSTOMER_ONLY__")
else
-- Load merged features (customer + entities)
features = loadCusFeatures(cacheKey, orgId, env, customerId)
end
-- Use loadCusFeatures to get merged features (customer + entities)
local features = loadCusFeatures(cacheKey, orgId, env, customerId)
if not features then
return nil -- Customer not in cache or partial eviction detected
end

View File

@@ -0,0 +1,505 @@
-- loadCusFeatures.lua
-- Shared function to load customer features with merged balances (customer + entities)
-- Returns: { [featureId] = { balance, usage, unlimited, ... } } or nil if not in cache
-- Helper function to safely convert values to numbers for arithmetic
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Helper function to parse HGETALL result into feature data object
local function parseFeatureHash(featureHash)
local featureData = {}
for i = 1, #featureHash, 2 do
local key = featureHash[i]
local value = featureHash[i + 1]
-- Check for null first before parsing
if value == "null" then
featureData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
featureData[key] = tonumber(value)
elseif key == "unlimited" or key == "overage_allowed" then
featureData[key] = (value == "true")
elseif key == "credit_schema" then
-- Parse credit_schema JSON array
if value ~= "" then
featureData[key] = cjson.decode(value)
else
featureData[key] = cjson.null
end
else
featureData[key] = value
end
end
return featureData
end
-- Helper function to fetch and parse rollover items
-- Returns: array of rollover data objects, or nil if any key is missing (partial eviction)
local function fetchRollovers(baseKey, rolloverCount)
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = baseKey .. ":rollover:" .. i
local rolloverHash = redis.call("HGETALL", rolloverKey)
-- If rollover key is missing, return nil (partial eviction detected)
if #rolloverHash == 0 then
return nil
end
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
return rollovers
end
-- Helper function to fetch and parse breakdown items
-- Returns: array of breakdown data objects, or nil if any key is missing (partial eviction)
local function fetchBreakdown(baseKey, breakdownCount)
local breakdown = {}
for i = 0, breakdownCount - 1 do
local breakdownKey = baseKey .. ":breakdown:" .. i
local breakdownHash = redis.call("HGETALL", breakdownKey)
-- If breakdown key is missing, return nil (partial eviction detected)
if #breakdownHash == 0 then
return nil
end
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
breakdownData[key] = tonumber(value)
elseif key == "overage_allowed" then
breakdownData[key] = (value == "true")
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
return breakdown
end
-- Helper function to merge source feature balances into target feature
-- Mutates targetFeature by adding sourceFeature's balances, usage, breakdowns, and rollovers
-- Also handles minimum next_reset_at (earliest reset time)
local function mergeFeatureBalances(targetFeature, sourceFeature)
if not sourceFeature then return end
-- Merge top-level balance and usage
targetFeature.balance = toNum(targetFeature.balance) + toNum(sourceFeature.balance)
targetFeature.usage = toNum(targetFeature.usage) + toNum(sourceFeature.usage)
targetFeature.included_usage = toNum(targetFeature.included_usage) + toNum(sourceFeature.included_usage)
targetFeature.usage_limit = toNum(targetFeature.usage_limit) + toNum(sourceFeature.usage_limit)
-- Use minimum next_reset_at (earliest reset time)
if type(sourceFeature.next_reset_at) == "number" then
if type(targetFeature.next_reset_at) == "number" then
if sourceFeature.next_reset_at < targetFeature.next_reset_at then
targetFeature.next_reset_at = sourceFeature.next_reset_at
end
else
targetFeature.next_reset_at = sourceFeature.next_reset_at
end
end
-- Merge breakdown balances and usage
if targetFeature.breakdown and sourceFeature.breakdowns then
for i, targetBreakdown in ipairs(targetFeature.breakdown) do
local sourceBreakdown = sourceFeature.breakdowns[i]
if sourceBreakdown then
targetBreakdown.balance = toNum(targetBreakdown.balance) + toNum(sourceBreakdown.balance)
targetBreakdown.usage = toNum(targetBreakdown.usage) + toNum(sourceBreakdown.usage)
targetBreakdown.included_usage = toNum(targetBreakdown.included_usage) + toNum(sourceBreakdown.included_usage)
targetBreakdown.usage_limit = toNum(targetBreakdown.usage_limit) + toNum(sourceBreakdown.usage_limit)
-- Use minimum next_reset_at for breakdown
if type(sourceBreakdown.next_reset_at) == "number" then
if type(targetBreakdown.next_reset_at) == "number" then
if sourceBreakdown.next_reset_at < targetBreakdown.next_reset_at then
targetBreakdown.next_reset_at = sourceBreakdown.next_reset_at
end
else
targetBreakdown.next_reset_at = sourceBreakdown.next_reset_at
end
end
end
end
end
-- Merge rollover balances
if targetFeature.rollovers and sourceFeature.rollovers then
for i, targetRollover in ipairs(targetFeature.rollovers) do
local sourceRollover = sourceFeature.rollovers[i]
if sourceRollover then
targetRollover.balance = toNum(targetRollover.balance) + toNum(sourceRollover.balance)
end
end
end
end
-- Load entity-level features (entity + customer merged)
-- Used for entity-level sync mode
-- Parameters: cacheKey (customer cache key), orgId, env, customerId, entityId
-- Returns: merged features table (entity + customer) or nil
local function loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
-- Build entity cache key
local entityCacheKey = "{" .. orgId .. "}:" .. env .. ":customer:" .. customerId .. ":entity:" .. entityId
-- Get entity base JSON
local entityBaseJson = redis.call("GET", entityCacheKey)
if not entityBaseJson then
return nil
end
local entityBase = cjson.decode(entityBaseJson)
local entityFeatureIds = entityBase._featureIds or {}
-- Load entity features
local entityFeatures = {}
for _, featureId in ipairs(entityFeatureIds) do
local featureKey = entityCacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
-- If feature key is missing, return nil (partial eviction detected)
if #featureHash == 0 then
return nil
end
-- Parse feature hash using helper function
local featureData = parseFeatureHash(featureHash)
-- Fetch rollovers using helper function
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil
local rollovers = fetchRollovers(featureKey, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil
local breakdown = fetchBreakdown(featureKey, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
entityFeatures[featureId] = featureData
end
-- Load customer features (raw, no entity aggregation)
local customerCacheKey = cacheKey
local customerBaseJson = redis.call("GET", customerCacheKey)
local customerFeatures = {}
if customerBaseJson then
local customerBase = cjson.decode(customerBaseJson)
local customerFeatureIds = customerBase._featureIds or {}
for _, featureId in ipairs(customerFeatureIds) do
local featureKey = customerCacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
if #featureHash > 0 then
-- Parse feature hash using helper function
local featureData = parseFeatureHash(featureHash)
-- Fetch rollovers
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil
local rollovers = fetchRollovers(featureKey, rolloverCount) or {}
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil
local breakdown = fetchBreakdown(featureKey, breakdownCount) or {}
if #breakdown > 0 then
featureData.breakdown = breakdown
end
customerFeatures[featureId] = featureData
end
end
end
-- Merge customer and entity features (entity + customer)
local mergedFeatures = {}
-- First, add all customer features (inherited)
for featureId, customerFeature in pairs(customerFeatures) do
mergedFeatures[featureId] = customerFeature
end
-- Then, merge or add entity features
for featureId, entityFeature in pairs(entityFeatures) do
local customerFeature = customerFeatures[featureId]
if customerFeature then
-- Both customer and entity have this feature - merge balances
if not entityFeature.unlimited and not customerFeature.unlimited then
mergeFeatureBalances(entityFeature, customerFeature)
end
mergedFeatures[featureId] = entityFeature
else
-- Only entity has this feature - use entity's feature
mergedFeatures[featureId] = entityFeature
end
end
return mergedFeatures
end
-- Load customer features with merged entity balances
-- Parameters: cacheKey, orgId, env, customerId, entityId (optional)
-- If entityId is "__CUSTOMER_ONLY__": returns ONLY customer features (no merging)
-- If entityId is provided (string): returns entity-level merged features (entity + customer)
-- If entityId is nil: returns customer-level merged features (customer + all entities)
-- Returns: merged features table or nil
local function loadCusFeatures(cacheKey, orgId, env, customerId, entityId)
-- Special case: Customer-only mode (no entity merging)
if entityId == "__CUSTOMER_ONLY__" then
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local base = cjson.decode(baseJson)
local featureIds = base._featureIds or {}
-- Load only customer's own features without entity merging
local customerFeatures = {}
for _, featureId in ipairs(featureIds) do
local featureKey = cacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
if #featureHash == 0 then
return nil -- Partial eviction detected
end
-- Parse feature hash
local featureData = parseFeatureHash(featureHash)
featureData.id = featureId
-- Fetch rollovers
local rollovers = fetchRollovers(featureKey, featureData._rollover_count or 0)
if rollovers == nil then
return nil -- Partial eviction
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown
local breakdown = fetchBreakdown(featureKey, featureData._breakdown_count or 0)
if breakdown == nil then
return nil -- Partial eviction
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
-- Remove metadata fields
featureData._breakdown_count = nil
featureData._rollover_count = nil
customerFeatures[featureId] = featureData
end
return customerFeatures
end
-- If entityId is provided, load entity-level features (entity + customer merged)
if entityId then
return loadEntityLevelFeatures(cacheKey, orgId, env, customerId, entityId)
end
-- Otherwise, load customer-level features (customer + all entities merged)
-- Get base customer JSON
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local featureIds = baseCustomer._featureIds or {}
local entityIds = baseCustomer._entityIds or {}
-- Build features object
local features = {}
for _, featureId in ipairs(featureIds) do
local featureKey = cacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
-- If feature key is missing, return nil (partial eviction detected)
if #featureHash == 0 then
return nil
end
-- Parse feature hash using helper function
local featureData = parseFeatureHash(featureHash)
-- Fetch rollovers using helper function
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil -- Remove from final output
local rollovers = fetchRollovers(featureKey, rolloverCount)
if rollovers == nil then
return nil -- Partial eviction detected
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Fetch breakdown using helper function
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil -- Remove from final output
local breakdown = fetchBreakdown(featureKey, breakdownCount)
if breakdown == nil then
return nil -- Partial eviction detected
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
features[featureId] = featureData
end
-- ============================================================================
-- FETCH AND MERGE ENTITY FEATURES
-- ============================================================================
-- Fetch all entity features and aggregate balances
local entityFeatureData = {} -- {[entityId][featureId] = featureData}
local entityBaseData = {} -- {[entityId] = entityBase} - Store entity base for product access
for _, entityId in ipairs(entityIds) do
local entityCacheKey = "{" .. orgId .. "}:" .. env .. ":customer:" .. customerId .. ":entity:" .. entityId
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
local entityBase = cjson.decode(entityBaseJson)
entityBaseData[entityId] = entityBase -- Store entity base for product access
local entityFeatureIds = entityBase._featureIds or {}
entityFeatureData[entityId] = {}
for _, featureId in ipairs(entityFeatureIds) do
local entityFeatureKey = entityCacheKey .. ":features:" .. featureId
local entityFeatureHash = redis.call("HGETALL", entityFeatureKey)
if #entityFeatureHash > 0 then
-- Parse entity feature using helper function
local entityFeature = parseFeatureHash(entityFeatureHash)
-- Fetch breakdown items for this entity feature using helper function
local breakdownCount = entityFeature._breakdown_count or 0
entityFeature._breakdown_count = nil
entityFeature.breakdowns = fetchBreakdown(entityFeatureKey, breakdownCount) or {}
-- Fetch rollover items for this entity feature using helper function
local rolloverCount = entityFeature._rollover_count or 0
entityFeature._rollover_count = nil
entityFeature.rollovers = fetchRollovers(entityFeatureKey, rolloverCount) or {}
entityFeatureData[entityId][featureId] = entityFeature
end
end
end
end
-- ============================================================================
-- MERGE ENTITY BALANCES INTO CUSTOMER FEATURES
-- ============================================================================
for featureId, customerFeature in pairs(features) do
-- Skip if unlimited
if not customerFeature.unlimited then
-- Merge each entity's feature balances into customer feature
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature then
mergeFeatureBalances(customerFeature, entityFeature)
end
end
end
end
-- Add entity-only features (features that exist in entities but not in customer)
for entityId, entityFeatures in pairs(entityFeatureData) do
for featureId, entityFeature in pairs(entityFeatures) do
if not features[featureId] then
-- This feature doesn't exist in customer, add it with zero values
features[featureId] = {
id = entityFeature.id,
type = entityFeature.type,
name = entityFeature.name,
interval = entityFeature.interval,
interval_count = entityFeature.interval_count,
unlimited = entityFeature.unlimited,
balance = 0,
usage = 0,
included_usage = 0,
next_reset_at = cjson.null,
overage_allowed = entityFeature.overage_allowed,
usage_limit = 0,
credit_schema = entityFeature.credit_schema
}
end
end
end
-- Aggregate balances for entity-only features using mergeFeatureBalances
for featureId, customerFeature in pairs(features) do
-- Only process if this was an entity-only feature (balance is still 0 from initialization)
if customerFeature.balance == 0 and customerFeature.usage == 0 then
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature then
mergeFeatureBalances(customerFeature, entityFeature)
end
end
end
end
-- Return merged features
return features
end

View File

@@ -66,6 +66,12 @@ end
-- Global delta accumulator: { [redisKey][field] = delta }
local keyDeltas = {}
-- Track which entities were modified (set: { [entityId] = true })
local changedEntityIds = {}
-- Track if customer (base customer, not entity) was modified
local customerChanged = false
-- ============================================================================
-- HELPER FUNCTIONS
-- ============================================================================
@@ -284,16 +290,33 @@ local function deductFromMainBalance(cusFeature, amount)
local allowOverage = breakdown.overage_allowed or cusFeature.type == "continuous_use"
if allowOverage then
local currentUsage = breakdown.usage or 0
-- Get current balance AFTER deducting from breakdown balance
local currentBalance = breakdown.balance or 0
-- Apply state changes to get updated balance
for _, change in ipairs(stateChanges) do
if change.type == "breakdown" and change.index == index and change.field == "balance" then
if change.newValue then
currentBalance = change.newValue
elseif change.delta then
currentBalance = currentBalance + change.delta
end
end
end
local toDeduct = remaining
-- If usage_limit is defined, cap the overage
if breakdown.usage_limit then
local availableOverage = breakdown.usage_limit - currentUsage
if availableOverage > 0 then
toDeduct = math.min(remaining, availableOverage)
else
toDeduct = 0
local breakdownIncludedUsage = breakdown.included_usage or cusFeature.included_usage or 0
local minNegativeBalance = breakdownIncludedUsage - breakdown.usage_limit
-- If min_negative_balance is 0 or positive, skip limit check
if minNegativeBalance < 0 then
local availableOverage = currentBalance - minNegativeBalance
if availableOverage > 0 then
toDeduct = math.min(remaining, availableOverage)
else
toDeduct = 0
end
end
end
@@ -364,16 +387,33 @@ local function deductFromMainBalance(cusFeature, amount)
local allowOverage = cusFeature.overage_allowed or cusFeature.type == "continuous_use"
if remaining > 0 and allowOverage then
local currentUsage = cusFeature.usage or 0
-- Get current balance AFTER deducting from main balance
local currentBalance = cusFeature.balance or 0
-- Apply state changes to get updated balance
for _, change in ipairs(stateChanges) do
if change.type == "cusFeature" and change.field == "balance" then
if change.newValue then
currentBalance = change.newValue
elseif change.delta then
currentBalance = currentBalance + change.delta
end
end
end
local toDeduct = remaining
-- If usage_limit is defined, cap the overage
if cusFeature.usage_limit then
local availableOverage = cusFeature.usage_limit - currentUsage
if availableOverage > 0 then
toDeduct = math.min(remaining, availableOverage)
else
toDeduct = 0
local includedUsage = cusFeature.included_usage or 0
local minNegativeBalance = includedUsage - cusFeature.usage_limit
-- If min_negative_balance is 0 or positive, skip limit check
if minNegativeBalance < 0 then
local availableOverage = currentBalance - minNegativeBalance
if availableOverage > 0 then
toDeduct = math.min(remaining, availableOverage)
else
toDeduct = 0
end
end
end
@@ -613,13 +653,14 @@ end
-- Helper: Calculate sync deltas for sync mode requests
-- In sync mode, we want to adjust cache to match the target balance from Postgres
-- This requires loading the MERGED balance (customer + all entities) to calculate the correct delta
local function calculateSyncDeltas(featureDeductions, targetBalance)
-- Load merged customer features (customer + entities) to get accurate current balance
local mergedFeatures = loadCusFeatures(cacheKey, orgId, env, customerId)
-- If entityId is provided, loads entity-level features (entity + customer)
-- If entityId is nil, loads customer-level features (customer + all entities)
local function calculateSyncDeltas(featureDeductions, targetBalance, entityId)
-- Load merged features based on perspective (entity-level or customer-level)
local mergedFeatures = loadCusFeatures(cacheKey, orgId, env, customerId, entityId)
if not mergedFeatures then
return -- Customer not in cache, no-op
return -- Customer/entity not in cache, no-op
end
for _, featureDeduction in ipairs(featureDeductions) do
@@ -627,7 +668,7 @@ local function calculateSyncDeltas(featureDeductions, targetBalance)
local mergedFeature = mergedFeatures[featureId]
if mergedFeature and not mergedFeature.unlimited then
-- Get current MERGED balance (includes entities)
-- Get current MERGED balance (from entity-level or customer-level perspective)
local currentBalance = mergedFeature.balance or 0
-- Calculate delta (positive means deduct, negative means refund)
@@ -688,7 +729,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
-- SYNC MODE: Calculate delta to bring cache to target balance
-- Note: syncMode requests should only have ONE feature deduction
if syncMode and targetBalance then
calculateSyncDeltas(featureDeductions, targetBalance)
calculateSyncDeltas(featureDeductions, targetBalance, entityId)
end
-- Try to deduct from all features (primary + credit systems)
@@ -880,6 +921,13 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates)
for _, stateChange in ipairs(requestStateChanges) do
applyStateChanges(stateChange.cusFeature, stateChange.changes)
-- Track which scopes were modified (customer vs entity)
if stateChange.target == "customer" then
customerChanged = true
elseif stateChange.target == "entity" and stateChange.entityId then
changedEntityIds[stateChange.entityId] = true
end
end
return {
@@ -1035,10 +1083,18 @@ for key, deltas in pairs(keyDeltas) do
end
end
-- Return results
-- Convert changedEntityIds set to array
local changedEntityIdsArray = {}
for entityId, _ in pairs(changedEntityIds) do
table.insert(changedEntityIdsArray, entityId)
end
-- Return results with changed scopes
return cjson.encode({
success = true,
results = results
results = results,
customerChanged = customerChanged,
changedEntityIds = changedEntityIdsArray
})

View File

@@ -0,0 +1,405 @@
-- getEntity.lua
-- Atomically retrieves an entity object from Redis, reconstructing from base JSON and feature HSETs
-- Merges entity features with customer features (unless skipCustomerMerge is true)
-- KEYS[1]: cache key (e.g., "{org_id}:env:customer:customer_id:entity:entity_id")
-- ARGV[1]: org_id (for building customer cache keys)
-- ARGV[2]: env (for building customer cache keys)
-- ARGV[3]: customerId
-- ARGV[4]: entityId
-- ARGV[5]: skipCustomerMerge (optional, "true" to skip merging with customer)
-- Helper function to safely convert values to numbers for arithmetic
-- Returns the value if it's a number, otherwise returns 0
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Helper function to get product key for grouping (product_id:normalized_status)
local function getProductKey(product)
local status = product.status
-- Normalize status: "active" or "past_due" -> "active", otherwise use actual status
if status == "active" or status == "past_due" then
status = "active"
end
return product.id .. ":" .. status
end
-- Helper function to merge customer products into entity products
-- Adds customer products that don't already exist in entity products (by product key)
local function mergeCustomerProductsIntoEntity(entityProducts, customerProducts)
if not customerProducts or #customerProducts == 0 then
return entityProducts or {}
end
if not entityProducts then
entityProducts = {}
end
-- Build a set of existing product keys in entity products
local existingKeys = {}
for _, product in ipairs(entityProducts) do
local key = getProductKey(product)
existingKeys[key] = true
end
-- Add customer products that don't exist in entity products
local mergedProducts = {}
-- First, add all entity products
for _, product in ipairs(entityProducts) do
table.insert(mergedProducts, product)
end
-- Then, add customer products that don't exist
for _, customerProduct in ipairs(customerProducts) do
local key = getProductKey(customerProduct)
if not existingKeys[key] then
table.insert(mergedProducts, customerProduct)
end
end
return mergedProducts
end
local cacheKey = KEYS[1]
local baseKey = cacheKey
local orgId = ARGV[1]
local env = ARGV[2]
local customerId = ARGV[3]
local entityId = ARGV[4]
local skipCustomerMerge = ARGV[5] == "true"
-- Get base entity JSON
local baseJson = redis.call("GET", baseKey)
if not baseJson then
return nil
end
local baseEntity = cjson.decode(baseJson)
local entityFeatureIds = baseEntity._featureIds or {}
-- ============================================================================
-- FETCH ENTITY FEATURES
-- ============================================================================
local entityFeatures = {}
for _, featureId in ipairs(entityFeatureIds) do
local featureKey = cacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
-- If feature key is missing, return nil (partial eviction detected)
if #featureHash == 0 then
return nil
end
-- Convert HGETALL result (flat array) to table
local featureData = {}
for i = 1, #featureHash, 2 do
local key = featureHash[i]
local value = featureHash[i + 1]
-- Check for null first before parsing
if value == "null" then
featureData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
featureData[key] = tonumber(value)
elseif key == "unlimited" or key == "overage_allowed" then
featureData[key] = (value == "true")
elseif key == "credit_schema" then
-- Parse credit_schema JSON array
if value ~= "" then
featureData[key] = cjson.decode(value)
else
featureData[key] = cjson.null
end
else
featureData[key] = value
end
end
-- Get rollover count
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil -- Remove from final output
-- Fetch rollover items
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. i
local rolloverHash = redis.call("HGETALL", rolloverKey)
-- If rollover key is missing, return nil (partial eviction detected)
if #rolloverHash == 0 then
return nil
end
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Get breakdown count
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil -- Remove from final output
-- Fetch breakdown items
local breakdown = {}
for i = 0, breakdownCount - 1 do
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. i
local breakdownHash = redis.call("HGETALL", breakdownKey)
-- If breakdown key is missing, return nil (partial eviction detected)
if #breakdownHash == 0 then
return nil
end
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
breakdownData[key] = tonumber(value)
elseif key == "overage_allowed" then
breakdownData[key] = (value == "true")
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
entityFeatures[featureId] = featureData
end
-- ============================================================================
-- FETCH CUSTOMER MASTER FEATURES (no entity aggregation)
-- Skip if skipCustomerMerge is true
-- ============================================================================
local customerFeatures = {}
local customerBase = nil -- Store customer base for product access
if not skipCustomerMerge and customerId then
local customerCacheKey = "{" .. orgId .. "}:" .. env .. ":customer:" .. customerId
local customerBaseJson = redis.call("GET", customerCacheKey)
if customerBaseJson then
customerBase = cjson.decode(customerBaseJson)
local customerFeatureIds = customerBase._featureIds or {}
for _, featureId in ipairs(customerFeatureIds) do
local customerFeatureKey = customerCacheKey .. ":features:" .. featureId
local customerFeatureHash = redis.call("HGETALL", customerFeatureKey)
if #customerFeatureHash > 0 then
-- Parse customer feature
local customerFeature = {}
for i = 1, #customerFeatureHash, 2 do
local key = customerFeatureHash[i]
local value = customerFeatureHash[i + 1]
if value == "null" then
customerFeature[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
customerFeature[key] = tonumber(value)
elseif key == "unlimited" or key == "overage_allowed" then
customerFeature[key] = (value == "true")
elseif key == "credit_schema" then
if value ~= "" then
customerFeature[key] = cjson.decode(value)
else
customerFeature[key] = cjson.null
end
else
customerFeature[key] = value
end
end
-- Fetch rollover items
local rolloverCount = customerFeature._rollover_count or 0
customerFeature._rollover_count = nil
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = customerFeatureKey .. ":rollover:" .. i
local rolloverHash = redis.call("HGETALL", rolloverKey)
if #rolloverHash > 0 then
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
end
if #rollovers > 0 then
customerFeature.rollovers = rollovers
end
-- Fetch breakdown items
local breakdownCount = customerFeature._breakdown_count or 0
customerFeature._breakdown_count = nil
local breakdown = {}
for i = 0, breakdownCount - 1 do
local breakdownKey = customerFeatureKey .. ":breakdown:" .. i
local breakdownHash = redis.call("HGETALL", breakdownKey)
if #breakdownHash > 0 then
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
breakdownData[key] = tonumber(value)
elseif key == "overage_allowed" then
breakdownData[key] = (value == "true")
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
end
if #breakdown > 0 then
customerFeature.breakdown = breakdown
end
customerFeatures[featureId] = customerFeature
end
end
end
end
-- ============================================================================
-- MERGE CUSTOMER AND ENTITY FEATURES
-- ============================================================================
local mergedFeatures = {}
-- First, add all customer features (inherited)
for featureId, customerFeature in pairs(customerFeatures) do
mergedFeatures[featureId] = customerFeature
end
-- Then, merge or add entity features
for featureId, entityFeature in pairs(entityFeatures) do
local customerFeature = customerFeatures[featureId]
if customerFeature then
-- Both customer and entity have this feature - merge balances
if not entityFeature.unlimited and not customerFeature.unlimited then
entityFeature.balance = toNum(entityFeature.balance) + toNum(customerFeature.balance)
entityFeature.usage = toNum(entityFeature.usage) + toNum(customerFeature.usage)
entityFeature.included_usage = toNum(entityFeature.included_usage) + toNum(customerFeature.included_usage)
entityFeature.usage_limit = toNum(entityFeature.usage_limit) + toNum(customerFeature.usage_limit)
-- Use minimum next_reset_at (earliest reset time)
if type(entityFeature.next_reset_at) == "number" and type(customerFeature.next_reset_at) == "number" then
if customerFeature.next_reset_at < entityFeature.next_reset_at then
entityFeature.next_reset_at = customerFeature.next_reset_at
end
elseif type(customerFeature.next_reset_at) == "number" then
entityFeature.next_reset_at = customerFeature.next_reset_at
end
-- Merge breakdown balances
if entityFeature.breakdown and customerFeature.breakdown then
for i, entityBreakdown in ipairs(entityFeature.breakdown) do
local customerBreakdown = customerFeature.breakdown[i]
if customerBreakdown then
entityBreakdown.balance = toNum(entityBreakdown.balance) + toNum(customerBreakdown.balance)
entityBreakdown.usage = toNum(entityBreakdown.usage) + toNum(customerBreakdown.usage)
entityBreakdown.included_usage = toNum(entityBreakdown.included_usage) + toNum(customerBreakdown.included_usage)
entityBreakdown.usage_limit = toNum(entityBreakdown.usage_limit) + toNum(customerBreakdown.usage_limit)
-- Use minimum next_reset_at for breakdown
if type(entityBreakdown.next_reset_at) == "number" and type(customerBreakdown.next_reset_at) == "number" then
if customerBreakdown.next_reset_at < entityBreakdown.next_reset_at then
entityBreakdown.next_reset_at = customerBreakdown.next_reset_at
end
elseif type(customerBreakdown.next_reset_at) == "number" then
entityBreakdown.next_reset_at = customerBreakdown.next_reset_at
end
end
end
end
-- Merge rollover balances
if entityFeature.rollovers and customerFeature.rollovers then
for i, entityRollover in ipairs(entityFeature.rollovers) do
local customerRollover = customerFeature.rollovers[i]
if customerRollover then
entityRollover.balance = toNum(entityRollover.balance) + toNum(customerRollover.balance)
end
end
end
end
mergedFeatures[featureId] = entityFeature
else
-- Only entity has this feature - use entity's feature
mergedFeatures[featureId] = entityFeature
end
end
-- ============================================================================
-- MERGE CUSTOMER PRODUCTS INTO ENTITY PRODUCTS
-- Skip if skipCustomerMerge is true
-- ============================================================================
-- Get entity products (start with entity's own products)
local entityProducts = baseEntity.products or {}
if not skipCustomerMerge then
-- Get customer products if customer base exists
local customerProducts = nil
if customerBase and customerBase.products then
customerProducts = customerBase.products
end
-- Merge customer products into entity products (only add if not exists)
baseEntity.products = mergeCustomerProductsIntoEntity(entityProducts, customerProducts)
else
-- No merging - just use entity's own products
baseEntity.products = entityProducts
end
-- Build final entity object
baseEntity._featureIds = nil -- Remove tracking field
baseEntity.features = mergedFeatures
return cjson.encode(baseEntity)

View File

@@ -0,0 +1,105 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ============================================================================
// SHARED LUA FUNCTIONS
// ============================================================================
// Load shared feature loading function (used by customer, entity, and deduction scripts)
const LOAD_CUS_FEATURES = readFileSync(
join(__dirname, "cusLuaScripts/loadCusFeatures.lua"),
"utf-8",
);
// ============================================================================
// CUSTOMER SCRIPTS
// ============================================================================
// Load shared validation function
const CHECK_CACHE_EXISTS = readFileSync(
join(__dirname, "cusLuaScripts/checkCacheExists.lua"),
"utf-8",
);
// Prepend loadCusFeatures to GET_CUSTOMER_SCRIPT so it can use the function
const getCustomerScript = readFileSync(
join(__dirname, "cusLuaScripts/getCustomer.lua"),
"utf-8",
);
export const GET_CUSTOMER_SCRIPT = `${LOAD_CUS_FEATURES}\n${getCustomerScript}`;
// Prepend validation function to SET_CUSTOMER_SCRIPT
const setCustomerScript = readFileSync(
join(__dirname, "cusLuaScripts/setCustomer.lua"),
"utf-8",
);
export const SET_CUSTOMER_SCRIPT = `${CHECK_CACHE_EXISTS}\n${setCustomerScript}`;
export const SET_CUSTOMER_PRODUCTS_SCRIPT = readFileSync(
join(__dirname, "cusLuaScripts/setCustomerProducts.lua"),
"utf-8",
);
export const SET_CUSTOMER_DETAILS_SCRIPT = readFileSync(
join(__dirname, "cusLuaScripts/setCustomerDetails.lua"),
"utf-8",
);
export const DELETE_CUSTOMER_SCRIPT = readFileSync(
join(__dirname, "cusLuaScripts/deleteCustomer.lua"),
"utf-8",
);
// ============================================================================
// ENTITY SCRIPTS
// ============================================================================
// Load shared validation function
const CHECK_ENTITY_CACHE_EXISTS = readFileSync(
join(__dirname, "entityLuaScripts/checkEntityCacheExists.lua"),
"utf-8",
);
// Prepend loadCusFeatures to GET_ENTITY_SCRIPT so it can use the function
const getEntityScript = readFileSync(
join(__dirname, "entityLuaScripts/getEntity.lua"),
"utf-8",
);
export const GET_ENTITY_SCRIPT = `${LOAD_CUS_FEATURES}\n${getEntityScript}`;
// Prepend validation function to SET_ENTITY_SCRIPT
const setEntityScript = readFileSync(
join(__dirname, "entityLuaScripts/setEntity.lua"),
"utf-8",
);
export const SET_ENTITY_SCRIPT = `${CHECK_ENTITY_CACHE_EXISTS}\n${setEntityScript}`;
export const SET_ENTITIES_BATCH_SCRIPT = readFileSync(
join(__dirname, "entityLuaScripts/setEntitiesBatch.lua"),
"utf-8",
);
export const SET_ENTITY_PRODUCTS_SCRIPT = readFileSync(
join(__dirname, "entityLuaScripts/setEntityProducts.lua"),
"utf-8",
);
// ============================================================================
// DEDUCTION SCRIPTS
// ============================================================================
// Load batchDeduction script
const batchDeduction = readFileSync(
join(__dirname, "deductionLuaScripts/batchDeduction.lua"),
"utf-8",
);
export function getBatchDeductionScript(): string {
return `${LOAD_CUS_FEATURES}\n${batchDeduction}`;
}
export const BATCH_DEDUCTION_SCRIPT = getBatchDeductionScript();

View File

@@ -295,8 +295,27 @@ export class AutumnInt {
return data;
},
create: async (customer: { id: string; email?: string; name?: string }) => {
const data = await this.post(`/customers?with_autumn_id=true`, customer);
create: async ({
id,
email,
name,
withAutumnId = true,
expand = [],
}: {
id: string;
email?: string;
name?: string;
withAutumnId?: boolean;
expand?: CusExpand[];
}) => {
const data = await this.post(
`/customers?with_autumn_id=${withAutumnId ? "true" : "false"}${expand && expand.length > 0 ? `&expand=${expand.join(",")}` : ""}`,
{
id,
email,
name,
},
);
return data;
},
delete: async (

View File

@@ -140,18 +140,17 @@ export const getCheckData = async ({
// });
let apiEntity: ApiCustomer | ApiEntity | undefined;
apiEntity = await getOrCreateApiCustomer({
const { apiCustomer } = await getOrCreateApiCustomer({
ctx,
customerId: customer_id,
withAutumnId: true,
});
apiEntity = apiCustomer;
if (entity_id) {
const { apiEntity: apiEntityResult } = await getCachedApiEntity({
ctx,
customerId: customer_id,
entityId: entity_id,
withAutumnId: false,
});
apiEntity = apiEntityResult;

View File

@@ -8,6 +8,7 @@ import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { RewardService } from "../../../../rewards/RewardService.js";
export default async (req: any, res: any) =>
routeHandler({
@@ -49,19 +50,29 @@ export default async (req: any, res: any) =>
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse(req.body),
const reward = await RewardService.get({
db,
idOrInternalId: body.internal_reward_id,
orgId,
env,
});
// Fetch reward ID
// let reward = await RewardService.get({
// db,
// id: rewardProgram.internal_reward_id,
// orgId,
// env,
// });
if (!reward) {
throw new RecaseError({
message: "Reward not found",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse({
...req.body,
internal_reward_id: reward.internal_id,
}),
orgId,
env,
});
if (
rewardProgram.when === RewardTriggerEvent.Checkout &&

View File

@@ -1,26 +0,0 @@
# Track Implementation Checklist
## Validation
### 0. ✅ Validate Deduction
- If overage_allowed: False → Check feature.balance >= amount
- If overage_allowed: True → Check (usage_limit - usage) >= amount OR no usage_limit
- Two-pass atomic validation: Validate ALL features before ANY deductions (all-or-nothing)
## Deduction Cases
### 1. ✅ Main Balance Deduction
- With breakdowns: Deduct from breakdown balances, then breakdown overage
- Without breakdowns: Deduct from top-level balance, then top-level overage
- Respect overage_behavior ("cap" | "reject")
### 2. ✅ Rollover Balance Deduction
- Deduct from rollovers before main balance
- Update top-level balance and usage
### 3. ⬜ Credit System Deduction
- Deduct from credit features when target feature is insufficient
### 4. ⬜ Entity-Specific Deduction
- Handle entity-scoped deductions

View File

@@ -0,0 +1,168 @@
# Track Implementation Rules
This guide is concise and has no fluff. It prevents future coding agents from making mistakes with the track implementation.
## BatchingManager: Customer vs Entity Batching
**CRITICAL**: Batching must be atomic per customer AND per entity.
### Batch Key Construction
```typescript
// ❌ WRONG: Batches all deductions for a customer together
const batchKey = cacheKey; // customer cache key only
// ✅ CORRECT: Separate batches for customer-level vs each entity
const batchKey = entityId
? buildCachedApiEntityKey({ entityId, customerId, orgId, env })
: buildCachedApiCustomerKey({ customerId, orgId, env });
```
### Why This Matters
- **Customer-level deduction**: Batch under `{orgId}:env:customer:{customerId}`
- **Entity1 deduction**: Batch under `{orgId}:env:customer:{customerId}:entity:{entity1Id}`
- **Entity2 deduction**: Batch under `{orgId}:env:customer:{customerId}:entity:{entity2Id}`
Each batch executes atomically. Mixing customer and entity deductions in one batch breaks atomicity.
### Implementation Details
- `entityId` is stored at the **batch level**, not per-request
- All requests in a batch share the same `entityId` (or all are customer-level)
- The Lua script receives `batch.entityId` for all requests in that batch
- This ensures proper batching by entity and prevents mixed customer/entity batches
### Example
```typescript
// These should create 3 separate batches:
await track({ customer_id: "cus1", feature_id: "messages", value: 10 }); // Batch 1
await track({ customer_id: "cus1", entity_id: "ent1", feature_id: "messages", value: 5 }); // Batch 2
await track({ customer_id: "cus1", entity_id: "ent2", feature_id: "messages", value: 3 }); // Batch 3
```
## Redis vs Postgres Tracking
### single_use features → Redis only
- Deducted via `runRedisDeduction.ts``BatchingManager``batchDeduction.lua`
- **MUST** sync Redis → Postgres (only for changed scopes)
- Uses `globalSyncBatchingManager.addSyncPair()` based on `customerChanged` and `changedEntityIds`
### continuous_use features → Postgres first, then Redis
1. Deduct from Postgres via `runDeductionTx.ts`
2. Get actual deducted amount from SQL result (`actualDeductions`)
3. Deduct same amount from Redis cache via `deductFromCache.ts`
4. Uses direct Lua script call (no batching) to avoid race conditions
### Rule
Never sync in both directions. Single source of truth:
- `single_use` → Redis is source of truth, sync to Postgres for durability
- `continuous_use` → Postgres is source of truth, Redis is cache
## Unmerged Cache Access for Syncing
**CRITICAL**: When syncing from Redis to Postgres, fetch the unmerged balance for that specific scope.
### Problem
The default cache behavior merges balances:
- `getCustomer`: Returns customer + all entities merged
- `getEntity`: Returns entity + customer merged
This is correct for API responses, but WRONG for syncing because:
```typescript
// Customer has 10, Entity1 has 5, Entity2 has 5
// GET /customers/:id returns balance=20 (10+5+5) ✓ correct for API
// But when syncing customer-level, we need ONLY 10 (customer's own balance)
```
### Solution
Use `skipEntityMerge` / `skipCustomerMerge` flags when fetching for sync:
```typescript
// Syncing customer-level
const { apiCustomer } = await getCachedApiCustomer({
ctx,
customerId,
skipEntityMerge: true, // Returns ONLY customer's balance (not merged with entities)
});
// Syncing entity-level
const { apiEntity } = await getCachedApiEntity({
ctx,
customerId,
entityId,
skipCustomerMerge: true, // Returns ONLY entity's balance (not merged with customer)
});
```
### Implementation
- `getCustomer.lua`: Accepts `ARGV[4]` as `skipEntityMerge` flag
- `getEntity.lua`: Accepts `ARGV[5]` as `skipCustomerMerge` flag
- `loadCusFeatures`: Special mode `"__CUSTOMER_ONLY__"` returns unmerged customer features
## Selective Sync: Preventing Unnecessary Syncs
**CRITICAL**: Only sync scopes that were actually modified.
### Problem
If every track queues a sync for customer + all entities, we get unnecessary syncs and potential race conditions:
```typescript
// ❌ WRONG: Always sync everything
track({ customer_id: "cus1", entity_id: "ent1", feature_id: "messages", value: 1 });
// Syncs: cus1, ent1 (but ent1 might not have changed if deduction came from customer balance!)
```
### Solution
`batchDeduction.lua` tracks which scopes were actually modified:
- `customerChanged`: Boolean flag for customer-level changes
- `changedEntityIds`: Array of entity IDs that had balance changes
```typescript
// ✅ CORRECT: Only sync what changed
const result = await deduct(...);
if (result.customerChanged) {
addSyncPair({ customerId, featureId, entityId: undefined });
}
for (const entityId of result.changedEntityIds) {
addSyncPair({ customerId, featureId, entityId });
}
```
### Examples
```typescript
// Customer-level track that deducts from customer balance only
track({ customer_id: "cus1", feature_id: "messages", value: 10 });
// Result: customerChanged=true, changedEntityIds=[]
// Syncs: cus1 only
// Entity-level track that deducts from entity first, then customer
track({ customer_id: "cus1", entity_id: "ent1", feature_id: "messages", value: 10 });
// Result: customerChanged=true, changedEntityIds=["ent1"]
// Syncs: cus1, ent1
// Entity-level track that only deducts from entity (customer has unlimited)
track({ customer_id: "cus1", entity_id: "ent1", feature_id: "messages", value: 10 });
// Result: customerChanged=false, changedEntityIds=["ent1"]
// Syncs: ent1 only
```
## Postgres Deduction Order
`performDeductionV2.sql` processes entitlements in the EXACT order they are passed in the `sorted_entitlements` array. The `jsonb_array_elements()` function preserves array order.
Use `reverseOrder` config to control deduction order:
- `reverseOrder: false` → Oldest entitlements first
- `reverseOrder: true` → Newest entitlements first
## Actual Deductions Tracking
When deducting from Postgres, always track the ACTUAL amount deducted (not the requested amount):
```typescript
// ❌ WRONG: Using requested amount
const requestedAmount = 10;
await deductFromCache({ amount: requestedAmount });
// ✅ CORRECT: Using actual deducted amount from SQL result
const result = await db.execute(sql`...`);
const actualDeducted = result.updates[entId].deducted;
actualDeductions[featureId] = actualDeducted;
await deductFromCache({ amount: actualDeducted });
```

View File

@@ -1,11 +1,13 @@
import {
ApiVersion,
ErrCode,
InsufficientBalanceError,
isContUseFeature,
RecaseError,
SuccessCode,
type TrackParams,
TrackParamsSchema,
type TrackResponse,
} from "@autumn/shared";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
@@ -30,30 +32,40 @@ const executePostgresTracking = async ({
body: TrackParams;
featureDeductions: FeatureDeduction[];
}) => {
const { event } = await runDeductionTx({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
deductions: featureDeductions,
overageBehaviour: body.overage_behavior,
eventInfo: {
event_name: body.feature_id || body.event_name!,
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
refreshCache: true,
});
return {
id: event?.id || "",
const response: TrackResponse = {
id: "",
code: SuccessCode.EventReceived,
customer_id: body.customer_id,
entity_id: body.entity_id,
feature_id: body.feature_id,
event_name: body.event_name,
};
try {
const { event } = await runDeductionTx({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
deductions: featureDeductions,
overageBehaviour: body.overage_behavior,
eventInfo: {
event_name: body.feature_id || body.event_name!,
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
refreshCache: true,
});
response.id = event?.id || "";
} catch (error) {
if (error instanceof InsufficientBalanceError) {
response.code = "insufficient_balance";
} else {
throw error;
}
}
return response;
};
export const handleTrack = createRoute({

View File

@@ -1,127 +0,0 @@
# Batching Architecture
## Overview
The batching system collects multiple track requests for the same customer within a 10ms window and processes them atomically in a single Lua script execution.
## Location
All batching-related files are in `server/src/internal/balances/track/redisTrackUtils/`:
- `batchDeduction.lua` - Lua script (processes batch atomically)
- `BatchingManager.ts` - Collects requests and triggers batch execution
- `executeBatchDeduction.ts` - Executes Lua script
- `luaScripts.ts` - Loads Lua script at module initialization
- `runRedisDeduction.ts` - Entry point from track endpoint
## Data Flow
```
runRedisDeduction
↓ (featureDeductions: [{ featureId, amount }])
globalBatchingManager.deduct
↓ (batches by customerId)
executeBatchDeduction
↓ (single Lua script call)
batchDeduction.lua
↓ (processes all requests, accumulates deltas)
Redis HINCRBYFLOAT (one command per key per field)
```
## New Interface
### globalBatchingManager.deduct()
```typescript
{
customerId: string,
featureDeductions: [
{ featureId: "credits", amount: 10 },
{ featureId: "api_calls", amount: 5 }
],
orgId: string,
env: string,
entityId?: string,
overageBehavior: "cap" | "reject"
}
```
### Batching Key
```
org_id:env:customer:customer_id
```
- Batches by **customer only** (not per-feature)
- All requests for the same customer in a 10ms window are batched together
### Lua Script Input (ARGV[1])
```json
[
{
"featureDeductions": [
{ "featureId": "credits", "amount": 10 },
{ "featureId": "api_calls", "amount": 5 }
],
"overageBehavior": "cap"
},
// ... more requests
]
```
### Lua Script Output
```json
{
"success": true,
"results": [
{ "success": true, "error": null },
{ "success": false, "error": "INSUFFICIENT_BALANCE" }
]
}
```
## Lua Script Structure
### Two Main Functions:
1. **processRequest(request)** - Handles one unit of request
- Takes: `{ featureDeductions: [...], overageBehavior: "cap" }`
- Loops through each feature deduction
- Calculates deltas for each feature
- Uses `addDelta()` to accumulate changes
- Returns: `{ success: boolean, error?: string }`
2. **Top-level loop** - Processes all requests
- Loops through all requests
- Calls `processRequest()` for each
- Applies all accumulated deltas at once with `redis.call("HINCRBYFLOAT", ...)`
## Delta Accumulation Pattern
```lua
-- Global accumulator
local keyDeltas = {} -- { [redisKey][field] = delta }
-- Helper to add deltas
local function addDelta(key, field, delta)
if not keyDeltas[key] then
keyDeltas[key] = {}
end
keyDeltas[key][field] = (keyDeltas[key][field] or 0) + delta
end
-- Process requests (accumulate deltas in memory)
for _, request in ipairs(requests) do
processRequest(request) -- calls addDelta() internally
end
-- Apply all deltas (ONE Redis write per key per field)
for key, deltas in pairs(keyDeltas) do
for field, delta in pairs(deltas) do
redis.call("HINCRBYFLOAT", key, field, delta)
end
end
```
## Performance Benefits
### Scenario: 1000 concurrent requests for same customer
- **Without batching**: 1000 Lua script calls, 6000 Redis writes (3 keys × 2 fields × 1000)
- **With batching**: 1 Lua script call, 6 Redis writes (3 keys × 2 fields)
- **Improvement**: ~1000x reduction in Redis writes! 🚀

View File

@@ -1,5 +1,6 @@
import { redis } from "../../../../external/redis/initRedis.js";
import { buildCachedApiCustomerKey } from "../../../customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js";
import { buildCachedApiEntityKey } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js";
import { executeBatchDeduction } from "./executeBatchDeduction.js";
interface FeatureDeduction {
@@ -7,11 +8,17 @@ interface FeatureDeduction {
amount: number;
}
interface DeductionResult {
success: boolean;
error?: string;
customerChanged?: boolean;
changedEntityIds?: string[];
}
interface BatchRequest {
featureDeductions: FeatureDeduction[];
overageBehavior: "cap" | "reject";
entityId?: string;
resolve: (result: { success: boolean; error?: string }) => void;
resolve: (result: DeductionResult) => void;
reject: (error: Error) => void;
}
@@ -56,13 +63,14 @@ export class BatchingManager {
env: string;
entityId?: string;
overageBehavior?: "cap" | "reject";
}): Promise<{ success: boolean; error?: string }> {
const cacheKey = buildCachedApiCustomerKey({
customerId,
orgId,
env,
});
const batchKey = cacheKey; // Batch by customer only
}): Promise<DeductionResult> {
// CRITICAL: Batch by customer AND entity (if entity-level deduction)
// This ensures entity-level deductions are atomic per entity
// Customer-level: {orgId}:env:customer:{customerId}
// Entity-level: {orgId}:env:customer:{customerId}:entity:{entityId}
const batchKey = entityId
? buildCachedApiEntityKey({ entityId, customerId, orgId, env })
: buildCachedApiCustomerKey({ customerId, orgId, env });
return new Promise((resolve, reject) => {
// Create batch if it doesn't exist
@@ -90,7 +98,6 @@ export class BatchingManager {
batch.requests.push({
featureDeductions,
overageBehavior,
entityId,
resolve,
reject,
});
@@ -135,26 +142,30 @@ export class BatchingManager {
const requests = batch.requests;
const batchSize = requests.length;
// Build cache key from batch context
// Build cache key from batch context (always customer cache key for the Lua script)
const cacheKey = buildCachedApiCustomerKey({
customerId: batch.customerId,
orgId: batch.orgId,
env: batch.env,
});
const batchType = batch.entityId
? `entity ${batch.entityId}`
: "customer-level";
console.log(
`🚀 Executing batch with ${batchSize} requests for customer ${batch.customerId}`,
`🚀 Executing batch with ${batchSize} requests for customer ${batch.customerId} (${batchType})`,
);
try {
// Execute batch Lua script
// All requests in this batch have the same entityId (batch-level)
const result = await executeBatchDeduction({
redis,
cacheKey,
requests: requests.map((r) => ({
featureDeductions: r.featureDeductions,
overageBehavior: r.overageBehavior,
entityId: r.entityId,
entityId: batch.entityId, // Use batch-level entityId (same for all requests)
})),
orgId: batch.orgId,
env: batch.env,
@@ -165,15 +176,15 @@ export class BatchingManager {
// Resolve each request based on its individual result
if (result.success && result.results) {
// TODO: Queue Postgres sync job for successful deductions if needed
// This can be added later when integrating with the sync system
// Match each request with its result
// All requests in this batch get the same customerChanged/changedEntityIds
for (let i = 0; i < requests.length; i++) {
const requestResult = result.results[i];
requests[i].resolve({
success: requestResult?.success || false,
error: requestResult?.error,
customerChanged: result.customerChanged,
changedEntityIds: result.changedEntityIds,
});
}
} else {

View File

@@ -5,25 +5,25 @@ import { buildCachedApiCustomerKey } from "../../../customers/cusUtils/apiCusCac
import { executeBatchDeduction } from "./executeBatchDeduction.js";
/**
* Syncs Redis cache balance to match Postgres balance after a deduction transaction
* Uses sync mode in batchDeduction.lua to calculate delta and apply it
* Deducts from Redis cache to match Postgres deduction
* Called after runDeductionTx to keep cache in sync
*
* Use case: After runDeductionTx completes, sync cache to prevent stale data
* Use case: After Postgres deduction completes, apply same deduction to Redis cache
* - If cache doesn't exist, no-op (lazy population is fine)
* - If cache exists, calculates delta between current cache and target balance
* - Applies delta to bring cache in sync with Postgres
* - If cache exists, deducts the actual amount from Postgres
* - Uses "cap" behavior since Postgres already validated the deduction
*/
export const syncCacheBalance = async ({
export const deductFromCache = async ({
ctx,
customerId,
featureId,
targetBalance,
amount,
entityId,
}: {
ctx: AutumnContext;
customerId: string;
featureId: string;
targetBalance: number;
amount: number;
entityId?: string;
}): Promise<void> => {
const { org, env } = ctx;
@@ -34,7 +34,7 @@ export const syncCacheBalance = async ({
env,
});
// Execute Redis sync call directly (no batching)
// Execute Redis deduction directly (no batching to avoid race conditions)
await tryRedisWrite(async () => {
const result = await executeBatchDeduction({
redis,
@@ -44,12 +44,10 @@ export const syncCacheBalance = async ({
featureDeductions: [
{
featureId,
amount: 0, // Will be calculated in Lua based on targetBalance
amount,
},
],
overageBehavior: "cap",
syncMode: true,
targetBalance,
overageBehavior: "cap", // Cap since Postgres already handled validation
entityId,
},
],
@@ -60,8 +58,11 @@ export const syncCacheBalance = async ({
if (!result.success && result.error !== "CUSTOMER_NOT_FOUND") {
ctx.logger.warn(
`Failed to sync cache balance for ${customerId}, feature ${featureId}: ${result.error}`,
`Failed to deduct from cache for ${customerId}, feature ${featureId}: ${result.error}`,
);
}
});
};
// Keep the old name for backward compatibility
export const syncCacheBalance = deductFromCache;

View File

@@ -1,5 +1,5 @@
import { getBatchDeductionScript } from "@lua/luaScripts.js";
import type { Redis } from "ioredis";
import { getBatchDeductionScript } from "./luaScripts.js";
interface FeatureDeduction {
featureId: string;
@@ -23,6 +23,8 @@ interface BatchDeductionResult {
success: boolean;
results: RequestResult[];
error?: string;
customerChanged?: boolean; // True if customer-level features were modified
changedEntityIds?: string[]; // Array of entity IDs that were modified
debug?: any; // For debugging purposes
}

View File

@@ -1,27 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load shared loadCusFeatures function from customer utils
const loadCusFeatures = readFileSync(
join(
__dirname,
"../../../customers/cusUtils/apiCusCacheUtils/cusLuaScripts/loadCusFeatures.lua",
),
"utf-8",
);
// Load batchDeduction script
const batchDeduction = readFileSync(
join(__dirname, "batchDeduction.lua"),
"utf-8",
);
export function getBatchDeductionScript(): string {
return `${loadCusFeatures}\n${batchDeduction}`;
}
export const BATCH_DEDUCTION_SCRIPT = getBatchDeductionScript();

View File

@@ -47,10 +47,9 @@ export const runRedisDeduction = async ({
const { org, env } = ctx;
// Ensure customer is in cache
const cachedCustomer = await getOrCreateApiCustomer({
const { apiCustomer: cachedCustomer } = await getOrCreateApiCustomer({
ctx,
customerId,
withAutumnId: true,
});
// Map feature deductions to the format expected by batching manager
@@ -82,14 +81,32 @@ export const runRedisDeduction = async ({
// Redis deduction successful: queue sync jobs and event insertion
if (result.success) {
// Only queue sync pairs for scopes that were actually modified
// This prevents unnecessary syncs and race conditions
for (const deduction of featureDeductions) {
globalSyncBatchingManager.addSyncPair({
customerId: customerId,
featureId: deduction.feature.id,
orgId: org.id,
env,
entityId: entityId,
});
// If customer was changed, queue customer-level sync
if (result.customerChanged) {
globalSyncBatchingManager.addSyncPair({
customerId: customerId,
featureId: deduction.feature.id,
orgId: org.id,
env,
entityId: undefined, // Customer-level sync
});
}
// For each changed entity, queue entity-level sync
if (result.changedEntityIds && result.changedEntityIds.length > 0) {
for (const changedEntityId of result.changedEntityIds) {
globalSyncBatchingManager.addSyncPair({
customerId: customerId,
featureId: deduction.feature.id,
orgId: org.id,
env,
entityId: changedEntityId,
});
}
}
}
// Queue event insertion (skip if skip_event is true)

View File

@@ -1,3 +1,4 @@
import type { AppEnv } from "@autumn/shared";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
@@ -5,7 +6,7 @@ interface SyncPairContext {
customerId: string;
featureId: string;
orgId: string;
env: string;
env: AppEnv;
entityId?: string;
timestamp: number;
}
@@ -125,6 +126,8 @@ export class SyncBatchingManager {
await addTaskToQueue({
jobName: JobName.SyncBalanceBatch,
payload: {
orgId: items?.[0]?.orgId,
env: items?.[0]?.env,
items,
},
messageGroupId: customerId,

View File

@@ -1,8 +1,4 @@
import type { AppEnv } from "@autumn/shared";
import type { Logger } from "pino";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createWorkerContext } from "@/queue/createWorkerContext.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { type SyncItem, syncItem } from "./syncItem.js";
interface SyncBatchPayload {
@@ -14,56 +10,33 @@ interface SyncBatchPayload {
* Groups items by org to minimize DB queries and optimize transactions
*/
export const runSyncBalanceBatch = async ({
db,
ctx,
payload,
logger,
}: {
db: DrizzleCli;
ctx?: AutumnContext;
payload: SyncBatchPayload;
logger: Logger;
}) => {
const { items } = payload;
if (!items || items.length === 0) return;
if (!items || !ctx || items.length === 0) return;
const { logger } = ctx;
// All items belong to the same customer (grouped by messageGroupId in SQS)
const firstItem = items[0];
const { orgId, env, customerId } = firstItem;
// Fetch org with features once for all items
const orgData = await OrgService.getWithFeatures({
db,
orgId,
env: env as AppEnv,
});
if (!orgData) {
logger.error(`Organization not found: ${orgId}, env: ${env}`);
return;
}
// Create worker context once
const ctx = createWorkerContext({
db,
org: orgData.org,
env: env as AppEnv,
features: orgData.features,
logger,
});
const { customerId } = firstItem;
// Sort items by timestamp (oldest first) to maintain chronological order
const sortedItems = items.sort((a, b) => a.timestamp - b.timestamp);
// Process each item sequentially for this customer
let successCount = 0;
let errorCount = 0;
for (const item of sortedItems) {
try {
await syncItem({ item, ctx });
successCount++;
} catch (error) {
errorCount++;
logger.error(
`❌ Failed to sync item ${item.customerId}:${item.featureId}: ${error instanceof Error ? error.message : String(error)}`,
);

View File

@@ -1,8 +1,11 @@
import {
type ApiCustomer,
type ApiEntity,
filterEntityLevelCusProducts,
filterOutEntitiesFromCusProducts,
getRelevantFeatures,
} from "@autumn/shared";
import chalk from "chalk";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
@@ -34,19 +37,22 @@ export const syncItem = async ({
const { customerId, featureId, entityId } = item;
const { db, org, env } = ctx;
// Get cached customer from Redis
// Get cached customer/entity from Redis WITHOUT merging
// For sync, we need the raw balance for that specific scope (not merged)
let redisEntity: ApiCustomer | ApiEntity;
if (entityId) {
const { apiEntity } = await getCachedApiEntity({
ctx,
customerId,
entityId,
skipCustomerMerge: true, // Don't merge with customer - we want entity's own balance
});
redisEntity = apiEntity;
} else {
const { apiCustomer } = await getCachedApiCustomer({
ctx,
customerId,
skipEntityMerge: true, // Don't merge with entities - we want customer's own balance
});
redisEntity = apiCustomer;
}
@@ -63,6 +69,18 @@ export const syncItem = async ({
entityId,
});
// If entityId provided, deduct entity level cusEnts
if (entityId) {
fullCus.customer_products = filterEntityLevelCusProducts({
cusProducts: fullCus.customer_products,
});
} else {
// If entityId NOT provided, JUST deduct customer level cusEnts
fullCus.customer_products = filterOutEntitiesFromCusProducts({
cusProducts: fullCus.customer_products,
});
}
const relevantFeatures = getRelevantFeatures({
features: ctx.features,
featureId,
@@ -81,7 +99,7 @@ export const syncItem = async ({
// Sync from Redis to Postgres - deduct using target balance
await deductFromCusEnts({
const result = await deductFromCusEnts({
ctx,
customerId,
entityId,
@@ -94,9 +112,12 @@ export const syncItem = async ({
// console.log(logText);
// ctx.logger.info(logText);
ctx.logger.info(
`[SYNC COMPLETE] customer ${customerId}, feature ${featureId}, target: ${featureDeductions?.[0]?.targetBalance}`,
`[SYNC COMPLETE] (${customerId}${entityId ? `, ${entityId}` : ""}) feature ${featureId}, target: ${chalk.yellow(featureDeductions?.[0]?.targetBalance)}`,
);
ctx.logger.info(`[SYNC COMPLETE] org: ${org.slug}, env: ${env}`);
ctx.logger.info(
`[SYNC COMPLETE], actual deducted: ${chalk.yellow(result.actualDeductions[featureId])}`,
);
if (process.env.NODE_ENV === "production") {
console.log(`synced customer ${customerId}, feature ${featureId}`);
console.log(`org: ${org.slug}, env: ${env}`);

View File

@@ -14,6 +14,7 @@ import {
nullish,
updateCusEntInFullCus,
} from "@autumn/shared";
import chalk from "chalk";
import { sql } from "drizzle-orm";
import type { DrizzleCli } from "../../../../db/initDrizzle.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
@@ -26,6 +27,7 @@ import {
getUnlimitedAndUsageAllowed,
} from "../../../customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCreditCost } from "../../../features/creditSystemUtils.js";
import { isPaidContinuousUse } from "../../../features/featureUtils.js";
import { constructEvent, type EventInfo } from "./eventUtils.js";
import type { FeatureDeduction } from "./getFeatureDeductions.js";
@@ -41,6 +43,10 @@ export type DeductionTxParams = {
refreshCache?: boolean; // Whether to refresh Redis cache after deduction (default: true for track, false for sync)
};
export type ActualDeductions = {
[featureId: string]: number; // Actual amount deducted from Postgres
};
export const deductFromCusEnts = async ({
ctx,
customerId,
@@ -49,7 +55,10 @@ export const deductFromCusEnts = async ({
overageBehaviour = "cap",
addToAdjustment = false,
fullCus,
}: DeductionTxParams) => {
}: DeductionTxParams): Promise<{
fullCus: FullCustomer | undefined;
actualDeductions: ActualDeductions;
}> => {
const { db, org, env } = ctx;
if (!fullCus) {
@@ -75,6 +84,21 @@ export const deductFromCusEnts = async ({
})),
);
}
const isPaidAllocated = deductions.some((d) =>
isPaidContinuousUse({
feature: d.feature,
fullCus,
}),
);
console.log(`Is paid allocated: ${isPaidAllocated}`);
if (isPaidAllocated) overageBehaviour = "reject";
// Track actual deductions per feature
const actualDeductions: ActualDeductions = {};
// Need to deduct from customer entitlement...
for (const deduction of deductions) {
const { feature, deduction: toDeduct, targetBalance } = deduction;
@@ -84,6 +108,10 @@ export const deductFromCusEnts = async ({
featureId: feature.id,
});
if (printLogs) {
console.log(`Entity Mode: ${entityId ? "Yes" : "No"}`);
}
const cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
featureIds: relevantFeatures.map((f) => f.id),
@@ -91,6 +119,17 @@ export const deductFromCusEnts = async ({
entity: fullCus.entity,
});
if (printLogs) {
console.log(
`Cus Ents: `,
cusEnts.map((ce) => ({
balance: ce.balance,
entity_id: ce.customer_product.entity_id,
cus_ent_id: ce.id,
})),
);
}
const { unlimited } = getUnlimitedAndUsageAllowed({
cusEnts,
internalFeatureId: feature.internal_id!,
@@ -121,8 +160,6 @@ export const deductFromCusEnts = async ({
};
});
// console.log("Cus ent input", cusEntInput);
// Collect and sort rollovers by expires_at (oldest first)
const sortedRollovers = cusEnts
.flatMap((ce) => ce.rollovers || [])
@@ -164,6 +201,11 @@ export const deductFromCusEnts = async ({
remaining: number;
};
// log updates
if (printLogs) {
console.log(`Updates: `, resultJson.updates);
}
if (!resultJson) {
throw new InternalError({
message: "Failed to deduct from entitlements",
@@ -179,13 +221,17 @@ export const deductFromCusEnts = async ({
});
}
// Calculate total deducted from the updates (sum of all deducted amounts)
const totalDeducted = Object.values(updates).reduce(
(sum, update) => sum + update.deducted,
0,
);
// Store actual deduction for this feature
actualDeductions[feature.id] = totalDeducted;
// Log deduction details
if (targetBalance !== undefined) {
// Calculate total deducted from the updates (sum of all deducted amounts)
const totalDeducted = Object.values(updates).reduce(
(sum, update) => sum + update.deducted,
0,
);
const entityInfo = entityId
? `Entity: ${entityId}`
: "Entity: customer-level";
@@ -200,12 +246,10 @@ export const deductFromCusEnts = async ({
});
} else {
ctx.logger.info(
`[Track] Deducted ${toDeduct - remaining} from feature ${feature.id}. Updated ${
`[Track] Deducted ${totalDeducted} from feature ${feature.id}. Updated ${
Object.keys(updates).length
} entitlements. Remaining: ${remaining}`,
);
// Log cus ent ids:
}
// Bill on Stripe for each updated entitlement
@@ -248,10 +292,14 @@ export const deductFromCusEnts = async ({
// Adjust balance based on replaceables
let reUpdatedBalance = update.balance;
let replaceableAdjustment = 0;
if (newReplaceables && newReplaceables.length > 0) {
reUpdatedBalance = reUpdatedBalance - newReplaceables.length;
replaceableAdjustment = newReplaceables.length;
} else if (deletedReplaceables && deletedReplaceables.length > 0) {
reUpdatedBalance = reUpdatedBalance + deletedReplaceables.length;
replaceableAdjustment = -deletedReplaceables.length;
}
if (reUpdatedBalance !== update.balance) {
@@ -262,6 +310,10 @@ export const deductFromCusEnts = async ({
balance: reUpdatedBalance,
},
});
// Adjust the actual deduction to reflect replaceables
actualDeductions[feature.id] =
(actualDeductions[feature.id] || 0) + replaceableAdjustment;
}
updateCusEntInFullCus({
@@ -272,7 +324,10 @@ export const deductFromCusEnts = async ({
}
}
return fullCus;
return {
fullCus,
actualDeductions,
};
};
export const runDeductionTx = async (
@@ -280,12 +335,14 @@ export const runDeductionTx = async (
): Promise<{
fullCus: FullCustomer | undefined;
event: Event | undefined;
actualDeductions: ActualDeductions;
}> => {
const ctx = params.ctx;
const { db } = ctx;
const { db, logger } = ctx;
let fullCus: FullCustomer | undefined;
let event: Event | undefined;
let actualDeductions: ActualDeductions = {};
await db.transaction(
async (tx) => {
@@ -298,12 +355,14 @@ export const runDeductionTx = async (
},
};
fullCus = await deductFromCusEnts(txParams);
const result = await deductFromCusEnts(txParams);
fullCus = result.fullCus;
actualDeductions = result.actualDeductions;
if (!fullCus) return;
if (params.eventInfo) {
const newEvent = await constructEvent({
const newEvent = constructEvent({
ctx: txParams.ctx,
eventInfo: params.eventInfo,
internalCustomerId: fullCus.internal_id,
@@ -317,52 +376,51 @@ export const runDeductionTx = async (
event: newEvent,
});
}
if (params?.refreshCache && fullCus) {
// Deduct the actual amounts from Redis cache (if exists)
// This prevents race conditions by directly deducting the exact Postgres amount
const { deductFromCache } = await import(
"../redisTrackUtils/deductFromCache.js"
);
const printLogs = true;
for (const [featureId, deductedAmount] of Object.entries(
actualDeductions,
)) {
if (deductedAmount !== 0) {
// Only deduct if something was actually deducted
await deductFromCache({
ctx,
customerId: fullCus.id ?? "",
featureId,
amount: deductedAmount,
entityId: params.entityId,
});
if (printLogs) {
logger.info(
`[REDIS] Deduced users from cache: ${chalk.yellow(actualDeductions.users)}`,
);
// logger.info(
// `[REDIS] balance after deduction for ${featureId}: ${chalk.yellow(balance)}`,
// );
}
}
}
}
},
{
isolationLevel: "read committed",
},
);
// Sync cache if requested (default: true for track, false for sync)
if (params?.refreshCache && fullCus) {
// Sync Redis cache for each affected feature
// This prevents race conditions with concurrent Redis track operations
const { syncCacheBalance } = await import(
"../redisTrackUtils/syncCacheBalance.js"
);
for (const deduction of params.deductions) {
const feature = deduction.feature;
// Find the customer entitlement for this feature to get the new balance
const cusEnts = cusProductsToCusEnts({
cusProducts: fullCus.customer_products,
featureIds: [feature.id],
reverseOrder: false,
entity: fullCus.entity,
});
if (cusEnts.length > 0) {
// Calculate total balance across all entitlements for this feature
const totalBalance = cusEnts.reduce(
(sum, ce) => sum + (ce.balance ?? 0),
0,
);
// Sync cache to match Postgres balance
await syncCacheBalance({
ctx,
customerId: fullCus.id ?? "",
featureId: feature.id,
targetBalance: totalBalance,
entityId: params.entityId,
});
}
}
}
// Deduct from Redis cache if requested (default: true for track, false for sync)
return {
fullCus,
event,
actualDeductions,
};
};

View File

@@ -1,27 +1,26 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
type BillingInterval,
BillingType,
CusProductStatus,
cusProductsToCusEnts,
cusProductsToCusPrices,
type FullCusProduct,
intervalsDifferent,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import {
getCusPriceUsage,
getRelatedCusEnt,
} from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { cusProductsToCusEnts, cusProductsToCusPrices } from "@autumn/shared";
import {
formatPrice,
getBillingType,
} from "@/internal/products/prices/priceUtils.js";
import {
FullCusProduct,
UsagePriceConfig,
BillingType,
BillingInterval,
intervalsDifferent,
CusProductStatus,
} from "@autumn/shared";
import Stripe from "stripe";
export const getUsageInvoiceItems = async ({
db,
@@ -93,7 +92,7 @@ export const getUsageInvoiceItems = async ({
cusEntIds.push(cusEnt.id);
let invoiceItem = {
const invoiceItem = {
description,
price_data: {
product: config.stripe_product_id!,
@@ -194,7 +193,7 @@ export const resetUsageBalances = async ({
},
});
let index = cusProduct.customer_entitlements.findIndex(
const index = cusProduct.customer_entitlements.findIndex(
(ce) => ce.id === cusEntId,
);

View File

@@ -1,7 +1,6 @@
import {
type AttachBody,
CusProductStatus,
type CustomerData,
ErrCode,
nullish,
} from "@autumn/shared";
@@ -80,7 +79,9 @@ export const getCustomerAndProducts = async ({
getOrCreateCustomer({
req,
customerId: attachBody.customer_id,
customerData: attachBody.customer_data as CustomerData,
customerData: {
...attachBody.customer_data,
},
inStatuses: [
CusProductStatus.Active,
CusProductStatus.Scheduled,

View File

@@ -102,7 +102,7 @@ export const getExistingUsages = ({
const ent = cusEnt.entitlement;
const key = `${ent.feature_id}-${ent.interval}-${ent.interval_count || 1}`;
const feature = ent.feature;
if (feature.type == FeatureType.Boolean) continue;
if (feature.type === FeatureType.Boolean) continue;
const { unlimited, usageAllowed } = getUnlimitedAndUsageAllowed({
cusEnts: curCusProduct.customer_entitlements,
@@ -223,7 +223,7 @@ export const addExistingUsagesToCusEnts = ({
const fromEntities = existingUsages[key].fromEntities;
// if (cusEntKey !== key) continue;
const isSameFeature = cusEnt.feature_id == feature_id;
const isSameFeature = cusEnt.feature_id === feature_id;
if (!isSameFeature) continue;

View File

@@ -1,374 +0,0 @@
-- loadCusFeatures.lua
-- Shared function to load customer features with merged balances (customer + entities)
-- Returns: { [featureId] = { balance, usage, unlimited, ... } } or nil if not in cache
-- Helper function to safely convert values to numbers for arithmetic
local function toNum(value)
return type(value) == "number" and value or 0
end
-- Load customer features with merged entity balances
-- Parameters: cacheKey, orgId, env, customerId
-- Returns: merged features table or nil
local function loadCusFeatures(cacheKey, orgId, env, customerId)
-- Get base customer JSON
local baseJson = redis.call("GET", cacheKey)
if not baseJson then
return nil
end
local baseCustomer = cjson.decode(baseJson)
local featureIds = baseCustomer._featureIds or {}
local entityIds = baseCustomer._entityIds or {}
-- Build features object
local features = {}
for _, featureId in ipairs(featureIds) do
local featureKey = cacheKey .. ":features:" .. featureId
local featureHash = redis.call("HGETALL", featureKey)
-- If feature key is missing, return nil (partial eviction detected)
if #featureHash == 0 then
return nil
end
-- Convert HGETALL result (flat array) to table
local featureData = {}
for i = 1, #featureHash, 2 do
local key = featureHash[i]
local value = featureHash[i + 1]
-- Check for null first before parsing
if value == "null" then
featureData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
featureData[key] = tonumber(value)
elseif key == "unlimited" or key == "overage_allowed" then
featureData[key] = (value == "true")
elseif key == "credit_schema" then
-- Parse credit_schema JSON array
if value ~= "" then
featureData[key] = cjson.decode(value)
else
featureData[key] = cjson.null
end
else
featureData[key] = value
end
end
-- Get rollover count
local rolloverCount = featureData._rollover_count or 0
featureData._rollover_count = nil -- Remove from final output
-- Fetch rollover items
local rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. i
local rolloverHash = redis.call("HGETALL", rolloverKey)
-- If rollover key is missing, return nil (partial eviction detected)
if #rolloverHash == 0 then
return nil
end
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(rollovers, rolloverData)
end
if #rollovers > 0 then
featureData.rollovers = rollovers
end
-- Get breakdown count
local breakdownCount = featureData._breakdown_count or 0
featureData._breakdown_count = nil -- Remove from final output
-- Fetch breakdown items
local breakdown = {}
for i = 0, breakdownCount - 1 do
local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. i
local breakdownHash = redis.call("HGETALL", breakdownKey)
-- If breakdown key is missing, return nil (partial eviction detected)
if #breakdownHash == 0 then
return nil
end
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
breakdownData[key] = tonumber(value)
elseif key == "overage_allowed" then
breakdownData[key] = (value == "true")
else
breakdownData[key] = value
end
end
table.insert(breakdown, breakdownData)
end
if #breakdown > 0 then
featureData.breakdown = breakdown
end
features[featureId] = featureData
end
-- ============================================================================
-- FETCH AND MERGE ENTITY FEATURES
-- ============================================================================
-- Fetch all entity features and aggregate balances
local entityFeatureData = {} -- {[entityId][featureId] = featureData}
local entityBaseData = {} -- {[entityId] = entityBase} - Store entity base for product access
for _, entityId in ipairs(entityIds) do
local entityCacheKey = "{" .. orgId .. "}:" .. env .. ":customer:" .. customerId .. ":entity:" .. entityId
local entityBaseJson = redis.call("GET", entityCacheKey)
if entityBaseJson then
local entityBase = cjson.decode(entityBaseJson)
entityBaseData[entityId] = entityBase -- Store entity base for product access
local entityFeatureIds = entityBase._featureIds or {}
entityFeatureData[entityId] = {}
for _, featureId in ipairs(entityFeatureIds) do
local entityFeatureKey = entityCacheKey .. ":features:" .. featureId
local entityFeatureHash = redis.call("HGETALL", entityFeatureKey)
if #entityFeatureHash > 0 then
-- Parse entity feature
local entityFeature = {}
for i = 1, #entityFeatureHash, 2 do
local key = entityFeatureHash[i]
local value = entityFeatureHash[i + 1]
if value == "null" then
entityFeature[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then
entityFeature[key] = tonumber(value)
elseif key == "unlimited" or key == "overage_allowed" then
entityFeature[key] = (value == "true")
else
entityFeature[key] = value
end
end
-- Fetch breakdown items for this entity feature
local breakdownCount = entityFeature._breakdown_count or 0
entityFeature._breakdown_count = nil
entityFeature.breakdowns = {}
for i = 0, breakdownCount - 1 do
local breakdownKey = entityFeatureKey .. ":breakdown:" .. i
local breakdownHash = redis.call("HGETALL", breakdownKey)
if #breakdownHash > 0 then
local breakdownData = {}
for j = 1, #breakdownHash, 2 do
local key = breakdownHash[j]
local value = breakdownHash[j + 1]
if value == "null" then
breakdownData[key] = cjson.null
elseif key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then
breakdownData[key] = tonumber(value)
elseif key == "overage_allowed" then
breakdownData[key] = (value == "true")
else
breakdownData[key] = value
end
end
table.insert(entityFeature.breakdowns, breakdownData)
end
end
-- Fetch rollover items for this entity feature
local rolloverCount = entityFeature._rollover_count or 0
entityFeature._rollover_count = nil
entityFeature.rollovers = {}
for i = 0, rolloverCount - 1 do
local rolloverKey = entityFeatureKey .. ":rollover:" .. i
local rolloverHash = redis.call("HGETALL", rolloverKey)
if #rolloverHash > 0 then
local rolloverData = {}
for j = 1, #rolloverHash, 2 do
local key = rolloverHash[j]
local value = rolloverHash[j + 1]
if value == "null" then
rolloverData[key] = cjson.null
elseif key == "balance" or key == "expires_at" then
rolloverData[key] = tonumber(value)
else
rolloverData[key] = value
end
end
table.insert(entityFeature.rollovers, rolloverData)
end
end
entityFeatureData[entityId][featureId] = entityFeature
end
end
end
end
-- ============================================================================
-- MERGE ENTITY BALANCES INTO CUSTOMER FEATURES
-- ============================================================================
for featureId, customerFeature in pairs(features) do
-- Skip if unlimited
if not customerFeature.unlimited then
-- Aggregate entity balances for this feature
local entityTotalBalance = 0
local entityTotalUsage = 0
local entityTotalIncludedUsage = 0
local entityTotalUsageLimit = 0
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature then
entityTotalBalance = entityTotalBalance + toNum(entityFeature.balance)
entityTotalUsage = entityTotalUsage + toNum(entityFeature.usage)
entityTotalIncludedUsage = entityTotalIncludedUsage + toNum(entityFeature.included_usage)
entityTotalUsageLimit = entityTotalUsageLimit + toNum(entityFeature.usage_limit)
end
end
-- Merge top-level balance and usage
customerFeature.balance = toNum(customerFeature.balance) + entityTotalBalance
customerFeature.usage = toNum(customerFeature.usage) + entityTotalUsage
customerFeature.included_usage = toNum(customerFeature.included_usage) + entityTotalIncludedUsage
customerFeature.usage_limit = toNum(customerFeature.usage_limit) + entityTotalUsageLimit
-- Merge breakdown balances and usage
if customerFeature.breakdown and #customerFeature.breakdown > 0 then
for i, breakdown in ipairs(customerFeature.breakdown) do
local entityBreakdownBalance = 0
local entityBreakdownUsage = 0
local entityBreakdownIncludedUsage = 0
local entityBreakdownUsageLimit = 0
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature and entityFeature.breakdowns and entityFeature.breakdowns[i] then
entityBreakdownBalance = entityBreakdownBalance + toNum(entityFeature.breakdowns[i].balance)
entityBreakdownUsage = entityBreakdownUsage + toNum(entityFeature.breakdowns[i].usage)
entityBreakdownIncludedUsage = entityBreakdownIncludedUsage + toNum(entityFeature.breakdowns[i].included_usage)
entityBreakdownUsageLimit = entityBreakdownUsageLimit + toNum(entityFeature.breakdowns[i].usage_limit)
end
end
breakdown.balance = toNum(breakdown.balance) + entityBreakdownBalance
breakdown.usage = toNum(breakdown.usage) + entityBreakdownUsage
breakdown.included_usage = toNum(breakdown.included_usage) + entityBreakdownIncludedUsage
breakdown.usage_limit = toNum(breakdown.usage_limit) + entityBreakdownUsageLimit
end
end
-- Merge rollover balances
if customerFeature.rollovers and #customerFeature.rollovers > 0 then
for i, rollover in ipairs(customerFeature.rollovers) do
local entityRolloverBalance = 0
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature and entityFeature.rollovers and entityFeature.rollovers[i] then
entityRolloverBalance = entityRolloverBalance + toNum(entityFeature.rollovers[i].balance)
end
end
rollover.balance = toNum(rollover.balance) + entityRolloverBalance
end
end
end
end
-- Add entity-only features (features that exist in entities but not in customer)
for entityId, entityFeatures in pairs(entityFeatureData) do
for featureId, entityFeature in pairs(entityFeatures) do
if not features[featureId] then
-- This feature doesn't exist in customer, add it
-- Initialize with zero balance, then we'll aggregate all entity balances
features[featureId] = {
id = entityFeature.id,
type = entityFeature.type,
name = entityFeature.name,
interval = entityFeature.interval,
interval_count = entityFeature.interval_count,
unlimited = entityFeature.unlimited,
balance = 0,
usage = 0,
included_usage = 0,
next_reset_at = cjson.null,
overage_allowed = entityFeature.overage_allowed,
usage_limit = entityFeature.usage_limit,
credit_schema = entityFeature.credit_schema
}
end
end
end
-- Now aggregate balances for entity-only features
for featureId, customerFeature in pairs(features) do
-- Only process if this was an entity-only feature (balance is still 0 from initialization)
if customerFeature.balance == 0 and customerFeature.usage == 0 then
local entityTotalBalance = 0
local entityTotalUsage = 0
local entityTotalIncludedUsage = 0
local entityTotalUsageLimit = 0
local minNextResetAt = nil
for entityId, entityFeatures in pairs(entityFeatureData) do
local entityFeature = entityFeatures[featureId]
if entityFeature then
entityTotalBalance = entityTotalBalance + toNum(entityFeature.balance)
entityTotalUsage = entityTotalUsage + toNum(entityFeature.usage)
entityTotalIncludedUsage = entityTotalIncludedUsage + toNum(entityFeature.included_usage)
entityTotalUsageLimit = entityTotalUsageLimit + toNum(entityFeature.usage_limit)
-- Find minimum next_reset_at across all entities
if type(entityFeature.next_reset_at) == "number" then
if not minNextResetAt or entityFeature.next_reset_at < minNextResetAt then
minNextResetAt = entityFeature.next_reset_at
end
end
end
end
customerFeature.balance = entityTotalBalance
customerFeature.usage = entityTotalUsage
customerFeature.included_usage = entityTotalIncludedUsage
customerFeature.usage_limit = entityTotalUsageLimit
customerFeature.next_reset_at = minNextResetAt or cjson.null
end
end
-- Return merged features
return features
end

View File

@@ -1,43 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load shared validation function
const CHECK_CACHE_EXISTS = readFileSync(
join(__dirname, "checkCacheExists.lua"),
"utf-8",
);
// Load shared feature loading function
const LOAD_CUS_FEATURES = readFileSync(
join(__dirname, "loadCusFeatures.lua"),
"utf-8",
);
// Load Lua scripts at module initialization
// Prepend loadCusFeatures to GET_CUSTOMER_SCRIPT so it can use the function
const getCustomerScript = readFileSync(
join(__dirname, "getCustomer.lua"),
"utf-8",
);
export const GET_CUSTOMER_SCRIPT = `${LOAD_CUS_FEATURES}\n${getCustomerScript}`;
// Prepend validation function to SET_CUSTOMER_SCRIPT
const setCustomerScript = readFileSync(
join(__dirname, "setCustomer.lua"),
"utf-8",
);
export const SET_CUSTOMER_SCRIPT = `${CHECK_CACHE_EXISTS}\n${setCustomerScript}`;
export const SET_CUSTOMER_PRODUCTS_SCRIPT = readFileSync(
join(__dirname, "setCustomerProducts.lua"),
"utf-8",
);
export const SET_CUSTOMER_DETAILS_SCRIPT = readFileSync(
join(__dirname, "setCustomerDetails.lua"),
"utf-8",
);

View File

@@ -1,14 +1,8 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DELETE_CUSTOMER_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "@/external/redis/initRedis.js";
import { logger } from "../../../../external/logtail/logtailUtils.js";
import { buildCachedApiCustomerKey } from "./getCachedApiCustomer.js";
const DELETE_CUSTOMER_SCRIPT = readFileSync(
join(import.meta.dir, "cusLuaScripts", "deleteCustomer.lua"),
"utf-8",
);
/**
* Delete all cached ApiCustomer data from Redis
* This includes the base customer key and all related feature/breakdown/rollover keys

View File

@@ -3,7 +3,9 @@ import {
ApiCustomerSchema,
type AppEnv,
type CustomerLegacyData,
filterOutEntitiesFromCusProducts,
} from "@autumn/shared";
import { GET_CUSTOMER_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import {
@@ -13,7 +15,6 @@ import {
import { CusService } from "../../CusService.js";
import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js";
import { getApiCustomerBase } from "../apiCusUtils/getApiCustomerBase.js";
import { GET_CUSTOMER_SCRIPT } from "./cusLuaScripts/luaScripts.js";
import { setCachedApiCustomer } from "./setCachedApiCustomer.js";
export const buildCachedApiCustomerKey = ({
@@ -36,17 +37,17 @@ export const buildCachedApiCustomerKey = ({
export const getCachedApiCustomer = async ({
ctx,
customerId,
withAutumnId = false,
skipCache = false,
skipEntityMerge = false,
source,
}: {
ctx: AutumnContext;
customerId: string;
withAutumnId?: boolean;
skipCache?: boolean;
skipEntityMerge?: boolean; // If true, returns only customer's own features (no entity merging)
source?: string;
}): Promise<{ apiCustomer: ApiCustomer; legacyData: CustomerLegacyData }> => {
const { org, env, db, logger } = ctx;
const { org, env, db } = ctx;
const cacheKey = buildCachedApiCustomerKey({
customerId,
@@ -57,7 +58,15 @@ export const getCachedApiCustomer = async ({
// Try to get from cache using Lua script (unless skipCache is true)
if (!skipCache) {
const cachedResult = await tryRedisRead(() =>
redis.eval(GET_CUSTOMER_SCRIPT, 1, cacheKey, org.id, env, customerId),
redis.eval(
GET_CUSTOMER_SCRIPT,
1,
cacheKey,
org.id,
env,
customerId,
skipEntityMerge ? "true" : "false",
),
);
if (cachedResult) {
@@ -69,14 +78,9 @@ export const getCachedApiCustomer = async ({
const { legacyData, ...rest } = cached;
// logger.info(`Customer cache hit:`, rest.features);
return {
// ← This returns from getCachedApiCustomer!
apiCustomer: ApiCustomerSchema.parse({
...rest,
autumn_id: withAutumnId ? rest.autumn_id : undefined,
}),
apiCustomer: ApiCustomerSchema.parse(rest),
legacyData,
};
}
@@ -101,6 +105,17 @@ export const getCachedApiCustomer = async ({
withAutumnId: true,
});
const { apiCustomer: masterApiCustomer } = await getApiCustomerBase({
ctx,
fullCus: {
...fullCus,
customer_products: filterOutEntitiesFromCusProducts({
cusProducts: fullCus.customer_products,
}),
},
withAutumnId: true,
});
// Store customer and entity caches (only if not skipping cache)
if (!skipCache) {
await setCachedApiCustomer({
@@ -112,7 +127,9 @@ export const getCachedApiCustomer = async ({
}
return {
apiCustomer: ApiCustomerSchema.parse(apiCustomer),
apiCustomer: ApiCustomerSchema.parse(
skipEntityMerge ? masterApiCustomer : apiCustomer,
),
legacyData,
};
};

View File

@@ -1,8 +1,8 @@
import type { ApiCustomer, FullCustomer } from "@autumn/shared";
import { SET_CUSTOMER_DETAILS_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js";
import { SET_CUSTOMER_DETAILS_SCRIPT } from "./cusLuaScripts/luaScripts.js";
import { buildCachedApiCustomerKey } from "./getCachedApiCustomer.js";
/**

View File

@@ -3,13 +3,15 @@ import {
filterCusProductsByEntity,
filterOutEntitiesFromCusProducts,
} from "@autumn/shared";
import {
SET_CUSTOMER_PRODUCTS_SCRIPT,
SET_ENTITY_PRODUCTS_SCRIPT,
} from "@lua/luaScripts.js";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js";
import { SET_ENTITY_PRODUCTS_SCRIPT } from "../../../entities/entityUtils/apiEntityCacheUtils/entityLuaScripts/luaScripts.js";
import { buildCachedApiEntityKey } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js";
import { getApiCusProducts } from "../apiCusUtils/getApiCusProduct/getApiCusProducts.js";
import { SET_CUSTOMER_PRODUCTS_SCRIPT } from "./cusLuaScripts/luaScripts.js";
import { buildCachedApiCustomerKey } from "./getCachedApiCustomer.js";
/**

View File

@@ -4,13 +4,15 @@ import {
filterEntityLevelCusProducts,
filterOutEntitiesFromCusProducts,
} from "@autumn/shared";
import {
SET_CUSTOMER_SCRIPT,
SET_ENTITIES_BATCH_SCRIPT,
} from "@lua/luaScripts.js";
import { redis } from "../../../../external/redis/initRedis.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { tryRedisWrite } from "../../../../utils/cacheUtils/cacheUtils.js";
import { SET_ENTITIES_BATCH_SCRIPT } from "../../../entities/entityUtils/apiEntityCacheUtils/entityLuaScripts/luaScripts.js";
import { getApiEntityBase } from "../../../entities/entityUtils/apiEntityUtils/getApiEntityBase.js";
import { getApiCustomerBase } from "../apiCusUtils/getApiCustomerBase.js";
import { SET_CUSTOMER_SCRIPT } from "./cusLuaScripts/luaScripts.js";
import { buildCachedApiCustomerKey } from "./getCachedApiCustomer.js";
/**

View File

@@ -20,6 +20,7 @@ export const getApiCustomer = async ({
customerId,
fullCus,
skipCache = false,
baseData,
}: {
ctx: RequestContext;
expand: CusExpand[];
@@ -27,23 +28,30 @@ export const getApiCustomer = async ({
customerId?: string;
fullCus?: FullCustomer;
skipCache?: boolean;
baseData?: { apiCustomer: ApiCustomer; legacyData: CustomerLegacyData };
}) => {
// Get base customer (cacheable or direct from DB)
// await redis.del(
// buildCachedApiCustomerKey({
// customerId: customerId || "",
// orgId: ctx.org.id,
// env: ctx.env,
// }),
// );
let baseCustomer: ApiCustomer;
let cusLegacyData: CustomerLegacyData;
const { apiCustomer: baseCustomer, legacyData: cusLegacyData } =
await getCachedApiCustomer({
if (!baseData) {
const { apiCustomer, legacyData } = await getCachedApiCustomer({
ctx,
customerId: customerId || "",
withAutumnId,
skipCache,
});
baseCustomer = apiCustomer;
cusLegacyData = legacyData;
} else {
baseCustomer = baseData.apiCustomer;
cusLegacyData = baseData.legacyData;
}
// Clean api customer
baseCustomer = {
...baseCustomer,
entities: undefined,
autumn_id: withAutumnId ? baseCustomer.autumn_id : undefined,
};
// Get expand fields (not cacheable)
const apiCusExpand = await getApiCustomerExpand({

View File

@@ -12,11 +12,12 @@ import { getApiCusProducts } from "./getApiCusProduct/getApiCusProducts.js";
/**
* Get base ApiCustomer without expand fields
* This is the core customer object that can be cached
* By default, it includes the autumn_id
*/
export const getApiCustomerBase = async ({
ctx,
fullCus,
withAutumnId = false,
withAutumnId = true,
}: {
ctx: RequestContext;
fullCus: FullCustomer;

View File

@@ -35,6 +35,8 @@ export const getApiCustomerExpand = async ({
orgId: org.id,
env,
expand,
withEntities: expand.includes(CusExpand.Entities),
withSubs: true,
});
}

View File

@@ -14,18 +14,16 @@ export const getOrCreateApiCustomer = async ({
ctx,
customerId,
customerData,
withAutumnId = false,
}: {
ctx: AutumnContext;
customerId: string | null;
customerData?: CustomerData;
withAutumnId?: boolean;
}): Promise<ApiCustomer> => {
}): Promise<{ apiCustomer: ApiCustomer; legacyData?: CustomerLegacyData }> => {
// ========================================
// Phase 1: Get or Create Customer
// ========================================
let apiCustomer: ApiCustomer;
let legacyData: CustomerLegacyData;
let legacyData: CustomerLegacyData | undefined;
// Path A: customerId is NULL - always create new customer
if (!customerId) {
@@ -44,7 +42,6 @@ export const getOrCreateApiCustomer = async ({
const res = await getCachedApiCustomer({
ctx,
customerId: newCustomer.id || newCustomer.internal_id,
withAutumnId,
});
apiCustomer = res.apiCustomer;
@@ -59,7 +56,6 @@ export const getOrCreateApiCustomer = async ({
const res = await getCachedApiCustomer({
ctx,
customerId,
withAutumnId,
});
apiCustomerOrUndefined = res?.apiCustomer;
legacyData = res?.legacyData;
@@ -89,7 +85,6 @@ export const getOrCreateApiCustomer = async ({
const res = await getCachedApiCustomer({
ctx,
customerId: newCustomer.id || newCustomer.internal_id,
withAutumnId,
source: "getOrCreateApiCustomer",
});
apiCustomerOrUndefined = res?.apiCustomer;
@@ -100,7 +95,6 @@ export const getOrCreateApiCustomer = async ({
const res = await getCachedApiCustomer({
ctx,
customerId,
withAutumnId,
});
apiCustomerOrUndefined = res?.apiCustomer;
legacyData = res?.legacyData;
@@ -127,11 +121,13 @@ export const getOrCreateApiCustomer = async ({
const res = await getCachedApiCustomer({
ctx,
customerId: apiCustomer.id || "",
withAutumnId,
});
apiCustomer = res?.apiCustomer;
legacyData = res?.legacyData;
}
return apiCustomer;
return {
apiCustomer,
legacyData,
};
};

View File

@@ -1,7 +1,7 @@
import {
type CreateCustomerParams,
CusExpand,
CusProductStatus,
type CustomerData,
type Entity,
type EntityData,
type FullCustomer,
@@ -33,7 +33,7 @@ export const getOrCreateCustomer = async ({
}: {
req: ExtendedRequest;
customerId: string | null;
customerData?: CreateCustomerParams;
customerData?: CustomerData;
inStatuses?: CusProductStatus[];
skipGet?: boolean;
withEntities?: boolean;
@@ -89,7 +89,7 @@ export const getOrCreateCustomer = async ({
fingerprint: customerData?.fingerprint,
metadata: customerData?.metadata || {},
stripe_id: customerData?.stripe_id,
default_product_id: customerData?.default_product_id,
// default_product_id: customerData?.default_product_id,
},
createDefaultProducts: customerData?.disable_default !== true,
})) as FullCustomer;

View File

@@ -7,6 +7,7 @@ import {
} from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { getApiCustomer } from "../cusUtils/apiCusUtils/getApiCustomer.js";
import { getOrCreateApiCustomer } from "../cusUtils/getOrCreateApiCustomer.js";
export const handlePostCustomer = createRoute({
@@ -19,7 +20,7 @@ export const handlePostCustomer = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { expand = [], with_autumn_id = false } = c.req.valid("query");
const { expand = [], with_autumn_id } = c.req.valid("query");
const createCusParams = c.req.valid("json");
// SIDE EFFECT
@@ -32,11 +33,26 @@ export const handlePostCustomer = createRoute({
expand.push(CusExpand.Invoices);
}
const apiCustomer = await getOrCreateApiCustomer({
const baseData = await getOrCreateApiCustomer({
ctx,
customerId: createCusParams.id,
customerData: createCusParams,
});
console.log("Expand:", expand);
const apiCustomer = await getApiCustomer({
ctx,
customerId: createCusParams.id || "",
expand,
skipCache: false,
withAutumnId: with_autumn_id,
baseData: {
apiCustomer: baseData.apiCustomer,
legacyData: baseData.legacyData || {
cusProductLegacyData: {},
},
},
});
return c.json(apiCustomer);

View File

@@ -1,32 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load shared validation function
const CHECK_ENTITY_CACHE_EXISTS = readFileSync(
join(__dirname, "checkEntityCacheExists.lua"),
"utf-8",
);
// Load Lua scripts at module initialization
export const GET_ENTITY_SCRIPT = readFileSync(
join(__dirname, "getEntity.lua"),
"utf-8",
);
// Prepend validation function to SET_ENTITY_SCRIPT
const setEntityScript = readFileSync(join(__dirname, "setEntity.lua"), "utf-8");
export const SET_ENTITY_SCRIPT = `${CHECK_ENTITY_CACHE_EXISTS}\n${setEntityScript}`;
export const SET_ENTITIES_BATCH_SCRIPT = readFileSync(
join(__dirname, "setEntitiesBatch.lua"),
"utf-8",
);
export const SET_ENTITY_PRODUCTS_SCRIPT = readFileSync(
join(__dirname, "setEntityProducts.lua"),
"utf-8",
);

View File

@@ -1,4 +1,11 @@
import { type ApiEntity, ApiEntitySchema, type AppEnv } from "@autumn/shared";
import {
type ApiEntity,
ApiEntitySchema,
type AppEnv,
type FullCustomer,
filterEntityLevelCusProducts,
} from "@autumn/shared";
import { GET_ENTITY_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
@@ -9,7 +16,6 @@ import {
} from "@/utils/cacheUtils/cacheUtils.js";
import { setCachedApiCustomer } from "../../../customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js";
import { getApiEntityBase } from "../apiEntityUtils/getApiEntityBase.js";
import { GET_ENTITY_SCRIPT } from "./entityLuaScripts/luaScripts.js";
export const buildCachedApiEntityKey = ({
entityId,
@@ -34,14 +40,16 @@ export const getCachedApiEntity = async ({
ctx,
customerId,
entityId,
withAutumnId = false,
skipCache = false,
skipCustomerMerge = false,
fullCus,
}: {
ctx: AutumnContext;
customerId: string;
entityId: string;
withAutumnId?: boolean;
skipCache?: boolean;
skipCustomerMerge?: boolean; // If true, returns only entity's own features (no customer merging)
fullCus?: FullCustomer;
}): Promise<{ apiEntity: ApiEntity }> => {
const { org, env, db } = ctx;
@@ -61,6 +69,9 @@ export const getCachedApiEntity = async ({
cacheKey, // KEYS[1]
org.id, // ARGV[1]
env, // ARGV[2]
customerId, // ARGV[3]
entityId, // ARGV[4]
skipCustomerMerge ? "true" : "false", // ARGV[5]
),
);
@@ -71,25 +82,24 @@ export const getCachedApiEntity = async ({
);
return {
apiEntity: ApiEntitySchema.parse({
...cached,
autumn_id: withAutumnId ? entityId : undefined,
}),
apiEntity: ApiEntitySchema.parse(cached),
};
}
}
// Cache miss or skipCache - fetch from DB
const fullCus = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env: env as AppEnv,
inStatuses: RELEVANT_STATUSES,
withEntities: true,
withSubs: true,
entityId,
});
if (!fullCus) {
fullCus = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env: env as AppEnv,
inStatuses: RELEVANT_STATUSES,
withEntities: true,
withSubs: true,
entityId,
});
}
const entity = fullCus.entity;
if (!entity) {
@@ -104,74 +114,6 @@ export const getCachedApiEntity = async ({
fullCus,
customerId,
});
// const { apiCustomer: masterApiCustomer, legacyData } =
// await getApiCustomerBase({
// ctx,
// fullCus: {
// ...structuredClone(fullCus),
// customer_products: filterOutEntitiesFromCusProducts({
// cusProducts: fullCus.customer_products,
// }),
// },
// withAutumnId: !skipCache,
// });
// // Build ApiEntity with filtered entity-level products for caching
// const entityCusProducts = filterEntityLevelCusProducts({
// cusProducts: fullCus.customer_products,
// });
// const { apiEntity: apiEntityForCache, legacyData: entityLegacyData } =
// await getApiEntityBase({
// ctx,
// entity,
// fullCus: {
// ...fullCus,
// customer_products: entityCusProducts,
// },
// withAutumnId: true,
// });
// await tryRedisWrite(async () => {
// // Get customer
// const customerCacheKey = buildCachedApiCustomerKey({
// customerId,
// orgId: org.id,
// env,
// });
// const cachedCustomer = await redis.eval(
// GET_CUSTOMER_SCRIPT,
// 1,
// customerCacheKey,
// org.id,
// env,
// customerId,
// );
// if (!cachedCustomer) {
// await redis.eval(
// SET_CUSTOMER_SCRIPT,
// 1,
// customerCacheKey,
// JSON.stringify({
// ...masterApiCustomer,
// entities: fullCus.entities,
// legacyData,
// }),
// org.id,
// env,
// );
// }
// await redis.eval(
// SET_ENTITY_SCRIPT,
// 1, // number of keys
// cacheKey, // KEYS[1]
// JSON.stringify({
// ...apiEntityForCache,
// legacyData: entityLegacyData,
// }), // ARGV[1]
// );
// });
}
// Build ApiEntity with full products for return
@@ -182,10 +124,21 @@ export const getCachedApiEntity = async ({
withAutumnId: !skipCache,
});
const { apiEntity: pureApiEntity } = await getApiEntityBase({
ctx,
entity,
fullCus: {
...fullCus,
customer_products: filterEntityLevelCusProducts({
cusProducts: fullCus.customer_products,
}),
},
withAutumnId: true,
});
return {
apiEntity: ApiEntitySchema.parse({
...apiEntity,
autumn_id: withAutumnId ? entity.internal_id : undefined,
}),
apiEntity: ApiEntitySchema.parse(
skipCustomerMerge ? pureApiEntity : apiEntity,
),
};
};

View File

@@ -1,10 +1,10 @@
import type { ApiEntity, AppEnv } from "@autumn/shared";
import { SET_ENTITY_SCRIPT } from "@lua/luaScripts.js";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { getApiEntityBase } from "../apiEntityUtils/getApiEntityBase.js";
import { SET_ENTITY_SCRIPT } from "./entityLuaScripts/luaScripts.js";
import { buildCachedApiEntityKey } from "./getCachedApiEntity.js";
/**

View File

@@ -24,14 +24,20 @@ export const getApiEntity = async ({
skipCache?: boolean;
}): Promise<ApiEntity> => {
// Get base entity (cacheable or direct from DB)
const { apiEntity: baseEntity } = await getCachedApiEntity({
let { apiEntity: baseEntity } = await getCachedApiEntity({
ctx,
customerId,
entityId,
withAutumnId,
skipCache,
fullCus,
});
// Clean api entity
baseEntity = {
...baseEntity,
autumn_id: withAutumnId ? baseEntity.autumn_id : undefined,
};
// Get expand fields (not cacheable)
const apiEntityExpand = await getApiEntityExpand({
ctx,

View File

@@ -1,5 +1,5 @@
import {
type CreateEntity,
type CreateEntityParams,
ErrCode,
type FullCusProduct,
type FullCustomer,
@@ -27,11 +27,13 @@ export const updateLinkedCusEnt = async ({
}: {
db: DrizzleCli;
linkedCusEnt: FullCustomerEntitlement;
inputEntities: CreateEntity[];
inputEntities: CreateEntityParams[];
entityToReplacement: Record<string, string>;
}) => {
const newEntities = structuredClone(linkedCusEnt.entities) || {};
for (const entity of inputEntities) {
if (!entity.id) continue;
const replaceableId = entityToReplacement[entity.id];
const replaceableInEntities = replaceableId
? newEntities[replaceableId]
@@ -73,7 +75,7 @@ export const createEntityForCusProduct = async ({
req: ExtendedRequest;
customer: FullCustomer;
cusProduct: FullCusProduct;
inputEntities: CreateEntity[];
inputEntities: CreateEntityParams[];
logger: any;
fromAutoCreate?: boolean;
}) => {
@@ -82,7 +84,7 @@ export const createEntityForCusProduct = async ({
acc[entity.feature_id!] = [...(acc[entity.feature_id!] || []), entity];
return acc;
},
{} as Record<string, CreateEntity[]>,
{} as Record<string, CreateEntityParams[]>,
);
const { db, env, org, features } = req;
@@ -161,7 +163,7 @@ export const createEntityForCusProduct = async ({
const entityToReplacement: Record<string, string> = {};
for (let i = 0; i < deletedReplaceables.length; i++) {
const replaceable = deletedReplaceables[i];
entityToReplacement[inputEntities[i].id] = replaceable.id;
entityToReplacement[inputEntities[i].id!] = replaceable.id;
if (i >= inputEntities.length) {
break;

View File

@@ -12,6 +12,7 @@ import { createRoute } from "../../../../honoMiddlewares/routeHandler.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { ExtendedRequest } from "../../../../utils/models/Request.js";
import { EntityService } from "../../../api/entities/EntityService.js";
import { getApiEntity } from "../../entityUtils/apiEntityUtils/getApiEntity.js";
import { constructEntity } from "../../entityUtils/entityUtils.js";
import { createEntityForCusProduct } from "./createEntityForCusProduct.js";
import { validateAndGetInputEntities } from "./getInputEntities.js";
@@ -89,25 +90,26 @@ export const createEntities = async ({
newEntities.push(...insertedEntities);
// // Get api entity for each entity...
// const apiEntities = [];
// for (const entity of newEntities) {
// // Cloned fullCus
// const clonedFullCus = structuredClone(fullCus);
// clonedFullCus.entity = entity;
// const apiEntity = await getApiEntity({
// ctx,
// expand: [],
// customerId,
// entityId: entity.id,
// fullCus: clonedFullCus,
// withAutumnId,
// });
// apiEntities.push(apiEntity);
// }
return newEntities;
// Get api entity for each entity...
const apiEntities = [];
for (const entity of newEntities) {
// Cloned fullCus
// return apiEntities;
const clonedFullCus = structuredClone(fullCus);
clonedFullCus.entity = entity;
const apiEntity = await getApiEntity({
ctx,
expand: [],
customerId,
entityId: entity.id,
fullCus: clonedFullCus,
withAutumnId,
skipCache: true,
});
apiEntities.push(apiEntity);
}
return apiEntities;
};
export const handleCreateEntity = createRoute({

View File

@@ -7,7 +7,7 @@ export const handleGetEntity = createRoute({
handler: async (c) => {
const { customer_id, entity_id } = c.req.param();
const ctx = c.get("ctx");
const { expand, skip_cache } = c.req.valid("query");
const { expand, skip_cache, with_autumn_id } = c.req.valid("query");
const apiEntity = await getApiEntity({
ctx,
@@ -15,6 +15,7 @@ export const handleGetEntity = createRoute({
entityId: entity_id,
expand,
skipCache: skip_cache,
withAutumnId: with_autumn_id,
});
return c.json(apiEntity);

View File

@@ -11,8 +11,9 @@ import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigration
import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js";
import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js";
import { generateId } from "@/utils/genUtils.js";
import { queue, workerRedis } from "./initBullMq.js";
import { createWorkerContext } from "../createWorkerContext.js";
import { JobName } from "../JobName.js";
import { queue, workerRedis } from "./initBullMq.js";
const NUM_WORKERS = 10;
@@ -38,6 +39,11 @@ const initWorker = ({ id, db }: { id: number; db: DrizzleCli }) => {
},
});
const ctx = await createWorkerContext({
db,
logger: workerLogger,
});
try {
if (job.name === JobName.DetectBaseVariant) {
await detectBaseVariant({
@@ -87,9 +93,8 @@ const initWorker = ({ id, db }: { id: number; db: DrizzleCli }) => {
if (job.name === JobName.SyncBalanceBatch) {
await runSyncBalanceBatch({
db,
ctx,
payload: job.data,
logger: workerLogger as Logger,
});
return;
}
@@ -167,4 +172,3 @@ export const initWorkers = async () => {
return workers;
};

View File

@@ -1,34 +1,55 @@
import {
type AppEnv,
AuthType,
createdAtToVersion,
type Feature,
type Organization,
} from "@autumn/shared";
import { type AppEnv, AuthType, createdAtToVersion } from "@autumn/shared";
import type { DrizzleCli } from "../db/initDrizzle.js";
import type { Logger } from "../external/logtail/logtailUtils.js";
import type { AutumnContext } from "../honoUtils/HonoEnv.js";
import { OrgService } from "../internal/orgs/OrgService.js";
import { generateId } from "../utils/genUtils.js";
export const createWorkerContext = ({
export const createWorkerContext = async ({
db,
org,
orgId,
env,
features,
// features,
logger,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
features: Feature[];
orgId?: string;
env?: AppEnv;
// features: Feature[];
logger: Logger;
}) => {
if (!orgId || !env) return;
// Fetch org with features once for all items
const orgData = await OrgService.getWithFeatures({
db,
orgId,
env: env as AppEnv,
});
if (!orgData) {
throw new Error(`Organization not found: ${orgId}, env: ${env}`);
}
const { org, features } = orgData;
const workerLogger = logger.child({
context: {
context: {
org_id: org?.id,
org_slug: org?.slug,
env: env,
authType: AuthType.Worker,
},
},
});
const ctx: AutumnContext = {
org,
env,
features,
db,
logger,
logger: workerLogger,
id: generateId("job"),
timestamp: Date.now(),

View File

@@ -15,6 +15,7 @@ import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigration
import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js";
import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js";
import { generateId } from "@/utils/genUtils.js";
import { createWorkerContext } from "./createWorkerContext.js";
import { QUEUE_URL, sqs } from "./initSqs.js";
import { JobName } from "./JobName.js";
@@ -56,7 +57,13 @@ const processMessage = async ({
},
},
});
// workerLogger.info(`Received message ${message.MessageId}`);
const ctx = await createWorkerContext({
db,
orgId: job.data.orgId,
env: job.data.env,
logger: workerLogger,
});
try {
if (job.name === JobName.DetectBaseVariant) {
@@ -109,9 +116,8 @@ const processMessage = async ({
if (job.name === JobName.SyncBalanceBatch) {
await runSyncBalanceBatch({
db,
ctx,
payload: job.data,
logger: workerLogger as Logger,
});
return;
}

View File

@@ -1,5 +1,5 @@
import { SendMessageCommand } from "@aws-sdk/client-sqs";
import type { AppEnv, EventInsert, Price } from "@autumn/shared";
import { SendMessageCommand } from "@aws-sdk/client-sqs";
import { generateId } from "@/utils/genUtils.js";
import { JobName } from "./JobName.js";
@@ -11,6 +11,8 @@ export interface Payloads {
env: AppEnv;
};
[JobName.SyncBalanceBatch]: {
orgId: string;
env: AppEnv;
items: Array<{
customerId: string;
featureId: string;
@@ -45,7 +47,9 @@ const initializeQueue = async () => {
const { queue } = await import("./bullmq/initBullMq.js");
bullmqQueue = queue;
} else {
throw new Error("No queue configured. Set either SQS_QUEUE_URL or QUEUE_URL");
throw new Error(
"No queue configured. Set either SQS_QUEUE_URL or QUEUE_URL",
);
}
};

View File

@@ -49,30 +49,55 @@ export const tryRedisRead = async <T>(
}
};
/**
* Helper function to normalize empty objects {} to empty arrays []
* Lua's cjson converts empty arrays to empty objects, so we need to fix this
*/
const normalizeArray = (value: unknown): unknown => {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).length === 0
) {
return [];
}
return value;
};
/**
* Fix Lua cjson quirks when parsing cached data:
* - Converts products[].items from {} back to [] if it's an empty object
* - Converts empty objects {} back to [] for all array fields
* - Converts usage_limit: 0 to undefined (when all sources were undefined)
*/
export const normalizeCachedData = <T extends ApiCustomer | ApiEntity>(
data: T,
): T => {
// Normalize top-level products array
if (data.products) {
if (!Array.isArray(data.products)) {
data.products = [];
}
// Normalize nested arrays in products
for (const product of data.products) {
if (
product.items &&
typeof product.items === "object" &&
!Array.isArray(product.items) &&
Object.keys(product.items).length === 0
) {
product.items = [];
// Normalize product.items array
if (product.items) {
product.items = normalizeArray(product.items) as typeof product.items;
}
// Normalize product.stripe_subscription_ids array
if (product.stripe_subscription_ids) {
product.stripe_subscription_ids = normalizeArray(
product.stripe_subscription_ids,
) as typeof product.stripe_subscription_ids;
}
}
}
// Convert empty entities to []
if ("entities" in data && data.entities && !Array.isArray(data.entities)) {
data.entities = [];
// Normalize entities array (included in Lua script)
if ("entities" in data && data.entities) {
data.entities = normalizeArray(data.entities) as typeof data.entities;
}
// Fix usage_limit: 0 -> undefined
@@ -80,7 +105,7 @@ export const normalizeCachedData = <T extends ApiCustomer | ApiEntity>(
if (data.features) {
for (const featureId in data.features) {
const feature = data.features[featureId];
if (feature.usage_limit === 0) {
if (feature.usage_limit === 0 || feature.usage_limit === null) {
feature.usage_limit = undefined;
}
@@ -110,6 +135,13 @@ export const normalizeCachedData = <T extends ApiCustomer | ApiEntity>(
// }
}
}
// Normalize feature.credit_schema array
if (feature.credit_schema) {
feature.credit_schema = normalizeArray(
feature.credit_schema,
) as typeof feature.credit_schema;
}
}
}

View File

@@ -76,6 +76,7 @@ export const constructPrepaidItem = ({
rolloverConfig,
usageLimit,
intervalCount = 1,
resetUsageWhenEnabled,
}: {
featureId: string;
price?: number;
@@ -87,6 +88,7 @@ export const constructPrepaidItem = ({
rolloverConfig?: RolloverConfig;
usageLimit?: number;
intervalCount?: number;
resetUsageWhenEnabled?: boolean;
}) => {
const item: ProductItem = {
feature_id: featureId,
@@ -104,6 +106,7 @@ export const constructPrepaidItem = ({
...(rolloverConfig ? { rollover: rolloverConfig } : {}),
},
usage_limit: usageLimit,
reset_usage_when_enabled: resetUsageWhenEnabled,
};
return item;

View File

@@ -58,7 +58,7 @@ export const initCustomerV3 = async ({
name,
email,
// @ts-expect-error
fingerprint: customerData?.fingerprint || fingerprint_,
fingerprint: customerData?.fingerprint,
stripe_id: stripeCus.id,
disable_default: !withDefault,
default_product_id: defaultProductId,

View File

@@ -1,36 +1,35 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type AppEnv,
type Customer,
CouponDurationType,
type CreateReward,
LegacyVersion,
type Organization,
RewardType,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import type Stripe from "stripe";
import { rewards } from "tests/global.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { timeout } from "tests/utils/genUtils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { createReward } from "tests/utils/productUtils.js";
import {
advanceTestClock,
completeCheckoutForm,
getDiscount,
} from "tests/utils/stripeUtils.js";
import {
addPrefixToProducts,
getBasePrice,
} from "tests/utils/testProductUtils/testProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "coupon1";
@@ -39,6 +38,24 @@ const pro = constructProduct({
items: [constructArrearItem({ featureId: TestFeature.Words })],
});
// Create reward inline - matching rolloverAll config from global.ts
const rewardId = `${testCase}rolloverAll`;
const promoCode = `${testCase}rolloverAllCode`;
const reward: CreateReward = {
id: rewardId,
name: "Rollover All",
type: RewardType.InvoiceCredits,
promo_codes: [{ code: promoCode }],
discount_config: {
discount_value: 1000,
duration_type: CouponDurationType.Forever,
duration_value: 0,
should_rollover: true,
apply_to_all: true,
price_ids: [],
},
};
const simulateOneCycle = async ({
customerId,
db,
@@ -100,13 +117,9 @@ const simulateOneCycle = async ({
expect(cusDiscount).toBeDefined();
expect(getOriginalCouponId(cusDiscount.coupon?.id)).toBe(
rewards.rolloverAll.id,
);
expect(getOriginalCouponId(cusDiscount.coupon?.id)).toBe(rewardId);
expect(cusDiscount.coupon?.amount_off).toBe(
Math.round(couponAmount * 100),
);
expect(cusDiscount.coupon?.amount_off).toBe(Math.round(couponAmount * 100));
return {
couponAmount,
@@ -121,7 +134,6 @@ describe(
() => {
const customerId = "coupon1";
let stripeCli: Stripe;
let customer: Customer;
let testClockId: string;
let db: DrizzleCli;
let org: Organization;
@@ -129,8 +141,8 @@ describe(
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let couponAmount = rewards.rolloverAll.discount_config.discount_value;
let curUnix = new Date().getTime();
let couponAmount = reward.discount_config!.discount_value;
let curUnix = Date.now();
beforeAll(async () => {
db = ctx.db;
@@ -138,26 +150,28 @@ describe(
env = ctx.env;
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
customerId,
});
const res = await initCustomerV3({
ctx,
customerId,
});
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
products: [pro],
await createReward({
orgId: org.id,
env,
db,
autumn,
reward,
productId: pro.id,
});
testClockId = res.testClockId;
customer = res.customer;
});
// CYCLE 0
@@ -167,11 +181,7 @@ describe(
product_id: pro.id,
});
await completeCheckoutForm(
res.checkout_url,
undefined,
rewards.rolloverAll.id,
);
await completeCheckoutForm(res.checkout_url, undefined, promoCode);
await timeout(10000);
@@ -182,15 +192,17 @@ describe(
expect(customer.invoices![0].total).toBe(0);
console.log("Customer", customer);
const cusDiscount = await getDiscount({
stripeCli,
stripeId: customer.stripe_id!,
});
// console.log("CusDiscount", cusDiscount);
expect(cusDiscount).toBeDefined();
expect(getOriginalCouponId(cusDiscount.coupon?.id)).toBe(
rewards.rolloverAll.id,
);
expect(getOriginalCouponId(cusDiscount.coupon?.id)).toBe(rewardId);
expect(cusDiscount.coupon?.amount_off).toBe(couponAmount * 100);
});
@@ -204,7 +216,7 @@ describe(
autumn,
testClockId,
couponAmount,
curUnix: new Date().getTime(),
curUnix: Date.now(),
});
couponAmount = res.couponAmount;
@@ -213,7 +225,7 @@ describe(
// CYCLE 1
test("should run another cycle and have correct invoice + coupon amount", async () => {
const res = await simulateOneCycle({
await simulateOneCycle({
customerId,
db,
org,

View File

@@ -1,3 +1,4 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type AppEnv,
CouponDurationType,
@@ -6,7 +7,6 @@ import {
type Organization,
RewardType,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import { Decimal } from "decimal.js";
@@ -18,11 +18,11 @@ import { expectProductAttached } from "tests/utils/expectUtils/expectProductAtta
import { timeout } from "tests/utils/genUtils.js";
import { createProducts, createReward } from "tests/utils/productUtils.js";
import { completeCheckoutForm, getDiscount } from "tests/utils/stripeUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import {
addPrefixToProducts,
getBasePrice,
} from "tests/utils/testProductUtils/testProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getOriginalCouponId } from "@/internal/rewards/rewardUtils.js";

View File

@@ -1,3 +1,4 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type AppEnv,
CouponDurationType,
@@ -6,17 +7,16 @@ import {
type Organization,
RewardType,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import type Stripe from "stripe";
import { TestFeature } from "tests/setup/v2Features.js";
import { expectAttachCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { createProducts, createReward } from "tests/utils/productUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import {
addPrefixToProducts,
getBasePrice,
} from "tests/utils/testProductUtils/testProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import {
@@ -25,6 +25,7 @@ import {
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached.js";
const pro = constructProduct({
type: "pro",
@@ -136,7 +137,7 @@ describe(chalk.yellow(`${testCase} - Testing attach coupon`), () => {
});
const customer = await autumn.customers.get(customerId);
expectAttachCorrect({
expectProductAttached({
customer,
product: oneOff,
});
@@ -155,9 +156,10 @@ describe(chalk.yellow(`${testCase} - Testing attach coupon`), () => {
});
const customer = await autumn.customers.get(customerId);
expectAttachCorrect({
expectProductAttached({
customer,
product: oneOff,
quantity: 2,
});
expect(customer.invoices!.length).toBe(3);

View File

@@ -1,19 +1,19 @@
import { LegacyVersion } from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import type Stripe from "stripe";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
const testCase = "customInterval1";
@@ -54,6 +54,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval and interva
beforeAll(async () => {
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [pro, premium],
prefix: testCase,
customerId,
});
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId,
@@ -62,13 +69,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval and interva
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro, premium],
prefix: testCase,
customerId,
});
testClockId = testClockId1!;
});

View File

@@ -1,19 +1,19 @@
import { LegacyVersion } from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { addHours, addMonths } from "date-fns";
import type Stripe from "stripe";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
const testCase = "customInterval2";
@@ -37,6 +37,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear p
beforeAll(async () => {
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
customerId,
});
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId,
@@ -45,13 +52,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on arrear p
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
customerId,
});
testClockId = testClockId1!;
});

View File

@@ -1,12 +1,12 @@
import { LegacyVersion } from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { addDays, addMonths } from "date-fns";
import type Stripe from "stripe";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import {
@@ -17,9 +17,9 @@ import {
constructProduct,
constructRawProduct,
} from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
const testCase = "customInterval3";
@@ -57,6 +57,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on add on m
beforeAll(async () => {
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [pro, addOn],
prefix: testCase,
customerId,
});
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId,
@@ -65,13 +72,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing custom interval on add on m
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro, addOn],
prefix: testCase,
customerId,
});
testClockId = testClockId1!;
});

View File

@@ -1,15 +1,15 @@
import { LegacyVersion } from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { addMonths } from "date-fns";
import type Stripe from "stripe";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import {
expectDowngradeCorrect,
expectNextCycleCorrect,
} from "tests/utils/expectUtils/expectScheduleUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
@@ -51,6 +51,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrades for custom inter
beforeAll(async () => {
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [pro, premium],
prefix: testCase,
customerId,
});
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId,
@@ -59,13 +66,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing downgrades for custom inter
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro, premium],
prefix: testCase,
customerId,
});
testClockId = testClockId1!;
});

View File

@@ -1,11 +1,11 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { LegacyVersion } from "@autumn/shared";
import type { Customer } from "autumn-js";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import type Stripe from "stripe";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
@@ -55,14 +55,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features wit
beforeAll(async () => {
stripeCli = ctx.stripeCli;
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: true,
});
await initProductsV0({
ctx,
products: [pro],
@@ -70,6 +62,13 @@ describe(`${chalk.yellowBright(`${testCase}: Testing multi interval features wit
customerId,
});
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: true,
});
testClockId = testClockId1!;
});

View File

@@ -1,75 +1,60 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { getMainCusProduct } from "tests/utils/cusProductUtils/cusProductUtils.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "advanced-misc1";
// UNCOMMENT FROM HERE
const pro = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
});
describe(`${chalk.yellowBright("advancedOthers1: Testing convert collection method from send_invoice")}`, () => {
const customerId = "advancedOthers1";
describe(`${chalk.yellowBright(
`${testCase}: Testing convert collection method from send_invoice`,
)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let stripeCli: Stripe;
let testClockId: string;
let curUnix: number;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
beforeAll(async () => {
db = ctx.db;
org = ctx.org;
env = ctx.env;
stripeCli = ctx.stripeCli;
stripeCli = this.stripeCli;
addPrefixToProducts({
await initProductsV0({
ctx,
products: [pro],
prefix: customerId,
});
await createProducts({
autumn: autumnJs,
products: [pro],
db,
orgId: org.id,
env,
prefix: testCase,
customerId,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
await initCustomerV3({
ctx,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach pro product and pay for it", async () => {
test("should attach pro product and pay for it", async () => {
const res = await autumn.attach({
customer_id: customerId,
product_id: pro.id,
@@ -77,7 +62,7 @@ describe(`${chalk.yellowBright("advancedOthers1: Testing convert collection meth
enable_product_immediately: true,
});
expect(res.invoice).to.exist;
expect(res.invoice).toBeDefined();
const customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
@@ -85,12 +70,12 @@ describe(`${chalk.yellowBright("advancedOthers1: Testing convert collection meth
});
const invoiceStripeId = res.invoice.stripe_id;
const invoice = await stripeCli.invoices.finalizeInvoice(invoiceStripeId);
await stripeCli.invoices.finalizeInvoice(invoiceStripeId);
await stripeCli.invoices.pay(invoiceStripeId);
});
it("should have collection method charge automatically", async () => {
test("should have collection method charge automatically", async () => {
await timeout(5000);
const cusProduct = await getMainCusProduct({
@@ -98,7 +83,7 @@ describe(`${chalk.yellowBright("advancedOthers1: Testing convert collection meth
customerId,
orgId: org.id,
env,
productGroup: pro.group,
productGroup: pro.group ?? undefined,
});
const sub = await cusProductToSub({
@@ -106,6 +91,6 @@ describe(`${chalk.yellowBright("advancedOthers1: Testing convert collection meth
stripeCli,
});
expect(sub?.collection_method).to.equal("charge_automatically");
expect(sub?.collection_method ?? undefined).toBe("charge_automatically");
});
});

View File

@@ -0,0 +1,236 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type AppEnv, LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import {
getPrepaidCusEnt,
getUsageCusEnt,
} from "tests/utils/cusProductUtils/cusEntSearchUtils.js";
import { getMainCusProduct } from "tests/utils/cusProductUtils/cusProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import {
constructArrearItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
// Scenario 1: prepaid + pay per use monthly -> prepaid + pay per use monthly
const pro = constructProduct({
id: "multiFeature1Pro",
type: "pro",
excludeBase: true,
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 50,
price: 10,
billingUnits: 1,
}),
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 0,
price: 0.5,
billingUnits: 1,
}),
],
});
const premium = constructProduct({
id: "multiFeature1Premium",
type: "premium",
excludeBase: true,
items: [
// Prepaid
constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 100,
price: 15,
billingUnits: 1,
resetUsageWhenEnabled: false,
}),
// Pay per use
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 0,
price: 1,
billingUnits: 1,
}),
],
});
export const getPrepaidAndUsageCusEnts = async ({
customerId,
db,
orgId,
env,
featureId,
}: {
customerId: string;
db: DrizzleCli;
orgId: string;
env: AppEnv;
featureId: string;
}) => {
const mainCusProduct = await getMainCusProduct({
customerId,
db,
orgId,
env,
});
const prepaidCusEnt = getPrepaidCusEnt({
cusProduct: mainCusProduct!,
featureId,
});
const usageCusEnt = getUsageCusEnt({
cusProduct: mainCusProduct!,
featureId,
});
return { prepaidCusEnt, usageCusEnt };
};
const testCase = "multiFeature1";
describe(`${chalk.yellowBright(
"multiFeature1: Testing prepaid + pay per use -> prepaid + pay per use",
)}`, () => {
const autumn: AutumnInt = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
const autumn2: AutumnInt = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: LegacyVersion.v1_2,
});
const customerId = testCase;
let totalUsage = 0;
const prepaidQuantity = 10;
const prepaidAllowance = 50 + prepaidQuantity; // pro.items[0].includedUsage + prepaidQuantity
const premiumPrepaidAllowance = 100 + prepaidQuantity; // premium.items[0].includedUsage + prepaidQuantity
const optionsList = [
{
feature_id: TestFeature.Messages,
quantity: prepaidQuantity,
},
];
beforeAll(async () => {
await initProductsV0({
ctx,
products: [pro, premium],
prefix: testCase,
customerId,
});
await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: false,
});
});
test("should attach pro product to customer", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
options: optionsList,
});
const { prepaidCusEnt, usageCusEnt } = await getPrepaidAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(prepaidCusEnt?.balance).toBe(prepaidAllowance);
expect(usageCusEnt?.balance).toBe(0); // pro.items[1].includedUsage
});
test("should use prepaid allowance first", async () => {
const value = 60;
await autumn.track({
customer_id: customerId,
value,
feature_id: TestFeature.Messages,
});
totalUsage += value;
await timeout(3000);
const { prepaidCusEnt, usageCusEnt } = await getPrepaidAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(prepaidCusEnt?.balance).toBe(prepaidAllowance - value);
expect(usageCusEnt?.balance).toBe(0); // pro.items[1].includedUsage
});
test("should have correct usage / invoice after upgrade", async () => {
const value = 60;
await autumn.track({
customer_id: customerId,
value,
feature_id: TestFeature.Messages,
});
// totalUsage += value;
await timeout(2500);
const { usageCusEnt } = await getPrepaidAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
await autumn.attach({
customer_id: customerId,
product_id: premium.id,
options: optionsList,
});
const { prepaidCusEnt, usageCusEnt: newUsageCusEnt } =
await getPrepaidAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
// Check invoice too
const { invoices } = await autumn2.customers.get(customerId);
const invoice1Amount = 15 * prepaidQuantity - 10 * prepaidQuantity; // premium.items[0].price * prepaidQuantity - pro.items[0].price * prepaidQuantity
const invoice0Amount = value * 0.5; // value * pro.items[1].price
const totalAmount = invoice1Amount + invoice0Amount;
expect(invoices![0].total).toBe(totalAmount);
// const leftover = premiumPrepaidAllowance - totalUsage + value;
// console.log(
// `Premium prepaid allowance: ${premiumPrepaidAllowance} - totalUsage: ${totalUsage}`,
// );
// console.log(`prepaidCusEnt?.balance: ${prepaidCusEnt?.balance}`);
expect(prepaidCusEnt?.balance).toBe(premiumPrepaidAllowance - totalUsage);
expect(newUsageCusEnt?.balance).toBe(0);
});
});

View File

@@ -0,0 +1,200 @@
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type AppEnv, LegacyVersion } from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import {
getLifetimeFreeCusEnt,
getUsageCusEnt,
} from "tests/utils/cusProductUtils/cusEntSearchUtils.js";
import { getMainCusProduct } from "tests/utils/cusProductUtils/cusProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import {
constructArrearItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
// Scenario 1: lifetime + pay per use monthly -> pay per use monthly
const pro = constructProduct({
id: "multiFeature2Pro",
type: "pro",
excludeBase: true,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 50,
interval: null,
}),
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 0,
price: 0.5,
billingUnits: 1,
}),
],
});
const premium = constructProduct({
id: "multiFeature2Premium",
type: "premium",
excludeBase: true,
items: [
// Pay per use
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 0,
price: 1,
billingUnits: 1,
}),
],
});
export const getLifetimeAndUsageCusEnts = async ({
customerId,
db,
orgId,
env,
featureId,
}: {
customerId: string;
db: DrizzleCli;
orgId: string;
env: AppEnv;
featureId: string;
}) => {
const mainCusProduct = await getMainCusProduct({
customerId: customerId,
db,
orgId,
env,
});
const lifetimeCusEnt = getLifetimeFreeCusEnt({
cusProduct: mainCusProduct!,
featureId,
});
const usageCusEnt = getUsageCusEnt({
cusProduct: mainCusProduct!,
featureId,
});
return { lifetimeCusEnt, usageCusEnt };
};
const testCase = "multiFeature2";
describe(`${chalk.yellowBright(
"multiFeature2: Testing lifetime + pay per use -> pay per use",
)}`, () => {
const autumn: AutumnInt = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
const autumn2: AutumnInt = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: LegacyVersion.v1_2,
});
const customerId = testCase;
let totalUsage = 0;
beforeAll(async () => {
await initProductsV0({
ctx,
products: [pro, premium],
prefix: testCase,
customerId,
});
await initCustomerV3({
ctx,
customerId,
attachPm: "success",
withTestClock: false,
});
});
test("should attach pro product to customer", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(lifetimeCusEnt?.balance).toBe(50); // pro.items[0].includedUsage
expect(usageCusEnt?.balance).toBe(0); // pro.items[1].includedUsage
});
test("should use lifetime allowance first", async () => {
const value = 50; // pro.items[0].includedUsage
await autumn.events.send({
customerId,
value,
featureId: TestFeature.Messages,
});
totalUsage += value;
await timeout(3000);
const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(lifetimeCusEnt?.balance).toBe(50 - value); // pro.items[0].includedUsage - value
expect(usageCusEnt?.balance).toBe(0); // pro.items[1].includedUsage
});
test("should have correct usage after upgrade", async () => {
const value = 20;
await autumn.track({
customer_id: customerId,
value,
feature_id: TestFeature.Messages,
});
await autumn.attach({
customer_id: customerId,
product_id: premium.id,
});
// return;
const { lifetimeCusEnt, usageCusEnt: newUsageCusEnt } =
await getLifetimeAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(lifetimeCusEnt).toBeUndefined();
expect(newUsageCusEnt?.balance).toBe(0);
// Check invoice too
// const res = await autumn2.customers.get(customerId);
// const invoices = res.invoices;
// const invoice0Amount = value * 0.5; // value * pro.items[1].price
// expect(invoices![0].total).toBe(invoice0Amount);
});
});

View File

@@ -0,0 +1,177 @@
/** biome-ignore-all lint/suspicious/noExportsInTest: needed */
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type AppEnv } from "@autumn/shared";
import chalk from "chalk";
import { addMonths } from "date-fns";
import { TestFeature } from "tests/setup/v2Features.js";
import {
getLifetimeFreeCusEnt,
getUsageCusEnt,
} from "tests/utils/cusProductUtils/cusEntSearchUtils.js";
import { getMainCusProduct } from "tests/utils/cusProductUtils/cusProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import {
constructArrearItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
// Scenario 1: lifetime + pay per use monthly -> lifetime + pay per use monthly
const pro = constructProduct({
type: "pro",
excludeBase: true,
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 50,
interval: null,
}),
constructArrearItem({
featureId: TestFeature.Messages,
includedUsage: 0,
price: 0.5,
billingUnits: 1,
}),
],
});
export const getLifetimeAndUsageCusEnts = async ({
customerId,
db,
orgId,
env,
featureId,
}: {
customerId: string;
db: DrizzleCli;
orgId: string;
env: AppEnv;
featureId: string;
}) => {
const mainCusProduct = await getMainCusProduct({
customerId,
db,
orgId,
env,
});
const lifetimeCusEnt = getLifetimeFreeCusEnt({
cusProduct: mainCusProduct!,
featureId,
});
const usageCusEnt = getUsageCusEnt({
cusProduct: mainCusProduct!,
featureId,
});
return { lifetimeCusEnt, usageCusEnt };
};
const testCase = "multiFeature3";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
`${testCase}: Testing lifetime + pay per use, advance test clock`,
)}`, () => {
const autumn: AutumnInt = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
const customerId = testCase;
let totalUsage = 0;
let testClockId: string;
beforeAll(async () => {
await initProductsV0({
ctx,
products: [pro],
prefix: testCase,
customerId,
});
const res = await initCustomerV3({
ctx,
customerId,
customerData: {},
attachPm: "success",
withTestClock: true,
});
testClockId = res.testClockId!;
});
test("should attach pro product to customer", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(lifetimeCusEnt?.balance).toBe(50); // pro.items[0].includedUsage
expect(usageCusEnt?.balance).toBe(0); // pro.items[1].includedUsage
});
const overageValue = 30;
test("should use lifetime allowance + overage", async () => {
let value = 50; // pro.items[0].includedUsage
value += overageValue;
await autumn.track({
customer_id: customerId,
value,
feature_id: TestFeature.Messages,
});
totalUsage += value;
await timeout(3000);
const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(lifetimeCusEnt?.balance).toBe(0);
expect(usageCusEnt?.balance).toBe(-overageValue);
});
test("cycle 1:should have correct usage after first cycle", async () => {
const advanceTo = addMonths(new Date(), 1).getTime();
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId,
advanceTo,
waitForSeconds: 20,
});
const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({
customerId,
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
featureId: TestFeature.Messages,
});
expect(lifetimeCusEnt?.balance).toBe(0);
expect(usageCusEnt?.balance).toBe(0);
});
});

View File

@@ -1,292 +0,0 @@
import {
type AppEnv,
ErrCode,
type Organization,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import { addDays } from "date-fns";
import type { Stripe } from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { timeout } from "tests/utils/genUtils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { products, referralPrograms } from "../../global.js";
const pro = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
trial: true,
});
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals1: Testing referrals (on checkout)",
)}`, () => {
const mainCustomerId = "main-referral-1";
const alternateCustomerId = "alternate-referral-1";
const redeemers = ["referral1-r1", "referral1-r2", "referral1-r3"];
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
const redemptions: RewardRedemption[] = [];
let mainCustomer: any;
let db: DrizzleCli;
let org: Organization;
let env: AppEnv;
before(async function () {
await setupBefore(this);
stripeCli = this.stripeCli;
db = this.db;
org = this.org;
env = this.env;
addPrefixToProducts({
products: [pro],
prefix: mainCustomerId,
});
await createProducts({
autumn: this.autumnJs,
products: [pro],
db,
orgId: org.id,
env,
customerId: mainCustomerId,
});
const res = await initCustomer({
autumn: this.autumnJs,
customerId: mainCustomerId,
fingerprint: "main-referral-1",
db,
org,
env,
attachPm: "success",
});
mainCustomer = res.customer;
testClockId = res.testClockId;
await autumn.attach({
customer_id: mainCustomerId,
product_id: pro.id,
});
const batchCreate = [];
for (const redeemer of redeemers) {
batchCreate.push(
initCustomer({
autumn: this.autumnJs,
customerId: redeemer,
db: this.db,
org: this.org,
env: this.env,
attachPm: "success",
}),
);
}
batchCreate.push(
initCustomer({
autumn: this.autumnJs,
customerId: alternateCustomerId,
fingerprint: "main-referral-1",
db: this.db,
org: this.org,
env: this.env,
attachPm: "success",
}),
);
await Promise.all(batchCreate);
});
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
});
assert.exists(referralCode.code);
// Get referral code again
const referralCode2 = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
});
assert.equal(referralCode2.code, referralCode.code);
});
it("should fail if same customer tries to redeem code again", async () => {
try {
await autumn.referrals.redeem({
customerId: mainCustomerId,
code: referralCode.code,
});
assert.fail("Own customer should not be able to redeem code");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerCannotRedeemOwnCode);
}
try {
await autumn.referrals.redeem({
customerId: alternateCustomerId,
code: referralCode.code,
});
assert.fail(
"Own customer (same fingerprint) should not be able to redeem code",
);
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerCannotRedeemOwnCode);
}
});
it("should create redemption for each redeemer and fail if redeemed again", async () => {
for (const redeemer of redeemers) {
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
redemptions.push(redemption);
}
// Try redeem for redeemer1 again
try {
const redemption1 = await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
assert.fail("Should not be able to redeem again");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
}
});
// return;
it("should be triggered (and applied) when redeemers check out", async () => {
for (let i = 0; i < redeemers.length; i++) {
const redeemer = redeemers[i];
await autumn.attach({
customer_id: redeemer,
product_id: products.pro.id,
});
await timeout(3000);
// Get redemption object
const redemption = await autumn.redemptions.get(redemptions[i].id);
// Check if redemption is triggered
const count = i + 1;
if (count > referralPrograms.onCheckout.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
assert.equal(redemption.triggered, true);
assert.equal(redemption.applied, i === 0);
}
// Check stripe customer
const stripeCus = (await stripeCli.customers.retrieve(
mainCustomer.processor?.id,
)) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null);
}
});
let curTime = new Date();
it("customer should have discount for first purchase", async () => {
curTime = addDays(addDays(curTime, 7), 4);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
});
// 1. Get invoice
const { invoices } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices.length, 2);
assert.equal(invoices[0].total, 0);
});
// it("customer should have discount for second purchase", async function () {
// // 2. Check that customer has another discount
// let stripeCus = (await stripeCli.customers.retrieve(
// mainCustomer.processor?.id,
// )) as Stripe.Customer;
// assert.notEqual(stripeCus.discount, null);
// // 2. Advance test clock to 1 month from start (trigger discount.deleted event)
// curTime = addHours(addMonths(new Date(), 1), 2);
// await advanceTestClock({
// testClockId,
// advanceTo: curTime.getTime(),
// stripeCli,
// });
// // 3. Advance test clock to 1 month + 12 days from start (trigger new invoice)
// curTime = addDays(curTime, 12);
// await advanceTestClock({
// testClockId,
// advanceTo: curTime.getTime(),
// stripeCli,
// });
// // // 3. Get invoice again
// let { invoices: invoices2 } = await autumn.customers.get(mainCustomerId);
// assert.equal(invoices2.length, 3);
// assert.equal(invoices2[0].total, 0);
// });
});
// const { testClockId: testClockId1, customer } =
// await initCustomerWithTestClock({
// customerId: mainCustomerId,
// db: this.db,
// org: this.org,
// env: this.env,
// fingerprint: "main-referral-1",
// });
// testClockId = testClockId1;
// mainCustomer = customer;
// await autumn.attach({
// customer_id: mainCustomerId,
// product_id: products.proWithTrial.id,
// });
// initCustomer({
// customer_data: {
// id: alternateCustomerId,
// name: "Alternate Referral 1",
// email: "alternate-referral-1@example.com",
// fingerprint: "main-referral-1",
// },
// db: this.db,
// org: this.org,
// env: this.env,
// })

View File

@@ -1,34 +1,73 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type AppEnv,
CouponDurationType,
type CreateReward,
type CreateRewardProgram,
ErrCode,
type Organization,
type ReferralCode,
RewardReceivedBy,
type RewardRedemption,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import { addDays } from "date-fns";
import type { Stripe } from "stripe";
import { TestFeature } from "tests/setup/v2Features.js";
import { timeout } from "tests/utils/genUtils.js";
import { createProducts } from "tests/utils/productUtils.js";
import { createReferralProgram } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addPrefixToProducts } from "tests/utils/testProductUtils/testProductUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { products, referralPrograms } from "../../global.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const pro = constructProduct({
const testCase = "referrals1";
const proWithTrial = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
trial: true,
});
const pro = constructProduct({
id: "proNoTrial",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
});
// Reward: 100% discount for 1 month
const monthOffReward: CreateReward = {
id: `${testCase}MonthOff`,
name: "Month Off",
type: RewardType.PercentageDiscount,
promo_codes: [],
discount_config: {
discount_value: 100,
duration_type: CouponDurationType.Months,
duration_value: 1,
apply_to_all: true,
price_ids: [],
},
};
// Referral program: triggers on checkout, applies to pro and proWithTrial
const onCheckoutProgram: CreateRewardProgram = {
id: `${testCase}OnCheckout`,
when: RewardTriggerEvent.Checkout,
product_ids: [proWithTrial.id, pro.id],
internal_reward_id: monthOffReward.id,
max_redemptions: 2,
received_by: RewardReceivedBy.Referrer,
};
describe(`${chalk.yellowBright(
"referrals1: Testing referrals (on checkout)",
)}`, () => {
@@ -52,18 +91,26 @@ describe(`${chalk.yellowBright(
org = ctx.org;
env = ctx.env;
addPrefixToProducts({
products: [pro],
prefix: mainCustomerId,
await initProductsV0({
ctx,
products: [proWithTrial, pro],
prefix: testCase,
customerId: mainCustomerId,
});
await createProducts({
autumn: new AutumnInt({ secretKey: ctx.orgSecretKey }),
products: [pro],
// Create referral program - product IDs are already prefixed by initProductsV0
const referralProgram: CreateRewardProgram = {
...onCheckoutProgram,
product_ids: [proWithTrial.id, pro.id],
};
await createReferralProgram({
db,
orgId: org.id,
env,
customerId: mainCustomerId,
autumn: new AutumnInt({ secretKey: ctx.orgSecretKey }),
reward: monthOffReward,
rewardProgram: referralProgram,
});
const res = await initCustomerV3({
@@ -78,7 +125,7 @@ describe(`${chalk.yellowBright(
await autumn.attach({
customer_id: mainCustomerId,
product_id: pro.id,
product_id: proWithTrial.id,
});
const batchCreate = [];
@@ -106,7 +153,7 @@ describe(`${chalk.yellowBright(
test("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
referralId: onCheckoutProgram.id,
});
expect(referralCode.code).toBeDefined();
@@ -114,7 +161,7 @@ describe(`${chalk.yellowBright(
// Get referral code again
const referralCode2 = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.onCheckout.id,
referralId: onCheckoutProgram.id,
});
expect(referralCode2.code).toBe(referralCode.code);
@@ -129,7 +176,9 @@ describe(`${chalk.yellowBright(
throw new Error("Own customer should not be able to redeem code");
} catch (error) {
expect(error).toBeInstanceOf(AutumnError);
expect((error as AutumnError).code).toBe(ErrCode.CustomerCannotRedeemOwnCode);
expect((error as AutumnError).code).toBe(
ErrCode.CustomerCannotRedeemOwnCode,
);
}
try {
@@ -142,7 +191,9 @@ describe(`${chalk.yellowBright(
);
} catch (error) {
expect(error).toBeInstanceOf(AutumnError);
expect((error as AutumnError).code).toBe(ErrCode.CustomerCannotRedeemOwnCode);
expect((error as AutumnError).code).toBe(
ErrCode.CustomerCannotRedeemOwnCode,
);
}
});
@@ -158,14 +209,16 @@ describe(`${chalk.yellowBright(
// Try redeem for redeemer1 again
try {
const redemption1 = await autumn.referrals.redeem({
await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
throw new Error("Should not be able to redeem again");
} catch (error) {
expect(error).toBeInstanceOf(AutumnError);
expect((error as AutumnError).code).toBe(ErrCode.CustomerAlreadyRedeemedReferralCode);
expect((error as AutumnError).code).toBe(
ErrCode.CustomerAlreadyRedeemedReferralCode,
);
}
});
@@ -175,7 +228,7 @@ describe(`${chalk.yellowBright(
await autumn.attach({
customer_id: redeemer,
product_id: products.pro.id,
product_id: pro.id,
});
await timeout(3000);
@@ -186,7 +239,7 @@ describe(`${chalk.yellowBright(
// Check if redemption is triggered
const count = i + 1;
if (count > referralPrograms.onCheckout.max_redemptions) {
if (count > onCheckoutProgram.max_redemptions!) {
expect(redemption.triggered).toBe(false);
expect(redemption.applied).toBe(false);
} else {

View File

@@ -1,174 +0,0 @@
import {
type AppEnv,
type Customer,
ErrCode,
type Organization,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import { addDays } from "date-fns";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomer } from "tests/utils/init.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
import { products, referralPrograms } from "../../global.js";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals2: Testing referrals (immediate redemption)",
)}`, () => {
const mainCustomerId = "main-referral-2";
const redeemers = ["referral2-r1", "referral2-r2", "referral2-r3"];
const autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
const redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
let org: Organization;
let env: AppEnv;
before(async function () {
await setupBefore(this);
stripeCli = this.stripeCli;
org = this.org;
env = this.env;
const { testClockId: testClockId1, customer } = await initCustomerV2({
customerId: mainCustomerId,
db: this.db,
org: this.org,
env: this.env,
autumn,
});
testClockId = testClockId1;
mainCustomer = customer;
const batchCreate = [];
for (const redeemer of redeemers) {
batchCreate.push(
initCustomer({
customerId: redeemer,
db: this.db,
org: this.org,
env: this.env,
attachPm: true,
}),
);
}
await Promise.all(batchCreate);
});
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.immediate.id,
});
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async () => {
for (let i = 0; i < redeemers.length; i++) {
const redeemer = redeemers[i];
const count = i + 1;
try {
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
redemptions.push(redemption);
if (count > referralPrograms.immediate.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
assert.fail("Should not be able to redeem again");
}
} catch (error) {
if (count > referralPrograms.immediate.max_redemptions) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.ReferralCodeMaxRedemptionsReached);
}
}
}
// Check stripe customer
const legacyStripe = createStripeCli({
org: org,
env: env,
legacyVersion: true,
});
const stripeCus = (await legacyStripe.customers.retrieve(
mainCustomer.processor?.id,
{
expand: ["discount"],
},
)) as Stripe.Customer;
assert.notEqual(stripeCus.discount, null);
});
let curTime = new Date();
it("customer should have discount for first purchase", async () => {
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
});
await timeout(3000);
curTime = addDays(addDays(curTime, 7), 4);
await advanceTestClock({
testClockId,
advanceTo: curTime.getTime(),
stripeCli,
waitForSeconds: 30,
});
// 1. Get invoice
const { invoices } = await autumn.customers.get(mainCustomerId);
assert.equal(invoices!.length, 2);
assert.equal(invoices![0].total, 0);
});
// it("customer should have discount for second purchase", async function () {
// // 2. Check that customer has another discount
// let stripeCus = (await stripeCli.customers.retrieve(
// mainCustomer.processor?.id,
// )) as Stripe.Customer;
// assert.notEqual(stripeCus.discount, null);
// // 2. Advance test clock to 1 month from start (trigger discount.deleted event)
// curTime = addHours(addMonths(new Date(), 1), 2);
// await advanceTestClock({
// testClockId,
// advanceTo: curTime.getTime(),
// stripeCli,
// });
// // 3. Advance test clock to 1 month + 7 days from start (trigger new invoice)
// curTime = addDays(curTime, 8);
// await advanceTestClock({
// testClockId,
// advanceTo: curTime.getTime(),
// stripeCli,
// });
// // // 3. Get invoice again
// let { invoices: invoices2 } = await autumn.customers.get(mainCustomerId);
// assert.equal(invoices2!.length, 3);
// assert.equal(invoices2![0].total, 0);
// });
});

View File

@@ -1,22 +1,66 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type AppEnv,
CouponDurationType,
type CreateReward,
type CreateRewardProgram,
type Customer,
ErrCode,
type Organization,
type ReferralCode,
RewardReceivedBy,
type RewardRedemption,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import { addDays } from "date-fns";
import type { Stripe } from "stripe";
import { TestFeature } from "tests/setup/v2Features.js";
import { timeout } from "tests/utils/genUtils.js";
import { createReferralProgram } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { products, referralPrograms } from "../../global.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
const testCase = "referrals2";
const proWithTrial = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
trial: true,
});
// Reward: 100% discount for 1 month
const monthOffReward: CreateReward = {
id: `${testCase}MonthOff`,
name: "Month Off",
type: RewardType.PercentageDiscount,
promo_codes: [],
discount_config: {
discount_value: 100,
duration_type: CouponDurationType.Months,
duration_value: 1,
apply_to_all: true,
price_ids: [],
},
};
// Referral program: triggers immediately on customer creation
const immediateProgram: CreateRewardProgram = {
id: `${testCase}Immediate`,
when: RewardTriggerEvent.CustomerCreation,
product_ids: [],
internal_reward_id: monthOffReward.id,
max_redemptions: 2,
received_by: RewardReceivedBy.Referrer,
};
describe(`${chalk.yellowBright(
"referrals2: Testing referrals (immediate redemption)",
@@ -38,6 +82,23 @@ describe(`${chalk.yellowBright(
org = ctx.org;
env = ctx.env;
await initProductsV0({
ctx,
products: [proWithTrial],
prefix: testCase,
customerId: mainCustomerId,
});
// Create referral program
await createReferralProgram({
db: ctx.db,
orgId: org.id,
env,
autumn: new AutumnInt({ secretKey: ctx.orgSecretKey }),
reward: monthOffReward,
rewardProgram: immediateProgram,
});
const { testClockId: testClockId1, customer } = await initCustomerV3({
ctx,
customerId: mainCustomerId,
@@ -62,7 +123,7 @@ describe(`${chalk.yellowBright(
test("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.immediate.id,
referralId: immediateProgram.id,
});
expect(referralCode.code).toBeDefined();
@@ -79,16 +140,18 @@ describe(`${chalk.yellowBright(
});
redemptions.push(redemption);
if (count > referralPrograms.immediate.max_redemptions) {
if (count > immediateProgram.max_redemptions!) {
expect(redemption.triggered).toBe(false);
expect(redemption.applied).toBe(false);
} else {
throw new Error("Should not be able to redeem again");
}
} catch (error) {
if (count > referralPrograms.immediate.max_redemptions) {
if (count > immediateProgram.max_redemptions!) {
expect(error).toBeInstanceOf(AutumnError);
expect((error as AutumnError).code).toBe(ErrCode.ReferralCodeMaxRedemptionsReached);
expect((error as AutumnError).code).toBe(
ErrCode.ReferralCodeMaxRedemptionsReached,
);
}
}
}
@@ -114,7 +177,7 @@ describe(`${chalk.yellowBright(
test("customer should have discount for first purchase", async () => {
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
product_id: proWithTrial.id,
});
await timeout(3000);

View File

@@ -1,141 +0,0 @@
import {
type Customer,
ErrCode,
type ReferralCode,
type RewardRedemption,
} from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { compareProductEntitlements } from "tests/utils/compare.js";
import { timeout } from "tests/utils/genUtils.js";
import { initCustomer } from "tests/utils/init.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { features, products, referralPrograms } from "../../global.js";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals3: Testing free product referrals",
)}`, () => {
const mainCustomerId = "main-referral-3";
const redeemers = ["referral3-r1", "referral3-r2", "referral3-r3"];
let autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
let referralCode: ReferralCode;
const redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
before(async function () {
await setupBefore(this);
autumn = this.autumn;
stripeCli = this.stripeCli;
const { testClockId: testClockId1, customer } =
await initCustomerWithTestClock({
customerId: mainCustomerId,
db: this.db,
org: this.org,
env: this.env,
fingerprint: "main-referral-3",
});
testClockId = testClockId1;
mainCustomer = customer;
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
});
const batchCreate = [];
for (const redeemer of redeemers) {
batchCreate.push(
initCustomer({
customerId: redeemer,
db: this.db,
org: this.org,
env: this.env,
attachPm: true,
}),
);
}
await Promise.all(batchCreate);
});
it("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.freeProduct.id,
});
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async () => {
for (const redeemer of redeemers) {
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemer,
code: referralCode.code,
});
redemptions.push(redemption);
// assert.equal(redemption.triggered, false);
// assert.equal(redemption.applied, false);
}
// Try redeem for redeemer1 again
try {
const redemption1 = await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
assert.fail("Should not be able to redeem again");
} catch (error) {
assert.instanceOf(error, AutumnError);
assert.equal(error.code, ErrCode.CustomerAlreadyRedeemedReferralCode);
}
});
it("should be triggered (and applied) when redeemers check out", async () => {
for (let i = 0; i < redeemers.length; i++) {
const redeemer = redeemers[i];
await autumn.attach({
customer_id: redeemer,
product_id: products.pro.id,
});
await timeout(3000);
// Get redemption object
const redemption = await autumn.redemptions.get(redemptions[i].id);
// Check if redemption is triggered
const count = i + 1;
if (count > referralPrograms.freeProduct.max_redemptions) {
assert.equal(redemption.triggered, false);
assert.equal(redemption.applied, false);
} else {
// 1. Check that main customer has free add on
compareProductEntitlements({
customerId: mainCustomerId,
product: products.freeAddOn,
features,
quantity: count,
});
compareProductEntitlements({
customerId: redeemer,
product: products.freeAddOn,
features,
});
}
}
});
});

View File

@@ -1,47 +1,122 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type Customer,
ApiVersion,
type CreateReward,
type CreateRewardProgram,
ErrCode,
type ReferralCode,
RewardReceivedBy,
type RewardRedemption,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import type { Stripe } from "stripe";
import { compareProductEntitlements } from "tests/utils/compare.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { timeout } from "tests/utils/genUtils.js";
import { createReferralProgram } from "tests/utils/productUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import AutumnError, { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { features, products, referralPrograms } from "../../global.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached.js";
const testCase = "referrals3";
const proWithTrial = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
trial: true,
});
const pro = constructProduct({
id: "proNoTrial",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
});
const freeAddOn = constructProduct({
id: "freeAddOn",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: null,
}),
],
type: "free",
isAddOn: true,
isDefault: false,
});
// Reward: Free product reward
const freeProductReward: CreateReward = {
id: `${testCase}FreeProduct`,
name: "Free Product",
type: RewardType.FreeProduct,
promo_codes: [],
free_product_id: freeAddOn.id,
};
// Referral program: triggers on checkout, applies to pro and proWithTrial
const freeProductProgram: CreateRewardProgram = {
id: `${testCase}FreeProduct`,
when: RewardTriggerEvent.Checkout,
product_ids: [proWithTrial.id, pro.id],
internal_reward_id: freeProductReward.id,
max_redemptions: 2,
received_by: RewardReceivedBy.All,
};
describe(`${chalk.yellowBright(
"referrals3: Testing free product referrals",
)}`, () => {
const mainCustomerId = "main-referral-3";
const redeemers = ["referral3-r1", "referral3-r2", "referral3-r3"];
let autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let testClockId: string;
const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
let referralCode: ReferralCode;
const redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
beforeAll(async () => {
autumn = new AutumnInt({ secretKey: ctx.orgSecretKey });
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [proWithTrial, pro, freeAddOn],
prefix: testCase,
customerId: mainCustomerId,
});
const { testClockId: testClockId1, customer } = await initCustomerV3({
// Create referral program - product IDs are already prefixed by initProductsV0
const referralProgram: CreateRewardProgram = {
...freeProductProgram,
product_ids: [proWithTrial.id, pro.id],
};
// Update reward with prefixed free product ID
const reward: CreateReward = {
...freeProductReward,
free_product_id: freeAddOn.id,
};
await createReferralProgram({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
autumn,
reward,
rewardProgram: referralProgram,
});
await initCustomerV3({
ctx,
customerId: mainCustomerId,
customerData: { fingerprint: "main-referral-3" },
});
testClockId = testClockId1;
mainCustomer = customer;
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
product_id: proWithTrial.id,
});
const batchCreate = [];
@@ -61,7 +136,7 @@ describe(`${chalk.yellowBright(
test("should create code once", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.freeProduct.id,
referralId: freeProductProgram.id,
});
expect(referralCode.code).toBeDefined();
@@ -79,14 +154,16 @@ describe(`${chalk.yellowBright(
// Try redeem for redeemer1 again
try {
const redemption1 = await autumn.referrals.redeem({
await autumn.referrals.redeem({
customerId: redeemers[0],
code: referralCode.code,
});
throw new Error("Should not be able to redeem again");
} catch (error) {
expect(error).toBeInstanceOf(AutumnError);
expect((error as AutumnError).code).toBe(ErrCode.CustomerAlreadyRedeemedReferralCode);
expect((error as AutumnError).code).toBe(
ErrCode.CustomerAlreadyRedeemedReferralCode,
);
}
});
@@ -96,7 +173,7 @@ describe(`${chalk.yellowBright(
await autumn.attach({
customer_id: redeemer,
product_id: products.pro.id,
product_id: pro.id,
});
await timeout(3000);
@@ -107,22 +184,22 @@ describe(`${chalk.yellowBright(
// Check if redemption is triggered
const count = i + 1;
if (count > referralPrograms.freeProduct.max_redemptions) {
if (count > freeProductProgram.max_redemptions!) {
expect(redemption.triggered).toBe(false);
expect(redemption.applied).toBe(false);
} else {
// 1. Check that main customer has free add on
compareProductEntitlements({
customerId: mainCustomerId,
product: products.freeAddOn,
features,
quantity: count,
const mainCustomer = await autumn.customers.get(mainCustomerId);
const redeemerCustomer = await autumn.customers.get(redeemer);
expectProductAttached({
customer: mainCustomer,
product: freeAddOn,
});
compareProductEntitlements({
customerId: redeemer,
product: products.freeAddOn,
features,
expectProductAttached({
customer: redeemerCustomer,
product: freeAddOn,
});
}
}

View File

@@ -1,123 +0,0 @@
import type { ReferralCode, RewardRedemption } from "@autumn/shared";
import { assert } from "chai";
import chalk from "chalk";
import { addDays, addHours } from "date-fns";
import type { Stripe } from "stripe";
import { setupBefore } from "tests/before.js";
import { compareProductEntitlements } from "tests/utils/compare.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { timeout } from "tests/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomerV2 } from "../../../src/utils/scriptUtils/initCustomer.js";
import { features, products, referralPrograms } from "../../global.js";
// UNCOMMENT FROM HERE
describe(`${chalk.yellowBright(
"referrals4: Testing free product referrals with trial",
)}`, () => {
const mainCustomerId = "main-referral-4";
// let redeemers = ["referral4-r1", "referral4-r2"];
const redeemerId = "referral4-r1";
let autumn: AutumnInt = new AutumnInt();
let stripeCli: Stripe;
let referralCode: ReferralCode;
const redemptions: RewardRedemption[] = [];
let testClockId: string;
before(async function () {
await setupBefore(this);
autumn = this.autumn;
stripeCli = this.stripeCli;
await initCustomerV2({
autumn,
customerId: mainCustomerId,
org: this.org,
env: this.env,
db: this.db,
attachPm: "success",
});
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
});
const { testClockId: testClockId1 } = await initCustomerV2({
autumn,
customerId: redeemerId,
db: this.db,
org: this.org,
env: this.env,
attachPm: "success",
});
testClockId = testClockId1;
});
it("should create referral code", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.freeProduct.id,
});
assert.exists(referralCode.code);
});
it("should create redemption for each redeemer and fail if redeemed again", async () => {
const redemption: RewardRedemption = await autumn.referrals.redeem({
customerId: redeemerId,
code: referralCode.code,
});
redemptions.push(redemption);
});
it("should not be triggered because of trial", async () => {
await autumn.attach({
customer_id: redeemerId,
product_id: products.proWithTrial.id,
});
await timeout(3000);
// Get redemption object
const redemption = await autumn.redemptions.get(redemptions[0].id);
assert.equal(redemption.triggered, false);
});
it("should be triggered after trial ends", async () => {
const advanceTo = addHours(
addDays(new Date(), 7),
hoursToFinalizeInvoice,
).getTime();
await advanceTestClock({
stripeCli,
testClockId,
advanceTo,
waitForSeconds: 30,
});
const redemption = await autumn.redemptions.get(redemptions[0].id);
assert.equal(redemption.triggered, true);
compareProductEntitlements({
customerId: mainCustomerId,
product: products.freeAddOn,
features,
quantity: 1,
});
compareProductEntitlements({
customerId: redeemerId,
product: products.freeAddOn,
features,
quantity: 1,
});
});
});

View File

@@ -1,16 +1,71 @@
import type { Customer, ReferralCode, RewardRedemption } from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import {
ApiVersion,
type CreateReward,
type CreateRewardProgram,
type ReferralCode,
RewardReceivedBy,
type RewardRedemption,
RewardTriggerEvent,
RewardType,
} from "@autumn/shared";
import chalk from "chalk";
import { addDays, addHours } from "date-fns";
import type { Stripe } from "stripe";
import { compareProductEntitlements } from "tests/utils/compare.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { timeout } from "tests/utils/genUtils.js";
import { createReferralProgram } from "tests/utils/productUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { features, products, referralPrograms } from "../../global.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
import { expectProductAttached } from "../../utils/expectUtils/expectProductAttached.js";
const testCase = "referrals4";
const proWithTrial = constructProduct({
id: "pro",
items: [constructFeatureItem({ featureId: TestFeature.Words })],
type: "pro",
trial: true,
});
const freeAddOn = constructProduct({
id: "freeAddOn",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
interval: null,
}),
],
type: "free",
isAddOn: true,
isDefault: false,
});
// Reward: Free product reward
const freeProductReward: CreateReward = {
id: `${testCase}FreeProduct`,
name: "Free Product",
type: RewardType.FreeProduct,
promo_codes: [],
free_product_id: freeAddOn.id,
};
// Referral program: triggers on checkout
const freeProductProgram: CreateRewardProgram = {
id: `${testCase}FreeProduct`,
when: RewardTriggerEvent.Checkout,
product_ids: [proWithTrial.id],
internal_reward_id: freeProductReward.id,
max_redemptions: 2,
received_by: RewardReceivedBy.All,
};
describe(`${chalk.yellowBright(
"referrals4: Testing free product referrals with trial",
@@ -23,15 +78,44 @@ describe(`${chalk.yellowBright(
let referralCode: ReferralCode;
const redemptions: RewardRedemption[] = [];
let mainCustomer: Customer;
let redeemer: Customer;
let testClockId: string;
beforeAll(async () => {
autumn = new AutumnInt({ secretKey: ctx.orgSecretKey });
autumn = new AutumnInt({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V1_2,
});
stripeCli = ctx.stripeCli;
await initProductsV0({
ctx,
products: [proWithTrial, freeAddOn],
prefix: testCase,
customerId: mainCustomerId,
});
// Create referral program - product IDs are already prefixed by initProductsV0
const referralProgram: CreateRewardProgram = {
...freeProductProgram,
product_ids: [proWithTrial.id],
};
// Update reward with prefixed free product ID
const reward: CreateReward = {
...freeProductReward,
free_product_id: freeAddOn.id,
};
await createReferralProgram({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
autumn,
reward,
rewardProgram: referralProgram,
});
await initCustomerV3({
ctx,
customerId: mainCustomerId,
@@ -40,22 +124,22 @@ describe(`${chalk.yellowBright(
await autumn.attach({
customer_id: mainCustomerId,
product_id: products.proWithTrial.id,
product_id: proWithTrial.id,
});
const { testClockId: testClockId1, customer } = await initCustomerV3({
const { testClockId: testClockId1 } = await initCustomerV3({
ctx,
customerId: redeemerId,
attachPm: "success",
});
testClockId = testClockId1;
redeemer = customer;
});
test("should create referral code", async () => {
referralCode = await autumn.referrals.createCode({
customerId: mainCustomerId,
referralId: referralPrograms.freeProduct.id,
referralId: freeProductProgram.id,
});
expect(referralCode.code).toBeDefined();
@@ -73,7 +157,7 @@ describe(`${chalk.yellowBright(
test("should not be triggered because of trial", async () => {
await autumn.attach({
customer_id: redeemerId,
product_id: products.proWithTrial.id,
product_id: proWithTrial.id,
});
await timeout(3000);
@@ -85,33 +169,30 @@ describe(`${chalk.yellowBright(
});
test("should be triggered after trial ends", async () => {
const advanceTo = addHours(
addDays(new Date(), 7),
hoursToFinalizeInvoice,
).getTime();
await advanceTestClock({
stripeCli,
testClockId,
advanceTo,
advanceTo: addHours(
addDays(new Date(), 7),
hoursToFinalizeInvoice,
).getTime(),
waitForSeconds: 30,
});
const redemption = await autumn.redemptions.get(redemptions[0].id);
expect(redemption.triggered).toBe(true);
compareProductEntitlements({
customerId: mainCustomerId,
product: products.freeAddOn,
features,
quantity: 1,
const mainCustomer = await autumn.customers.get(mainCustomerId);
const redeemer = await autumn.customers.get(redeemerId);
expectProductAttached({
customer: mainCustomer,
product: freeAddOn,
});
compareProductEntitlements({
customerId: redeemerId,
product: products.freeAddOn,
features,
quantity: 1,
expectProductAttached({
customer: redeemer,
product: freeAddOn,
});
});
});

View File

@@ -1,198 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
ProductItemInterval,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { resetAndGetCusEnt } from "./rolloverTestUtils.js";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverDuration.Month,
};
const messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 400,
interval: ProductItemInterval.Month,
rolloverConfig,
}) as LimitedItem;
export const free = constructProduct({
items: [messagesItem],
type: "free",
isDefault: false,
});
const testCase = "rollover1";
// , per entity and regular
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [free],
prefix: testCase,
});
await createProducts({
autumn,
products: [free],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
it("should attach free product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: free.id,
});
});
const messageUsage = 250;
let curBalance = messagesItem.included_usage;
it("should create track messages, reset, and have correct rollover", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messageUsage,
});
await timeout(3000);
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group,
featureId: TestFeature.Messages,
});
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
const expectedRollover = Math.min(
messagesItem.included_usage - messageUsage,
rolloverConfig.max,
);
const expectedBalance = messagesItem.included_usage + expectedRollover;
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(expectedBalance);
// @ts-expect-error
expect(msgesFeature?.rollovers[0].balance).to.equal(expectedRollover);
curBalance = expectedBalance;
});
// let usage2 = 50;
it("should reset again and have correct rollover", async () => {
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group,
featureId: TestFeature.Messages,
});
const expectedRollover = Math.min(curBalance, rolloverConfig.max);
const expectedBalance = messagesItem.included_usage + expectedRollover;
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(expectedBalance);
// @ts-expect-error (oldest rollover should be 100 (150 - 50))
expect(msgesFeature?.rollovers[0].balance).to.equal(100);
// @ts-expect-error (newest rollover should be 400 (msges.included_usage))
expect(msgesFeature?.rollovers[1].balance).to.equal(400);
});
it("should track messages and deduct from rollovers first", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 150,
});
await timeout(3000);
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollover1 = msgesFeature?.rollovers[0];
// @ts-expect-error
const rollover2 = msgesFeature?.rollovers[1];
expect(rollover1.balance).to.equal(0);
expect(rollover2.balance).to.equal(350);
});
it("should track and deduct from rollover + original balance", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 400,
});
await timeout(3000);
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollovers = msgesFeature.rollovers;
expect(rollovers![0].balance).to.equal(0);
expect(rollovers![1].balance).to.equal(0);
expect(msgesFeature.balance).to.equal(messagesItem.included_usage - 50);
});
});

View File

@@ -1,3 +1,4 @@
import { beforeAll, describe, expect, test } from "bun:test";
import {
type Customer,
LegacyVersion,
@@ -5,11 +6,10 @@ import {
ProductItemInterval,
RolloverDuration,
} from "@autumn/shared";
import { beforeAll, describe, expect, test } from "bun:test";
import chalk from "chalk";
import type Stripe from "stripe";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
@@ -92,7 +92,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
await resetAndGetCusEnt({
db: ctx.db,
customer,
productGroup: free.group,
productGroup: free.group!,
featureId: TestFeature.Messages,
});
@@ -111,6 +111,17 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
// @ts-expect-error
expect(msgesFeature?.rollovers[0].balance).toBe(expectedRollover);
curBalance = expectedBalance;
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature =
nonCachedCustomer.features[TestFeature.Messages];
expect(nonCachedMsgesFeature?.balance).toBe(expectedBalance);
// @ts-expect-error
expect(nonCachedMsgesFeature?.rollovers[0].balance).toBe(expectedRollover);
});
// let usage2 = 50;
@@ -118,7 +129,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
await resetAndGetCusEnt({
db: ctx.db,
customer,
productGroup: free.group,
productGroup: free.group!,
featureId: TestFeature.Messages,
});
@@ -135,6 +146,19 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
expect(msgesFeature?.rollovers[0].balance).toBe(100);
// @ts-expect-error (newest rollover should be 400 (msges.included_usage))
expect(msgesFeature?.rollovers[1].balance).toBe(400);
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature =
nonCachedCustomer.features[TestFeature.Messages];
expect(nonCachedMsgesFeature?.balance).toBe(expectedBalance);
// @ts-expect-error
expect(nonCachedMsgesFeature?.rollovers[0].balance).toBe(100);
// @ts-expect-error
expect(nonCachedMsgesFeature?.rollovers[1].balance).toBe(400);
});
test("should track messages and deduct from rollovers first", async () => {
@@ -156,6 +180,20 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
expect(rollover1.balance).toBe(0);
expect(rollover2.balance).toBe(350);
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature =
nonCachedCustomer.features[TestFeature.Messages];
// @ts-expect-error
const nonCachedRollover1 = nonCachedMsgesFeature?.rollovers[0];
// @ts-expect-error
const nonCachedRollover2 = nonCachedMsgesFeature?.rollovers[1];
expect(nonCachedRollover1.balance).toBe(0);
expect(nonCachedRollover2.balance).toBe(350);
});
test("should track and deduct from rollover + original balance", async () => {
@@ -170,10 +208,27 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollovers = msgesFeature.rollovers;
// @ts-expect-error (rollovers is an array of rollovers)
expect(rollovers![0].balance).toBe(0);
// @ts-expect-error (rollovers is an array of rollovers)
expect(rollovers![1].balance).toBe(0);
expect(msgesFeature.balance).toBe(messagesItem.included_usage - 50);
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature =
nonCachedCustomer.features[TestFeature.Messages];
const nonCachedRollovers = nonCachedMsgesFeature.rollovers;
// @ts-expect-error
expect(nonCachedRollovers![0].balance).toBe(0);
// @ts-expect-error
expect(nonCachedRollovers![1].balance).toBe(0);
expect(nonCachedMsgesFeature.balance).toBe(
messagesItem.included_usage - 50,
);
});
});

View File

@@ -1,199 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
ProductItemInterval,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { resetAndGetCusEnt } from "./rolloverTestUtils.js";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverDuration.Month,
};
const messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 400,
interval: ProductItemInterval.Month,
rolloverConfig,
}) as LimitedItem;
export const free = constructProduct({
items: [messagesItem],
type: "free",
isDefault: false,
});
const testCase = "rollover1";
// , per entity and regular
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [free],
prefix: testCase,
});
await createProducts({
autumn,
products: [free],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
it("should attach free product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: free.id,
});
});
const messageUsage = 250;
let curBalance = messagesItem.included_usage;
it("should create track messages, reset, and have correct rollover", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messageUsage,
});
await timeout(3000);
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group,
featureId: TestFeature.Messages,
});
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
const expectedRollover = Math.min(
messagesItem.included_usage - messageUsage,
rolloverConfig.max,
);
const expectedBalance = messagesItem.included_usage + expectedRollover;
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(expectedBalance);
// @ts-expect-error
expect(msgesFeature?.rollovers[0].balance).to.equal(expectedRollover);
curBalance = expectedBalance;
});
// let usage2 = 50;
it("should reset again and have correct rollover", async () => {
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group,
featureId: TestFeature.Messages,
});
const expectedRollover = Math.min(curBalance, rolloverConfig.max);
const expectedBalance = messagesItem.included_usage + expectedRollover;
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(expectedBalance);
// @ts-expect-error (oldest rollover should be 100 (150 - 50))
expect(msgesFeature?.rollovers[0].balance).to.equal(100);
// @ts-expect-error (newest rollover should be 400 (msges.included_usage))
expect(msgesFeature?.rollovers[1].balance).to.equal(400);
});
it("should track messages and deduct from rollovers first", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 150,
});
await timeout(3000);
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollover1 = msgesFeature?.rollovers[0];
// @ts-expect-error
const rollover2 = msgesFeature?.rollovers[1];
expect(rollover1.balance).to.equal(0);
expect(rollover2.balance).to.equal(350);
});
return;
it("should track and deduct from rollover + original balance", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 400,
});
await timeout(3000);
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollovers = msgesFeature.rollovers;
expect(rollovers![0].balance).to.equal(0);
expect(rollovers![1].balance).to.equal(0);
expect(msgesFeature.balance).to.equal(messagesItem.included_usage - 50);
});
});

View File

@@ -1,225 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
ProductItemInterval,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { resetAndGetCusEnt } from "./rolloverTestUtils.js";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverDuration.Month,
};
const msgesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 400,
interval: ProductItemInterval.Month,
rolloverConfig,
entityFeatureId: TestFeature.Users,
}) as LimitedItem;
export const free = constructProduct({
items: [msgesItem],
type: "free",
isDefault: false,
});
const testCase = "rollover2";
// , per entity and regular
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item (per entity)`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [free],
prefix: testCase,
});
await createProducts({
autumn,
products: [free],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
const entities: any[] = [
{
id: "1",
name: "Entity 1",
feature_id: TestFeature.Users,
},
{
id: "2",
name: "Entity 2",
feature_id: TestFeature.Users,
},
];
it("should attach pro product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: free.id,
});
await autumn.entities.create(customerId, entities);
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
const newEntity1Balance = 300;
const newEntity2Balance = 200;
const includedUsage = msgesItem.included_usage;
const usages = [
{
entityId: entity1Id,
usage: includedUsage - newEntity1Balance,
rollover: newEntity1Balance,
},
{
entityId: entity2Id,
usage: includedUsage - newEntity2Balance,
rollover: newEntity2Balance,
},
];
it("should create track messages, reset, and have correct rollover", async () => {
for (const usage of usages) {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usage.usage,
entity_id: usage.entityId,
});
}
await timeout(3000);
// Run reset cusEnt on ...
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group,
featureId: TestFeature.Messages,
});
for (const usage of usages) {
const entity = await autumn.entities.get(customerId, usage.entityId);
const msgesFeature = entity.features[TestFeature.Messages];
const expectedRollover = Math.min(usage.rollover, rolloverConfig.max);
expect(msgesFeature.rollovers.length).to.equal(1);
expect(msgesFeature.balance).to.equal(includedUsage + expectedRollover);
expect(msgesFeature.rollovers[0].balance).to.equal(expectedRollover);
}
});
it("should reset again and have correct rollovers", async () => {
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group,
featureId: TestFeature.Messages,
});
const entity1 = await autumn.entities.get(customerId, entity1Id);
const entity1Msges = entity1.features[TestFeature.Messages];
// 400, 300 -> 400, 100 (max is 500)
const rollovers = entity1Msges.rollovers;
expect(rollovers[0].balance).to.equal(100);
expect(rollovers[1].balance).to.equal(400);
const entity2 = await autumn.entities.get(customerId, entity2Id);
const entity2Msges = entity2.features[TestFeature.Messages];
// 400, 200 -> 400, 0 (max is 500)
const rollovers2 = entity2Msges.rollovers;
expect(rollovers2[0].balance).to.equal(100);
expect(rollovers2[1].balance).to.equal(400);
});
it("should track and deduct from oldest rollovers first", async () => {
for (const entity of entities) {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 150,
entity_id: entity.id,
});
await timeout(2000);
const entRes = await autumn.entities.get(customerId, entity.id);
const msgesFeature = entRes.features[TestFeature.Messages];
const rollovers = msgesFeature.rollovers;
expect(rollovers[0].balance).to.equal(0);
expect(rollovers[1].balance).to.equal(350);
expect(msgesFeature.balance).to.equal(includedUsage + 350);
}
});
it("should track past rollovers and deduct from original balance", async () => {
for (const entity of entities) {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 400,
entity_id: entity.id,
});
await timeout(2000);
const entRes = await autumn.entities.get(customerId, entity.id);
const msgesFeature = entRes.features[TestFeature.Messages];
const rollovers = msgesFeature.rollovers;
expect(rollovers[0].balance).to.equal(0);
expect(rollovers[1].balance).to.equal(0);
expect(msgesFeature.balance).to.equal(includedUsage - 50);
}
});
});

View File

@@ -141,6 +141,22 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
expect(msgesFeature.balance).toBe(includedUsage + expectedRollover);
expect(msgesFeature.rollovers[0].balance).toBe(expectedRollover);
}
// Verify non-cached entity balances
await timeout(2000);
for (const usage of usages) {
const expectedRollover = Math.min(usage.rollover, rolloverConfig.max);
const nonCachedEntity = await autumn.entities.get(
customerId,
usage.entityId,
{
skip_cache: "true",
},
);
const nonCachedMsgesFeature = nonCachedEntity.features[TestFeature.Messages];
expect(nonCachedMsgesFeature.balance).toBe(includedUsage + expectedRollover);
expect(nonCachedMsgesFeature.rollovers[0].balance).toBe(expectedRollover);
}
});
test("should reset again and have correct rollovers", async () => {
@@ -164,6 +180,24 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
const rollovers2 = entity2Msges.rollovers;
expect(rollovers2[0].balance).toBe(100);
expect(rollovers2[1].balance).toBe(400);
// Verify non-cached entity balances
await timeout(2000);
const nonCachedEntity1 = await autumn.entities.get(customerId, entity1Id, {
skip_cache: "true",
});
const nonCachedEntity1Msges = nonCachedEntity1.features[TestFeature.Messages];
const nonCachedRollovers1 = nonCachedEntity1Msges.rollovers;
expect(nonCachedRollovers1[0].balance).toBe(100);
expect(nonCachedRollovers1[1].balance).toBe(400);
const nonCachedEntity2 = await autumn.entities.get(customerId, entity2Id, {
skip_cache: "true",
});
const nonCachedEntity2Msges = nonCachedEntity2.features[TestFeature.Messages];
const nonCachedRollovers2 = nonCachedEntity2Msges.rollovers;
expect(nonCachedRollovers2[0].balance).toBe(100);
expect(nonCachedRollovers2[1].balance).toBe(400);
});
test("should track and deduct from oldest rollovers first", async () => {
@@ -183,6 +217,19 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
expect(rollovers[1].balance).toBe(350);
expect(msgesFeature.balance).toBe(includedUsage + 350);
}
// Verify non-cached entity balances
await timeout(2000);
for (const entity of entities) {
const nonCachedEntity = await autumn.entities.get(customerId, entity.id, {
skip_cache: "true",
});
const nonCachedMsgesFeature = nonCachedEntity.features[TestFeature.Messages];
const nonCachedRollovers = nonCachedMsgesFeature.rollovers;
expect(nonCachedRollovers[0].balance).toBe(0);
expect(nonCachedRollovers[1].balance).toBe(350);
expect(nonCachedMsgesFeature.balance).toBe(includedUsage + 350);
}
});
test("should track past rollovers and deduct from original balance", async () => {
@@ -202,5 +249,18 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
expect(rollovers[1].balance).toBe(0);
expect(msgesFeature.balance).toBe(includedUsage - 50);
}
// Verify non-cached entity balances
await timeout(2000);
for (const entity of entities) {
const nonCachedEntity = await autumn.entities.get(customerId, entity.id, {
skip_cache: "true",
});
const nonCachedMsgesFeature = nonCachedEntity.features[TestFeature.Messages];
const nonCachedRollovers = nonCachedMsgesFeature.rollovers;
expect(nonCachedRollovers[0].balance).toBe(0);
expect(nonCachedRollovers[1].balance).toBe(0);
expect(nonCachedMsgesFeature.balance).toBe(includedUsage - 50);
}
});
});

View File

@@ -1,225 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
ProductItemInterval,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { resetAndGetCusEnt } from "./rolloverTestUtils.js";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverDuration.Month,
};
const msgesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 400,
interval: ProductItemInterval.Month,
rolloverConfig,
entityFeatureId: TestFeature.Users,
}) as LimitedItem;
export const free = constructProduct({
items: [msgesItem],
type: "free",
isDefault: false,
});
const testCase = "rollover2";
// , per entity and regular
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item (per entity)`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [free],
prefix: testCase,
});
await createProducts({
autumn,
products: [free],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
const entities: any[] = [
{
id: "1",
name: "Entity 1",
feature_id: TestFeature.Users,
},
{
id: "2",
name: "Entity 2",
feature_id: TestFeature.Users,
},
];
it("should attach pro product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: free.id,
});
await autumn.entities.create(customerId, entities);
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
const newEntity1Balance = 300;
const newEntity2Balance = 200;
const includedUsage = msgesItem.included_usage;
const usages = [
{
entityId: entity1Id,
usage: includedUsage - newEntity1Balance,
rollover: newEntity1Balance,
},
{
entityId: entity2Id,
usage: includedUsage - newEntity2Balance,
rollover: newEntity2Balance,
},
];
it("should create track messages, reset, and have correct rollover", async () => {
for (const usage of usages) {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usage.usage,
entity_id: usage.entityId,
});
}
await timeout(3000);
// Run reset cusEnt on ...
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group!,
featureId: TestFeature.Messages,
});
for (const usage of usages) {
const entity = await autumn.entities.get(customerId, usage.entityId);
const msgesFeature = entity.features[TestFeature.Messages];
const expectedRollover = Math.min(usage.rollover, rolloverConfig.max);
expect(msgesFeature.rollovers.length).to.equal(1);
expect(msgesFeature.balance).to.equal(includedUsage + expectedRollover);
expect(msgesFeature.rollovers[0].balance).to.equal(expectedRollover);
}
});
it("should reset again and have correct rollovers", async () => {
await resetAndGetCusEnt({
db,
customer,
productGroup: free.group!,
featureId: TestFeature.Messages,
});
const entity1 = await autumn.entities.get(customerId, entity1Id);
const entity1Msges = entity1.features[TestFeature.Messages];
// 400, 300 -> 400, 100 (max is 500)
const rollovers = entity1Msges.rollovers;
expect(rollovers[0].balance).to.equal(100);
expect(rollovers[1].balance).to.equal(400);
const entity2 = await autumn.entities.get(customerId, entity2Id);
const entity2Msges = entity2.features[TestFeature.Messages];
// 400, 200 -> 400, 0 (max is 500)
const rollovers2 = entity2Msges.rollovers;
expect(rollovers2[0].balance).to.equal(100);
expect(rollovers2[1].balance).to.equal(400);
});
it("should track and deduct from oldest rollovers first", async () => {
for (const entity of entities) {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 150,
entity_id: entity.id,
});
await timeout(2000);
const entRes = await autumn.entities.get(customerId, entity.id);
const msgesFeature = entRes.features[TestFeature.Messages];
const rollovers = msgesFeature.rollovers;
expect(rollovers[0].balance).to.equal(0);
expect(rollovers[1].balance).to.equal(350);
expect(msgesFeature.balance).to.equal(includedUsage + 350);
}
});
it("should track past rollovers and deduct from original balance", async () => {
for (const entity of entities) {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 400,
entity_id: entity.id,
});
await timeout(2000);
const entRes = await autumn.entities.get(customerId, entity.id);
const msgesFeature = entRes.features[TestFeature.Messages];
const rollovers = msgesFeature.rollovers;
expect(rollovers[0].balance).to.equal(0);
expect(rollovers[1].balance).to.equal(0);
expect(msgesFeature.balance).to.equal(includedUsage - 50);
}
});
});

View File

@@ -1,127 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addMonths } from "date-fns";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverDuration.Month,
};
const messagesItem = constructArrearProratedItem({
featureId: TestFeature.Messages,
includedUsage: 400,
rolloverConfig,
}) as LimitedItem;
export const pro = constructProduct({
items: [messagesItem],
type: "pro",
isDefault: false,
});
const testCase = "rollover3";
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
it("should attach pro product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
});
const rollover = 250;
let curBalance = messagesItem.included_usage;
it("should create track messages, reset, and have correct rollover", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesItem.included_usage - rollover,
});
await timeout(3000);
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addMonths(new Date(), 1).getTime(),
waitForSeconds: 20,
});
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
const expectedBalance = messagesItem.included_usage + rollover;
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(expectedBalance);
// @ts-expect-error
expect(msgesFeature?.rollovers[0].balance).to.equal(rollover);
curBalance = expectedBalance;
});
});

View File

@@ -104,5 +104,15 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price f
// @ts-expect-error
expect(msgesFeature?.rollovers[0].balance).toBe(rollover);
curBalance = expectedBalance;
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature = nonCachedCustomer.features[TestFeature.Messages];
expect(nonCachedMsgesFeature?.balance).toBe(expectedBalance);
// @ts-expect-error
expect(nonCachedMsgesFeature?.rollovers[0].balance).toBe(rollover);
});
});

View File

@@ -1,127 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addMonths } from "date-fns";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
const rolloverConfig = {
max: 500,
length: 1,
duration: RolloverDuration.Month,
};
const messagesItem = constructArrearProratedItem({
featureId: TestFeature.Messages,
includedUsage: 400,
rolloverConfig,
}) as LimitedItem;
export const pro = constructProduct({
items: [messagesItem],
type: "pro",
isDefault: false,
});
const testCase = "rollover3";
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
const curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
it("should attach pro product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
});
});
const rollover = 250;
let curBalance = messagesItem.included_usage;
it("should create track messages, reset, and have correct rollover", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesItem.included_usage - rollover,
});
await timeout(3000);
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addMonths(new Date(), 1).getTime(),
waitForSeconds: 20,
});
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
const expectedBalance = messagesItem.included_usage + rollover;
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(expectedBalance);
// @ts-expect-error
expect(msgesFeature?.rollovers[0].balance).to.equal(rollover);
curBalance = expectedBalance;
});
});

View File

@@ -1,157 +0,0 @@
import {
type AppEnv,
type Customer,
LegacyVersion,
type LimitedItem,
type Organization,
RolloverDuration,
} from "@autumn/shared";
import { expect } from "chai";
import chalk from "chalk";
import { addMonths } from "date-fns";
import type Stripe from "stripe";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { setupBefore } from "tests/before.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { createProducts } from "tests/utils/productUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { timeout } from "@/utils/genUtils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
const rolloverConfig = {
max: 400,
length: 1,
duration: RolloverDuration.Month,
};
const messagesItem = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 100,
billingUnits: 300,
price: 10,
rolloverConfig,
}) as LimitedItem;
export const pro = constructProduct({
items: [messagesItem],
type: "pro",
isDefault: false,
});
const testCase = "rollover4";
describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => {
const customerId = testCase;
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let customer: Customer;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = res.testClockId!;
customer = res.customer;
});
const paidQuantity = 300;
const balance = paidQuantity + messagesItem.included_usage;
const options = [
{
feature_id: TestFeature.Messages,
quantity: paidQuantity,
},
];
it("should attach pro product", async () => {
await autumn.attach({
customer_id: customerId,
product_id: pro.id,
options,
});
});
const rollover = 50;
it("should create track messages, reset, and have correct rollover", async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: balance - rollover,
});
await timeout(3000);
curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addMonths(new Date(), 1).getTime(),
waitForSeconds: 20,
});
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollovers = msgesFeature?.rollovers;
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(balance + rollover);
expect(rollovers[0].balance).to.equal(rollover);
});
// let usage2 = 50;
it("should reset again and have correct rollover", async () => {
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addMonths(curUnix, 1).getTime(),
waitForSeconds: 20,
});
const newRollover = Math.min(balance + rollover, rolloverConfig.max);
const cus = await autumn.customers.get(customerId);
const msgesFeature = cus.features[TestFeature.Messages];
// @ts-expect-error
const rollovers = msgesFeature?.rollovers;
expect(msgesFeature).to.exist;
expect(msgesFeature?.balance).to.equal(balance + newRollover);
expect(rollovers[0].balance).to.equal(0);
expect(rollovers[1].balance).to.equal(400);
});
});

View File

@@ -113,6 +113,17 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price f
expect(msgesFeature).toBeDefined();
expect(msgesFeature?.balance).toBe(balance + rollover);
expect(rollovers[0].balance).toBe(rollover);
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature = nonCachedCustomer.features[TestFeature.Messages];
// @ts-expect-error
const nonCachedRollovers = nonCachedMsgesFeature?.rollovers;
expect(nonCachedMsgesFeature?.balance).toBe(balance + rollover);
expect(nonCachedRollovers[0].balance).toBe(rollover);
});
// let usage2 = 50;
@@ -134,5 +145,17 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price f
expect(msgesFeature?.balance).toBe(balance + newRollover);
expect(rollovers[0].balance).toBe(0);
expect(rollovers[1].balance).toBe(400);
// Verify non-cached customer balance
await timeout(2000);
const nonCachedCustomer = await autumn.customers.get(customerId, {
skip_cache: "true",
});
const nonCachedMsgesFeature = nonCachedCustomer.features[TestFeature.Messages];
// @ts-expect-error
const nonCachedRollovers = nonCachedMsgesFeature?.rollovers;
expect(nonCachedMsgesFeature?.balance).toBe(balance + newRollover);
expect(nonCachedRollovers[0].balance).toBe(0);
expect(nonCachedRollovers[1].balance).toBe(400);
});
});

Some files were not shown because too many files have changed in this diff Show More