writing tests
This commit is contained in:
@@ -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 \
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' \
|
||||
@@ -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"}`);
|
||||
|
||||
@@ -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
|
||||
505
server/src/_luaScripts/cusLuaScripts/loadCusFeatures.lua
Normal file
505
server/src/_luaScripts/cusLuaScripts/loadCusFeatures.lua
Normal 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
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
405
server/src/_luaScripts/entityLuaScripts/getEntity.lua
Normal file
405
server/src/_luaScripts/entityLuaScripts/getEntity.lua
Normal 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)
|
||||
|
||||
105
server/src/_luaScripts/luaScripts.ts
Normal file
105
server/src/_luaScripts/luaScripts.ts
Normal 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();
|
||||
23
server/src/external/autumn/autumnCli.ts
vendored
23
server/src/external/autumn/autumnCli.ts
vendored
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
168
server/src/internal/balances/track/TRACK_RULES.md
Normal file
168
server/src/internal/balances/track/TRACK_RULES.md
Normal 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 });
|
||||
```
|
||||
@@ -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({
|
||||
|
||||
@@ -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! 🚀
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,8 @@ export const getApiCustomerExpand = async ({
|
||||
orgId: org.id,
|
||||
env,
|
||||
expand,
|
||||
withEntities: expand.includes(CusExpand.Entities),
|
||||
withSubs: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!;
|
||||
});
|
||||
|
||||
|
||||
@@ -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!;
|
||||
});
|
||||
|
||||
|
||||
@@ -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!;
|
||||
});
|
||||
|
||||
|
||||
@@ -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!;
|
||||
});
|
||||
|
||||
|
||||
@@ -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!;
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
236
server/tests/advanced/multiFeature/multiFeature1.test.ts
Normal file
236
server/tests/advanced/multiFeature/multiFeature1.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
200
server/tests/advanced/multiFeature/multiFeature2.test.ts
Normal file
200
server/tests/advanced/multiFeature/multiFeature2.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
177
server/tests/advanced/multiFeature/multiFeature3.test.ts
Normal file
177
server/tests/advanced/multiFeature/multiFeature3.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
// })
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
// });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user