diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 8d42367b8..b7f27073c 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -14,26 +14,28 @@ 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/allocated' \ +# BUN_PARALLEL_COMPACT \ # 'server/tests/balances/check/basic' \ # 'server/tests/balances/check/credit-systems' \ # 'server/tests/balances/check/misc' \ + # 'server/tests/balances/check/prepaid' \ # 'server/tests/balances/track/basic' \ # '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/track/allocated' \ + # 'server/tests/balances/track/entity-balances' \ + # 'server/tests/balances/track/concurrency' \ -# 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 \ + +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 \ diff --git a/scripts/testGroups/g3.sh b/scripts/testGroups/g3.sh index 3bfcda66f..4d1b12bee 100755 --- a/scripts/testGroups/g3.sh +++ b/scripts/testGroups/g3.sh @@ -13,9 +13,11 @@ if [[ "$1" == *"setup"* ]]; then fi BUN_PARALLEL_COMPACT \ - 'server/tests/contUse/track' \ 'server/tests/contUse/roles' \ 'server/tests/contUse/update' \ 'server/tests/contUse/entities' \ + 'server/tests/balances/track/paid-allocated' \ + 'server/tests/balances/set-usage' \ --max=6 + # 'server/tests/contUse/track' \ diff --git a/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua b/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua index 8d79661c5..64023f400 100644 --- a/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua +++ b/server/src/_luaScripts/deductionLuaScripts/batchDeduction.lua @@ -983,7 +983,7 @@ local function processRequest(request, loadedCusFeatures, entityFeatureStates) -- If credit system couldn't cover all, calculate how much of original remains if result.remaining ~= 0 then local creditCovered = creditAmount - result.remaining - local originalCovered = creditCovered / creditItem.credit_amount + local originalCovered = creditCovered / creditItem.credit_cost remainingAmount = remainingAmount - originalCovered else -- Credit system covered everything @@ -1059,6 +1059,20 @@ for _, request in ipairs(requests) do end end +-- Helper function to apply legacy continuous_use logic +-- Legacy case: continuous_use features always allow overage +local function applyContinuousUseLegacy(balance) + if balance.feature and balance.feature.type == "continuous_use" then + balance.overage_allowed = true + -- Apply to breakdowns as well + if balance.breakdown then + for _, breakdown in ipairs(balance.breakdown) do + breakdown.overage_allowed = true + end + end + end +end + -- Get list of all customer feature IDs local baseJson = redis.call("GET", cacheKey) local allFeatureIds = {} @@ -1074,6 +1088,10 @@ for _, featureId in ipairs(allFeatureIds) do if balance then -- Add id field for compatibility with existing code balance.id = featureId + + -- Apply legacy continuous_use logic + applyContinuousUseLegacy(balance) + loadedCusFeatures[featureId] = balance end end @@ -1099,6 +1117,10 @@ for _, entityId in ipairs(entityIds) do if balance then -- Add id field for compatibility with existing code balance.id = featureId + + -- Apply legacy continuous_use logic + applyContinuousUseLegacy(balance) + entityFeatureStates[entityId][featureId] = balance end end @@ -1128,6 +1150,28 @@ for entityId, _ in pairs(changedEntityIds) do table.insert(changedEntityIdsArray, entityId) end +-- Calculate actual deductions per feature from keyDeltas +-- Sum up usage deltas (or granted_balance deltas if adjustGrantedBalance is true) +local featureDeductions = {} +for key, deltas in pairs(keyDeltas) do + -- Extract featureId from key (format: "{orgId}:env:customer:{version}:customerId:balances:featureId" or with ":entity:entityId:balances:featureId") + local featureId = key:match(":balances:([^:]+)$") + if featureId then + local deductionAmount = 0 + if adjustGrantedBalance then + -- When adjustGrantedBalance is true, we decrement granted_balance (negative delta = deduction) + deductionAmount = -(deltas.granted_balance or 0) + else + -- Normal case: increment usage (positive delta = deduction) + deductionAmount = deltas.usage or 0 + end + + if deductionAmount ~= 0 then + featureDeductions[featureId] = (featureDeductions[featureId] or 0) + deductionAmount + end + end +end + -- ============================================================================ -- LOAD CHANGED BALANCES AFTER DEDUCTIONS (MERGED VERSIONS) -- ============================================================================ @@ -1169,7 +1213,8 @@ return cjson.encode({ results = results, customerChanged = customerChanged, changedEntityIds = changedEntityIdsArray, - balances = changedBalances + balances = changedBalances, + featureDeductions = featureDeductions }) diff --git a/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua b/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua index bf632eabf..884778f06 100644 --- a/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua +++ b/server/src/_luaScripts/entityLuaScripts/setEntitiesBatch.lua @@ -1,5 +1,6 @@ -- setEntitiesBatch.lua -- Atomically stores multiple entity objects in a single call +-- Uses new ApiEntity schema with balances (replacing features) and subscriptions (replacing products) -- ARGV[1]: JSON array of entity data objects: [{entityId: "...", entityData: {...}}, ...] -- ARGV[2]: org_id -- ARGV[3]: env @@ -11,14 +12,6 @@ local env = ARGV[3] -- Decode the entities array local entities = cjson.decode(entitiesJson) --- Helper function to convert values to strings, handling cjson.null -local function toString(value) - if value == cjson.null or value == nil then - return "null" - end - return tostring(value) -end - -- Process each entity for _, entityWrapper in ipairs(entities) do local entityId = entityWrapper.entityId @@ -28,15 +21,18 @@ for _, entityWrapper in ipairs(entities) do local customerId = entityData.customer_id local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId) - -- Extract feature IDs for tracking - local featureIds = {} - if entityData.features then - for featureId, _ in pairs(entityData.features) do - table.insert(featureIds, featureId) + -- Extract balance IDs (feature_ids) for tracking + local balanceFeatureIds = {} + if entityData.balances then + for featureId, _ in pairs(entityData.balances) do + table.insert(balanceFeatureIds, featureId) end end - -- Build base entity object (everything except features) + -- Store balance feature IDs in the base data for retrieval + entityData._balanceFeatureIds = balanceFeatureIds + + -- Build base entity object (everything except balances) local baseEntity = { id = entityData.id, autumn_id = entityData.autumn_id, @@ -44,90 +40,16 @@ for _, entityWrapper in ipairs(entities) do customer_id = entityData.customer_id, created_at = entityData.created_at, env = entityData.env, - products = entityData.products, - _featureIds = featureIds + subscriptions = entityData.subscriptions, + _balanceFeatureIds = balanceFeatureIds } -- Store base entity as JSON with TTL redis.call("SET", cacheKey, cjson.encode(baseEntity)) redis.call("EXPIRE", cacheKey, CACHE_TTL_SECONDS) - -- Store each feature as HSET - if entityData.features then - for featureId, featureData in pairs(entityData.features) do - local featureKey = cacheKey .. ":features:" .. featureId - - -- Store breakdown count for reconstruction - local breakdownCount = 0 - if featureData.breakdown then - breakdownCount = #featureData.breakdown - end - - -- Store rollover count for reconstruction - local rolloverCount = 0 - if featureData.rollovers then - rolloverCount = #featureData.rollovers - end - - -- Serialize credit_schema as JSON string - local creditSchemaJson = "null" - if featureData.credit_schema and #featureData.credit_schema > 0 then - creditSchemaJson = cjson.encode(featureData.credit_schema) - end - - -- Store all top-level feature fields in a single HSET call with TTL - redis.call("HSET", featureKey, - "id", toString(featureData.id), - "type", toString(featureData.type), - "name", toString(featureData.name), - "interval", toString(featureData.interval), - "interval_count", toString(featureData.interval_count), - "unlimited", toString(featureData.unlimited), - "balance", toString(featureData.balance), - "usage", toString(featureData.usage), - "included_usage", toString(featureData.included_usage), - "next_reset_at", toString(featureData.next_reset_at), - "overage_allowed", toString(featureData.overage_allowed), - "usage_limit", toString(featureData.usage_limit), - "credit_schema", creditSchemaJson, - "_breakdown_count", toString(breakdownCount), - "_rollover_count", toString(rolloverCount) - ) - redis.call("EXPIRE", featureKey, CACHE_TTL_SECONDS) - - -- Store each rollover item as separate HSET with TTL (single call per rollover) - if featureData.rollovers then - for index, rolloverItem in ipairs(featureData.rollovers) do - local rolloverKey = cacheKey .. ":features:" .. featureId .. ":rollover:" .. (index - 1) - - redis.call("HSET", rolloverKey, - "balance", toString(rolloverItem.balance), - "expires_at", toString(rolloverItem.expires_at) - ) - redis.call("EXPIRE", rolloverKey, CACHE_TTL_SECONDS) - end - end - - -- Store each breakdown item as separate HSET with TTL (single call per breakdown) - if featureData.breakdown then - for index, breakdownItem in ipairs(featureData.breakdown) do - local breakdownKey = cacheKey .. ":features:" .. featureId .. ":breakdown:" .. (index - 1) - - redis.call("HSET", breakdownKey, - "interval", toString(breakdownItem.interval), - "interval_count", toString(breakdownItem.interval_count), - "balance", toString(breakdownItem.balance), - "usage", toString(breakdownItem.usage), - "included_usage", toString(breakdownItem.included_usage), - "next_reset_at", toString(breakdownItem.next_reset_at), - "usage_limit", toString(breakdownItem.usage_limit), - "overage_allowed", toString(breakdownItem.overage_allowed) - ) - redis.call("EXPIRE", breakdownKey, CACHE_TTL_SECONDS) - end - end - end - end + -- Store balances using shared utility function + storeBalances(cacheKey, entityData.balances) end return "OK" diff --git a/server/src/_luaScripts/entityLuaScripts/setEntity.lua b/server/src/_luaScripts/entityLuaScripts/setEntity.lua index 8dad2cd32..ea6fab345 100644 --- a/server/src/_luaScripts/entityLuaScripts/setEntity.lua +++ b/server/src/_luaScripts/entityLuaScripts/setEntity.lua @@ -1,6 +1,5 @@ -- setEntity.lua -- Atomically stores an entity object with base data as JSON and balances/breakdowns as HSETs --- Uses new ApiEntity schema with balances (replacing features) and subscriptions (replacing products) -- ARGV[1]: serialized entity data JSON string -- ARGV[2]: org_id -- ARGV[3]: env diff --git a/server/src/_luaScripts/luaScripts.ts b/server/src/_luaScripts/luaScripts.ts index a2ccd6e53..911013262 100644 --- a/server/src/_luaScripts/luaScripts.ts +++ b/server/src/_luaScripts/luaScripts.ts @@ -115,12 +115,12 @@ const setEntityScript = readFileSync( ); export const SET_ENTITY_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${CHECK_ENTITY_CACHE_EXISTS}\n${setEntityScript}`; -// Prepend cache key utils to SET_ENTITIES_BATCH_SCRIPT +// Prepend cache key utils and balance utils to SET_ENTITIES_BATCH_SCRIPT const setEntitiesBatchScript = readFileSync( join(__dirname, "entityLuaScripts/setEntitiesBatch.lua"), "utf-8", ); -export const SET_ENTITIES_BATCH_SCRIPT = `${CACHE_KEY_UTILS}\n${setEntitiesBatchScript}`; +export const SET_ENTITIES_BATCH_SCRIPT = `${CACHE_KEY_UTILS}\n${CACHE_BALANCE_UTILS}\n${setEntitiesBatchScript}`; // Prepend cache key utils to SET_ENTITY_PRODUCTS_SCRIPT const setEntityProductsScript = readFileSync( diff --git a/server/src/_luaScripts/luaUtils/loadBalances.lua b/server/src/_luaScripts/luaUtils/loadBalances.lua index dbeda8518..01a85121f 100644 --- a/server/src/_luaScripts/luaUtils/loadBalances.lua +++ b/server/src/_luaScripts/luaUtils/loadBalances.lua @@ -202,6 +202,30 @@ local function mergeBalanceReset(target, source) end end +-- Helper function to generate breakdown item key for matching +-- Key format: "interval_count:interval:overage_allowed" +-- Example: "1:month:true" or "1:month:false" +local function getBreakdownItemKey(breakdownItem) + if not breakdownItem then + return nil + end + + local intervalCount = 1 + local interval = "none" + + -- Extract interval and interval_count from reset object + if breakdownItem.reset and breakdownItem.reset ~= cjson.null and type(breakdownItem.reset) == "table" then + interval = breakdownItem.reset.interval or "none" + intervalCount = breakdownItem.reset.interval_count or 1 + end + + -- Get overage_allowed (usage model) + local overageAllowed = breakdownItem.overage_allowed or false + + -- Return key in format: "interval_count:interval:overage_allowed" + return tostring(intervalCount) .. ":" .. interval .. ":" .. tostring(overageAllowed) +end + -- Helper function to merge source balance into target balance -- Mutates targetBalance by adding sourceBalance's balances, usage, breakdowns, and rollovers -- Also handles minimum resets_at (earliest reset time) and overage_allowed (true if any is true) @@ -214,18 +238,18 @@ local function mergeFeatureBalances(targetBalance, sourceBalance) mergeBalanceReset(targetBalance, sourceBalance) -- Merge breakdown balances and usage - -- Breakdown items are matched by reset.interval, not by index + -- Breakdown items are matched by key (interval_count:interval:overage_allowed) -- If a matching breakdown exists, merge it; otherwise, add as new breakdown item if sourceBalance.breakdown then for _, sourceBreakdown in ipairs(sourceBalance.breakdown) do - local sourceInterval = sourceBreakdown.reset and sourceBreakdown.reset.interval + local sourceKey = getBreakdownItemKey(sourceBreakdown) local foundMatch = false - -- Try to find matching breakdown by reset.interval + -- Try to find matching breakdown by key if targetBalance.breakdown then for _, targetBreakdown in ipairs(targetBalance.breakdown) do - local targetInterval = targetBreakdown.reset and targetBreakdown.reset.interval - if sourceInterval and targetInterval and sourceInterval == targetInterval then + local targetKey = getBreakdownItemKey(targetBreakdown) + if sourceKey and targetKey and sourceKey == targetKey then -- Found matching breakdown - merge it mergeBalanceNumericFields(targetBreakdown, sourceBreakdown) mergeBalanceOverageAllowed(targetBreakdown, sourceBreakdown) diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 0eb163ce4..f84bc12c7 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -4,7 +4,7 @@ import dotenv from "dotenv"; dotenv.config(); import { - type ApiEntity, + type ApiBaseEntity, type AttachBody, type BalancesUpdateParams, type CheckQuery, @@ -290,20 +290,20 @@ export class AutumnInt { return data; }, - get: async ( + get: async < + T = Customer & { + invoices: any[]; + autumn_id?: string; + entities?: ApiBaseEntity[]; + }, + >( customerId: string, params?: { expand?: CusExpand[]; skip_cache?: string; with_autumn_id?: boolean; }, - ): Promise< - Customer & { - invoices: any[]; - autumn_id?: string; - entities?: ApiEntity[]; - } - > => { + ): Promise => { const queryParams = new URLSearchParams(); const defaultParams = { expand: [CusExpand.Invoices], diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts index fa3237a18..2bfbd36ba 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleContUsePrices.ts @@ -55,9 +55,7 @@ export const handleContUsePrices = async ({ const replaceables = cusEnt.replaceables.filter((r) => r.delete_next_cycle); - if (replaceables.length === 0) { - return false; - } + if (replaceables.length === 0) return false; logger.info(`🚀 Deleting replaceables for ${feature.id}`); diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts index e1ab81ad3..96ffc2d77 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleUsagePrices.ts @@ -16,7 +16,6 @@ import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRol import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js"; import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js"; -import { getAllFullCustomers } from "@/utils/scriptUtils/getAll/getAllAutumnCustomers.js"; import { submitUsageToStripe } from "../../stripeMeterUtils.js"; import { getInvoiceItemForUsage } from "../../stripePriceUtils.js"; import { subToPeriodStartEnd } from "../../stripeSubUtils/convertSubUtils.js"; diff --git a/server/src/internal/api/check/checkUtils/getCheckData.ts b/server/src/internal/api/check/checkUtils/getCheckData.ts index c0dcc94ee..0cdead55e 100644 --- a/server/src/internal/api/check/checkUtils/getCheckData.ts +++ b/server/src/internal/api/check/checkUtils/getCheckData.ts @@ -1,6 +1,6 @@ import { type ApiCustomer, - type ApiEntity, + type ApiEntityV1, type CheckParams, type CustomerLegacyData, ErrCode, @@ -44,7 +44,7 @@ export const getFeatureToUse = ({ }: { creditSystems: Feature[]; feature: Feature; - apiEntity: ApiCustomer | ApiEntity; + apiEntity: ApiCustomer | ApiEntityV1; requiredBalance: number; }) => { // 1. If there's a credit system & cusEnts for that credit system -> return credit system @@ -95,7 +95,7 @@ export const getCheckData = async ({ }); } - let apiEntity: ApiCustomer | ApiEntity | undefined; + let apiEntity: ApiCustomer | ApiEntityV1 | undefined; let legacyData: CustomerLegacyData | undefined; const { apiCustomer, legacyData: legacyDataResult } = await getOrCreateApiCustomer({ diff --git a/server/src/internal/api/entities/getApiEntity.ts b/server/src/internal/api/entities/getApiEntity.ts deleted file mode 100644 index 572a3177e..000000000 --- a/server/src/internal/api/entities/getApiEntity.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ApiEntity, Entity, EntityExpand, FullCustomer } from "@autumn/shared"; -import { - AffectedResource, - ApiVersion, - applyResponseVersionChanges, -} from "@autumn/shared"; -import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; -import { getSingleEntityResponse } from "./getEntityUtils.js"; - -/** - * Get API entity response with version transformations applied - */ -export const getApiEntity = async ({ - ctx, - entity, - fullCus, - expand, - withAutumnId = false, -}: { - ctx: AutumnContext; - entity: Entity; - fullCus: FullCustomer; - expand?: EntityExpand[]; - withAutumnId?: boolean; -}): Promise => { - const { entity: entityData, legacyData } = await getSingleEntityResponse({ - ctx, - entityId: entity.id, - fullCus, - entity, - withAutumnId, - }); - - // For v1.2/v1.4 clients, transform plans → products - const isLegacyVersion = - ctx.apiVersion && ctx.apiVersion.lte(ApiVersion.V1_Beta); - - if (isLegacyVersion) { - console.log("Legacy data keys:", Object.keys(legacyData)); - console.log( - "First plan legacy data:", - legacyData[entityData.plans?.[0]?.plan_id], - ); - - // Use the built-in version change system - const transformed = applyResponseVersionChanges({ - input: { - ...entityData, - features: {}, // Exclude features to avoid transformation errors - }, - legacyData: { - cusProductLegacyData: legacyData, - cusFeatureLegacyData: {}, - }, - targetVersion: ctx.apiVersion, - resource: AffectedResource.Customer, - }); - - // Merge back original features - return { - ...transformed, - features: entityData.features, - } as unknown as ApiEntity; - } - - return entityData; -}; diff --git a/server/src/internal/api/entities/getEntityUtils.ts b/server/src/internal/api/entities/getEntityUtils.ts index 8e75fc317..bac600d59 100644 --- a/server/src/internal/api/entities/getEntityUtils.ts +++ b/server/src/internal/api/entities/getEntityUtils.ts @@ -1,5 +1,5 @@ import { - type ApiEntity, + type ApiEntityV1, type Entity, ErrCode, type FullCusProduct, @@ -93,9 +93,9 @@ export const getSingleEntityResponse = async ({ // feature_id: entity.feature_id, customer_id: fullCus.id || fullCus.internal_id, env, - plans: apiSubscriptions, - features: cusFeatures, - } satisfies ApiEntity, + subscriptions: apiSubscriptions, + balances: cusFeatures, + } satisfies ApiEntityV1, legacyData, }; }; diff --git a/server/src/internal/balances/track/redisTrackUtils/deductFromCache.ts b/server/src/internal/balances/track/redisTrackUtils/deductFromCache.ts index d0242dc2c..e8a5a2273 100644 --- a/server/src/internal/balances/track/redisTrackUtils/deductFromCache.ts +++ b/server/src/internal/balances/track/redisTrackUtils/deductFromCache.ts @@ -24,21 +24,18 @@ export const deductFromCache = async ({ featureId: string; amount: number; entityId?: string; -}): Promise => { +}): Promise | undefined> => { const { org, env } = ctx; // Execute Redis deduction directly (no batching to avoid race conditions) + let featureDeductions: Record | undefined; + await tryRedisWrite(async () => { const result = await executeBatchDeduction({ redis, requests: [ { - featureDeductions: [ - { - featureId, - amount, - }, - ], + featureDeductions: [{ featureId, amount }], overageBehavior: "cap", // Cap since Postgres already handled validation entityId, }, @@ -53,7 +50,12 @@ export const deductFromCache = async ({ `Failed to deduct from cache for ${customerId}, feature ${featureId}: ${result.error}`, ); } + + // Capture feature deductions for return + featureDeductions = result.featureDeductions; }); + + return featureDeductions; }; // Keep the old name for backward compatibility diff --git a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts index cb7fbd238..3eccc36ec 100644 --- a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts +++ b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts @@ -27,6 +27,7 @@ interface BatchDeductionResult { customerChanged?: boolean; // True if customer-level features were modified changedEntityIds?: string[]; // Array of entity IDs that were modified balances?: Record; // Object of changed balances keyed by featureId + featureDeductions?: Record; // Actual amounts deducted per feature debug?: unknown; // For debugging purposes } @@ -67,6 +68,17 @@ export const executeBatchDeduction = async ({ console.log("🔍 Lua debug info:", JSON.stringify(parsed.debug, null, 2)); } + // Log actual feature deductions + if ( + parsed.featureDeductions && + Object.keys(parsed.featureDeductions).length > 0 + ) { + console.log( + "✅ Feature deductions from Redis:", + parsed.featureDeductions, + ); + } + return parsed; } catch (error) { console.error("Error executing batch deduction:", error); diff --git a/server/src/internal/balances/track/syncUtils/syncItem.ts b/server/src/internal/balances/track/syncUtils/syncItem.ts index 1f3ac9a8a..153800cf9 100644 --- a/server/src/internal/balances/track/syncUtils/syncItem.ts +++ b/server/src/internal/balances/track/syncUtils/syncItem.ts @@ -2,7 +2,7 @@ import type { Feature, FullCusEntWithFullCusProduct } from "@autumn/shared"; import { type ApiBalance, type ApiCustomer, - type ApiEntity, + type ApiEntityV1, cusEntToPrepaidQuantity, cusProductsToCusEnts, filterEntityLevelCusProducts, @@ -77,7 +77,7 @@ export const syncItem = async ({ // 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; + let redisEntity: ApiCustomer | ApiEntityV1; if (entityId) { const { apiEntity } = await getCachedApiEntity({ diff --git a/server/tests/balances/track/allocated/track-allocated.test.ts b/server/src/internal/balances/track/trackTypes.ts similarity index 100% rename from server/tests/balances/track/allocated/track-allocated.test.ts rename to server/src/internal/balances/track/trackTypes.ts diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql b/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql index 2d0b1dbeb..e299dfa57 100644 --- a/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql +++ b/server/src/internal/balances/track/trackUtils/deductRpc/performDeduction.sql @@ -188,7 +188,7 @@ BEGIN WHERE ce.id = ent_id; END IF; - -- Track in updates_json + -- Track in updates_json (deducted is inclusive of additional_deducted) updates_json := jsonb_set( updates_json, ARRAY[ent_id], @@ -197,7 +197,7 @@ BEGIN 'additional_balance', new_additional_balance, 'additional_granted_balance', new_additional_granted_balance, 'entities', new_entities, - 'deducted', deducted, + 'deducted', deducted + additional_deducted, 'additional_deducted', additional_deducted ) ); @@ -281,6 +281,7 @@ BEGIN -- Update or create entry in updates_json IF updates_json ? ent_id THEN -- Update existing entry (entitlement was updated in both passes) + -- deducted from Pass 1 already includes additional_deducted, so just add Pass 2 deducted updates_json := jsonb_set( updates_json, ARRAY[ent_id], @@ -294,7 +295,7 @@ BEGIN ) ); ELSE - -- Create new entry (entitlement only updated in Pass 2) + -- Create new entry (entitlement only updated in Pass 2, no additional_balance deduction) updates_json := jsonb_set( updates_json, ARRAY[ent_id], diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index 423f00b48..4563adde1 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -1,4 +1,8 @@ -import type { Event, SortCusEntParams } from "@autumn/shared"; +import type { + Event, + PgDeductionUpdate, + SortCusEntParams, +} from "@autumn/shared"; import { CusProductStatus, cusEntToCusPrice, @@ -15,23 +19,21 @@ import { orgToInStatuses, updateCusEntInFullCus, } from "@autumn/shared"; - +import { Decimal } from "decimal.js"; import { sql } from "drizzle-orm"; import type { DrizzleCli } from "../../../../db/initDrizzle.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import { adjustAllowance } from "../../../../trigger/adjustAllowance.js"; import { EventService } from "../../../api/events/EventService.js"; import { CusService } from "../../../customers/CusService.js"; - import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService.js"; import { getTotalNegativeBalance, getUnlimitedAndUsageAllowed, } from "../../../customers/cusProducts/cusEnts/cusEntUtils.js"; +import { deleteCachedApiCustomer } from "../../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.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"; @@ -51,10 +53,6 @@ export type DeductionTxParams = { sortParams?: SortCusEntParams; }; -export type ActualDeductions = { - [featureId: string]: number; // Actual amount deducted from Postgres -}; - export const deductFromCusEnts = async ({ ctx, customerId, @@ -68,10 +66,9 @@ export const deductFromCusEnts = async ({ sortParams, }: DeductionTxParams): Promise<{ fullCus: FullCustomer | undefined; - actualDeductions: ActualDeductions; - remainingAmounts: { - [featureId: string]: number; - }; + isPaidAllocated: boolean; + actualDeductions: Record; + remainingAmounts: Record; }> => { const { db, org, env } = ctx; @@ -88,17 +85,17 @@ export const deductFromCusEnts = async ({ }); } - const printLogs = false; + const printLogs = true; - if (printLogs) { - console.log( - `Deductions: `, - deductions.map((d) => ({ - feature_id: d.feature.id, - deduction: d.deduction, - })), - ); - } + // if (printLogs) { + // console.log( + // `Deductions: `, + // deductions.map((d) => ({ + // feature_id: d.feature.id, + // deduction: d.deduction, + // })), + // ); + // } const isPaidAllocated = deductions.some((d) => isPaidContinuousUse({ @@ -107,13 +104,14 @@ export const deductFromCusEnts = async ({ }), ); - if (isPaidAllocated) overageBehaviour = "reject"; + if (isPaidAllocated) { + overageBehaviour = "reject"; + skipAdditionalBalance = true; + } // Track actual deductions per feature - const actualDeductions: ActualDeductions = {}; - const remainingAmounts: { - [featureId: string]: number; - } = {}; + const actualDeductions: Record = {}; + const remainingAmounts: Record = {}; // Need to deduct from customer entitlement... for (const deduction of deductions) { @@ -124,10 +122,6 @@ 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), @@ -137,16 +131,16 @@ export const deductFromCusEnts = async ({ sortParams, }); - if (printLogs) { - console.log( - `Cus Ents: `, - cusEnts.map((ce) => ({ - balance: ce.balance, - entity_id: ce.customer_product.entity_id, - cus_ent_id: ce.id, - })), - ); - } + // 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, @@ -211,24 +205,13 @@ export const deductFromCusEnts = async ({ // Parse the JSONB result const resultJson = result[0]?.deduct_from_cus_ents as { - updates: Record< - string, - { - balance: number; - additional_balance: number; - additional_granted_balance?: number; - entities: any; - adjustment: number; - deducted: number; - additional_deducted?: number; - } - >; + updates: Record; remaining: number; }; // log updates if (printLogs) { - console.log(`Updates: `, resultJson.updates); + console.log(`📊 Postgres updates for ${feature.id}:`, resultJson.updates); } if (!resultJson) { @@ -255,8 +238,17 @@ export const deductFromCusEnts = async ({ 0, ); - // Store actual deduction for this feature - actualDeductions[feature.id] = totalDeducted; + // Convert updates to actual deduction + for (const [cusEntId, update] of Object.entries(updates)) { + const cusEnt = cusEnts.find((ce) => ce.id === cusEntId); + const deductedFeature = cusEnt?.entitlement.feature; + if (!deductedFeature) continue; + + const currentDeduction = actualDeductions[deductedFeature.id] || 0; + actualDeductions[deductedFeature.id] = new Decimal(update.deducted) + .add(currentDeduction) + .toNumber(); + } // Log deduction details if (targetBalance !== undefined) { @@ -318,14 +310,11 @@ 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) { @@ -337,9 +326,11 @@ export const deductFromCusEnts = async ({ }, }); - // Adjust the actual deduction to reflect replaceables - actualDeductions[feature.id] = - (actualDeductions[feature.id] || 0) + replaceableAdjustment; + // Update the updates object with the new balance + updates[cusEntId].balance = reUpdatedBalance; + updates[cusEntId].newReplaceables = newReplaceables ?? undefined; + updates[cusEntId].deletedReplaceables = + deletedReplaceables ?? undefined; } updateCusEntInFullCus({ @@ -350,10 +341,16 @@ export const deductFromCusEnts = async ({ } } + // Log summary of all Postgres deductions + if (printLogs && Object.keys(actualDeductions).length > 0) { + console.log("📊 Total Postgres deductions:", actualDeductions); + } + return { fullCus, actualDeductions, remainingAmounts, + isPaidAllocated, }; }; @@ -362,14 +359,14 @@ export const runDeductionTx = async ( ): Promise<{ fullCus: FullCustomer | undefined; event: Event | undefined; - actualDeductions: ActualDeductions; + actualDeductions: Record; }> => { const ctx = params.ctx; const { db } = ctx; let fullCus: FullCustomer | undefined; let event: Event | undefined; - let actualDeductions: ActualDeductions = {}; + let actualDeductions: Record = {}; await db.transaction( async (tx) => { @@ -405,34 +402,39 @@ export const runDeductionTx = async ( } 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" - ); + // 1. If paid allocated, delete cache + if (result.isPaidAllocated) { + await deleteCachedApiCustomer({ + customerId: fullCus.id ?? "", + orgId: ctx.org.id, + env: ctx.env, + }); + } else { + // 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 = false; - - 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) { - console.log("Deducted from Redis cache", { + for (const [featureId, deductedAmount] of Object.entries( + actualDeductions, + )) { + if (deductedAmount !== 0) { + // Only deduct if something was actually deducted + console.log( + `Deducting from Redis cache: ${featureId}, ${deductedAmount}`, + ); + await deductFromCache({ + ctx, + customerId: fullCus.id ?? "", featureId, - deductedAmount, + amount: deductedAmount, + entityId: params.entityId, }); } } + + // Log summary comparison } } }, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts index a6c19de42..605874ee5 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils.ts @@ -313,7 +313,7 @@ export const getTotalNegativeBalance = ({ } } - if (totalNegative == 0) { + if (totalNegative === 0) { if (Object.values(entities).length > 0) { const entityBalances = Object.values(entities).map((e) => e.balance || 0); return Math.min(...entityBalances); diff --git a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts index e5b1aa37f..785c9ebc0 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts @@ -20,6 +20,8 @@ export const getResetBalancesUpdate = ({ for (const entityId in newEntities) { newEntities[entityId].balance = newBalance; newEntities[entityId].adjustment = 0; + newEntities[entityId].additional_balance = 0; + newEntities[entityId].additional_granted_balance = 0; } update = { entities: newEntities }; } else { diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index 35cab4c31..3cf2f1810 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -2,9 +2,9 @@ import { type ApiCustomer, ApiCustomerSchema, type AppEnv, - CusExpand, type CustomerLegacyData, filterOutEntitiesFromCusProducts, + filterPlanAndFeatureExpand, } from "@autumn/shared"; import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; import { GET_CUSTOMER_SCRIPT } from "@lua/luaScripts.js"; @@ -132,14 +132,13 @@ export const getCachedApiCustomer = async ({ const { apiCustomer, legacyData } = await getExpandedApiCustomer(); - if (!ctx.expand.includes(CusExpand.BalanceFeature)) { - for (const featureId in apiCustomer.balances) { - apiCustomer.balances[featureId].feature = undefined; - } - } + const filteredApiCustomer = filterPlanAndFeatureExpand({ + expand: ctx.expand, + target: apiCustomer, + }); return { - apiCustomer, + apiCustomer: filteredApiCustomer, legacyData, }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts index 0ba508f95..3ed80efcd 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.ts @@ -1,5 +1,5 @@ import { - type ApiEntity, + type ApiEntityV1, addToExpand, CusExpand, type FullCustomer, @@ -34,7 +34,10 @@ export const setCachedApiCustomer = async ({ }) => { const { org, env, logger } = ctx; - const ctxWithExpand = addToExpand({ ctx, add: [CusExpand.BalanceFeature] }); + const ctxWithExpand = addToExpand({ + ctx, + add: [CusExpand.BalanceFeature, CusExpand.SubscriptionPlan], + }); // Build master api customer (customer-level features only) const { apiCustomer: masterApiCustomer, legacyData } = @@ -55,7 +58,7 @@ export const setCachedApiCustomer = async ({ }); // Build entities first - const entityBatch: { entityId: string; entityData: ApiEntity }[] = []; + const entityBatch: { entityId: string; entityData: ApiEntityV1 }[] = []; const entityFullCus = { ...fullCus, customer_products: entityLevelCusProducts, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 5e2a6b1db..a541da653 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -169,12 +169,12 @@ export const getApiBalance = ({ }), ); - // const totalUnused = sumValues( - // cusEnts.map((cusEnt) => { - // const { unused } = getCusEntBalance({ cusEnt, entityId }); - // return unused; - // }), - // ); + const totalUnused = sumValues( + cusEnts.map((cusEnt) => { + const { unused } = getCusEntBalance({ cusEnt, entityId }); + return unused; + }), + ); const totalAdditionalBalance = sumValues( cusEnts.map((cusEnt) => { @@ -226,13 +226,12 @@ export const getApiBalance = ({ const currentBalance = new Decimal(Math.max(0, totalBalanceWithRollovers)) .add(totalAdditionalBalance) + .add(totalUnused) .toNumber(); - // .add(totalUnused) // 4. Usage const totalUsage = new Decimal(grantedBalance) .add(totalPurchasedBalance) - // .add(totalAdjustment) .sub(currentBalance) .toNumber(); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index b3cb17b55..f88d27797 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -4,9 +4,11 @@ import { CusExpand, type FullCusProduct, type FullCustomer, + filterExpand, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js"; +import { InvoiceService } from "../../../invoices/InvoiceService.js"; import { CusService } from "../../CusService.js"; import { getCusPaymentMethodRes } from "../cusResponseUtils/getCusPaymentMethodRes.js"; import { getCusReferrals } from "../cusResponseUtils/getCusReferrals.js"; @@ -24,8 +26,13 @@ export const getApiCustomerExpand = async ({ }): Promise => { const { org, env, db, logger, expand } = ctx; - const asyncExpand = expand.filter((e) => e !== CusExpand.BalanceFeature); - if (asyncExpand.length === 0) return {}; + // Filter out balances.feature and subscriptions.plan + const filteredExpand = filterExpand({ + expand, + filter: [CusExpand.BalanceFeature, CusExpand.SubscriptionPlan], + }); + + if (filteredExpand.length === 0) return {}; if (!fullCus) { fullCus = await CusService.getFull({ @@ -53,16 +60,26 @@ export const getApiCustomerExpand = async ({ return undefined; }; - const invoices = expand.includes(CusExpand.Invoices) - ? invoicesToResponse({ - invoices: fullCus.invoices || [], - logger, - }) - : undefined; + const getInvoices = async () => { + if (!expand.includes(CusExpand.Invoices)) { + return undefined; + } + + const invoices = await InvoiceService.list({ + db, + internalCustomerId: fullCus.internal_id, + internalEntityId: fullCus.entity?.internal_id, + }); + + return invoicesToResponse({ + invoices, + logger, + }); + }; const cusExpand = expand as CusExpand[]; - const [rewards, upcomingInvoice, referrals, paymentMethod] = + const [rewards, upcomingInvoice, referrals, paymentMethod, invoices] = await Promise.all([ getCusRewards({ org, @@ -91,6 +108,7 @@ export const getApiCustomerExpand = async ({ fullCus, expand: cusExpand, }), + getInvoices(), ]); return { diff --git a/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts index f34ec7f84..f740107f1 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts @@ -1,6 +1,6 @@ import { type ApiCustomer, - ApiEntitySchema, + ApiEntityV1Schema, type CustomerData, type CustomerLegacyData, CustomerNotFoundError, @@ -45,6 +45,7 @@ export const getOrCreateApiCustomer = async ({ metadata: customerData?.metadata || {}, stripe_id: customerData?.stripe_id, }, + createDefaultProducts: customerData?.disable_default !== true, }); const res = await getCachedApiCustomer({ @@ -89,6 +90,7 @@ export const getOrCreateApiCustomer = async ({ metadata: customerData?.metadata || {}, stripe_id: customerData?.stripe_id, }, + createDefaultProducts: customerData?.disable_default !== true, }); const res = await getCachedApiCustomer({ @@ -167,7 +169,7 @@ export const getOrCreateApiCustomer = async ({ customerId, }); - const apiEntity = ApiEntitySchema.parse(newEntity); + const apiEntity = ApiEntityV1Schema.parse(newEntity); apiCustomer.entities = [...(apiCustomer.entities || []), apiEntity]; } diff --git a/server/src/internal/customers/handlers/handleCreateCustomer.ts b/server/src/internal/customers/handlers/handleCreateCustomer.ts index 7c0466744..97719c6a5 100644 --- a/server/src/internal/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/customers/handlers/handleCreateCustomer.ts @@ -189,6 +189,8 @@ export const handleCreateCustomer = async ({ }) => { const newCus = CreateCustomerSchema.parse(cusData); + console.log("Create default products:", createDefaultProducts); + // 1. If no ID and email is not NULL let createdCustomer: Customer; diff --git a/server/src/internal/customers/handlers/handleGetCustomerV2.ts b/server/src/internal/customers/handlers/handleGetCustomerV2.ts index 6d61adc92..7565afe33 100644 --- a/server/src/internal/customers/handlers/handleGetCustomerV2.ts +++ b/server/src/internal/customers/handlers/handleGetCustomerV2.ts @@ -19,7 +19,7 @@ export const handleGetCustomerV2 = createRoute({ const ctx = c.get("ctx"); const customerId = c.req.param("customer_id"); const { expand } = ctx; - const { skip_cache = false, with_autumn_id } = c.req.valid("query"); + const { with_autumn_id } = c.req.valid("query"); // SIDE EFFECT if ( diff --git a/server/src/internal/customers/handlers/handlePostCustomerV2.ts b/server/src/internal/customers/handlers/handlePostCustomerV2.ts index 29897249f..992ed748b 100644 --- a/server/src/internal/customers/handlers/handlePostCustomerV2.ts +++ b/server/src/internal/customers/handlers/handlePostCustomerV2.ts @@ -42,7 +42,6 @@ export const handlePostCustomer = createRoute({ const apiCustomer = await getApiCustomer({ ctx, customerId: createCusParams.id || "", - skipCache: false, withAutumnId: with_autumn_id, baseData: { apiCustomer: baseData.apiCustomer, diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/deleteCachedApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/deleteCachedApiEntity.ts index a84bfc774..d63273155 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/deleteCachedApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/deleteCachedApiEntity.ts @@ -3,7 +3,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { buildCachedApiEntityKey } from "./getCachedApiEntity.js"; /** - * Delete ApiEntity from Redis cache + * Delete entity from Redis cache */ export const deleteCachedApiEntity = async ({ ctx, diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts index 5266eabad..9b8743185 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts @@ -1,9 +1,11 @@ import { - type ApiEntity, - ApiEntitySchema, + type ApiEntityV1, + ApiEntityV1Schema, type AppEnv, + type EntityLegacyData, type FullCustomer, filterEntityLevelCusProducts, + filterPlanAndFeatureExpand, } from "@autumn/shared"; import { CACHE_CUSTOMER_VERSION } from "@lua/cacheConfig.js"; import { GET_ENTITY_SCRIPT } from "@lua/luaScripts.js"; @@ -41,97 +43,114 @@ export const getCachedApiEntity = async ({ ctx, customerId, entityId, - skipCache = false, skipCustomerMerge = false, fullCus, }: { ctx: AutumnContext; customerId: string; entityId: string; - 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; +}): Promise<{ apiEntity: ApiEntityV1; legacyData: EntityLegacyData }> => { + const { org, env, db, skipCache } = ctx; - // Try to get from cache using Lua script (unless skipCache is true) - if (!skipCache) { - const cachedResult = await tryRedisRead(() => - redis.eval( - GET_ENTITY_SCRIPT, - 0, // No KEYS, all params in ARGV - org.id, // ARGV[1] - env, // ARGV[2] - customerId, // ARGV[3] - entityId, // ARGV[4] - skipCustomerMerge ? "true" : "false", // ARGV[5] - ), - ); - - // If found in cache, parse and return - if (cachedResult) { - const cached = normalizeCachedData( - JSON.parse(cachedResult as string) as ApiEntity, + const getExpandedApiEntity = async () => { + // Try to get from cache using Lua script (unless skipCache is true) + if (!skipCache) { + const cachedResult = await tryRedisRead(() => + redis.eval( + GET_ENTITY_SCRIPT, + 0, // No KEYS, all params in ARGV + org.id, // ARGV[1] + env, // ARGV[2] + customerId, // ARGV[3] + entityId, // ARGV[4] + skipCustomerMerge ? "true" : "false", // ARGV[5] + ), ); - return { - apiEntity: ApiEntitySchema.parse(cached), - }; + // If found in cache, parse and return + if (cachedResult) { + const cached = normalizeCachedData( + JSON.parse(cachedResult as string) as ApiEntityV1 & { + legacyData: EntityLegacyData; + }, + ); + + const { legacyData, ...rest } = cached; + + return { + apiEntity: ApiEntityV1Schema.parse(rest), + legacyData, + }; + } } - } - // Cache miss or skipCache - fetch from DB - if (!fullCus) { - fullCus = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env: env as AppEnv, - inStatuses: RELEVANT_STATUSES, - withEntities: true, - withSubs: true, - entityId, - }); - } + // Cache miss or skipCache - fetch from DB + 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) { - throw new Error(`Entity ${entityId} not found`); - } + const entity = fullCus.entity; + if (!entity) { + throw new Error(`Entity ${entityId} not found`); + } - // Store in cache (only if not skipping cache) - if (!skipCache) { - // Set entity cache - await setCachedApiCustomer({ + // Store in cache (only if not skipping cache) + if (!skipCache) { + // Set entity cache + await setCachedApiCustomer({ + ctx, + fullCus, + customerId, + }); + } + + // Build ApiEntity with full products for return + const { apiEntity, legacyData } = await getApiEntityBase({ ctx, - fullCus, - customerId, + entity, + fullCus: fullCus, + withAutumnId: !skipCache, }); - } - // Build ApiEntity with full products for return - const { apiEntity } = await getApiEntityBase({ - ctx, - entity, - fullCus: fullCus, - withAutumnId: !skipCache, - }); + const { apiEntity: pureApiEntity } = await getApiEntityBase({ + ctx, + entity, + fullCus: { + ...fullCus, + customer_products: filterEntityLevelCusProducts({ + cusProducts: fullCus.customer_products, + }), + }, + withAutumnId: true, + }); - const { apiEntity: pureApiEntity } = await getApiEntityBase({ - ctx, - entity, - fullCus: { - ...fullCus, - customer_products: filterEntityLevelCusProducts({ - cusProducts: fullCus.customer_products, - }), - }, - withAutumnId: true, + return { + apiEntity: ApiEntityV1Schema.parse( + skipCustomerMerge ? pureApiEntity : apiEntity, + ), + legacyData, + }; + }; + + const { apiEntity, legacyData } = await getExpandedApiEntity(); + const filteredApiEntity = filterPlanAndFeatureExpand({ + expand: ctx.expand, + target: apiEntity, }); return { - apiEntity: ApiEntitySchema.parse( - skipCustomerMerge ? pureApiEntity : apiEntity, - ), + apiEntity: filteredApiEntity, + legacyData, }; }; diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/refreshCachedApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/refreshCachedApiEntity.ts deleted file mode 100644 index 742f4eaa1..000000000 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/refreshCachedApiEntity.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 { buildCachedApiEntityKey } from "./getCachedApiEntity.js"; - -/** - * Refresh ApiEntity in Redis cache by fetching fresh data from DB - */ -export const refreshCachedApiEntity = async ({ - ctx, - customerId, - entityId, -}: { - ctx: AutumnContext; - customerId: string; - entityId: string; -}): Promise<{ apiEntity: ApiEntity }> => { - const { org, env, db } = ctx; - - const cacheKey = buildCachedApiEntityKey({ - entityId, - customerId, - orgId: org.id, - env, - }); - - // Fetch fresh entity 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, - }); - - const entity = fullCus.entity; - if (!entity) { - throw new Error(`Entity ${entityId} not found`); - } - - // Build fresh ApiEntity - const { apiEntity } = await getApiEntityBase({ - ctx, - entity, - fullCus, - withAutumnId: false, - }); - - // Update cache with fresh data using Lua script - await redis.eval( - SET_ENTITY_SCRIPT, - 1, // number of keys - cacheKey, // KEYS[1] - JSON.stringify(apiEntity), // ARGV[1] - ); - - return { - apiEntity, - }; -}; diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts index d25b346ee..25f8b7f13 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts @@ -1,4 +1,10 @@ -import type { ApiEntity, EntityExpand, FullCustomer } from "@autumn/shared"; +import { + AffectedResource, + type ApiEntityV1, + applyResponseVersionChanges, + type EntityLegacyData, + type FullCustomer, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getCachedApiEntity } from "../apiEntityCacheUtils/getCachedApiEntity.js"; import { getApiEntityExpand } from "./getApiEntityExpand.js"; @@ -8,32 +14,28 @@ import { getApiEntityExpand } from "./getApiEntityExpand.js"; */ export const getApiEntity = async ({ ctx, - expand, customerId, entityId, fullCus, withAutumnId = false, - skipCache = false, }: { ctx: AutumnContext; - expand: EntityExpand[]; customerId: string; entityId: string; fullCus?: FullCustomer; withAutumnId?: boolean; - skipCache?: boolean; -}): Promise => { +}): Promise => { // Get base entity (cacheable or direct from DB) - let { apiEntity: baseEntity } = await getCachedApiEntity({ - ctx, - customerId, - entityId, - skipCache, - fullCus, - }); + const { apiEntity: baseEntity, legacyData: entityLegacyData } = + await getCachedApiEntity({ + ctx, + customerId, + entityId, + fullCus, + }); // Clean api entity - baseEntity = { + const cleanedEntity = { ...baseEntity, autumn_id: withAutumnId ? baseEntity.autumn_id : undefined, }; @@ -43,23 +45,19 @@ export const getApiEntity = async ({ ctx, customerId, entityId, - expand, fullCus, }); // Merge expand fields const apiEntity = { - ...baseEntity, + ...cleanedEntity, ...apiEntityExpand, }; - // When entities have version changes, add this: - // return applyResponseVersionChanges({ - // input: apiEntity, - // legacyData: entityLegacyData, - // targetVersion: ctx.apiVersion, - // resource: AffectedResource.Entity, - // }); - - return apiEntity; + return applyResponseVersionChanges({ + input: apiEntity, + legacyData: entityLegacyData, + targetVersion: ctx.apiVersion, + resource: AffectedResource.Entity, + }); }; diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts index 569063d81..94f464590 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.ts @@ -1,7 +1,8 @@ import { - type ApiEntity, - ApiEntitySchema, + type ApiEntityV1, + ApiEntityV1Schema, type Entity, + type EntityLegacyData, type FullCustomer, filterCusProductsByEntity, } from "@autumn/shared"; @@ -24,7 +25,7 @@ export const getApiEntityBase = async ({ entity: Entity; fullCus: FullCustomer; withAutumnId?: boolean; -}): Promise<{ apiEntity: ApiEntity; legacyData: undefined }> => { +}): Promise<{ apiEntity: ApiEntityV1; legacyData: EntityLegacyData }> => { const { org } = ctx; // Filter customer products for this entity @@ -42,17 +43,19 @@ export const getApiEntityBase = async ({ }; // Reuse existing customer functions with filtered products - const { data: apiBalances } = await getApiBalances({ - ctx, - fullCus: filteredFullCus, - }); + const { data: apiBalances, legacyData: cusFeatureLegacyData } = + await getApiBalances({ + ctx, + fullCus: filteredFullCus, + }); - const { data: apiSubscriptions } = await getApiSubscriptions({ - ctx, - fullCus: filteredFullCus, - }); + const { data: apiSubscriptions, legacyData: cusProductLegacyData } = + await getApiSubscriptions({ + ctx, + fullCus: filteredFullCus, + }); - const apiEntity = ApiEntitySchema.extend({ + const apiEntity = ApiEntityV1Schema.extend({ autumn_id: z.string().optional(), }).parse({ autumn_id: withAutumnId ? entity.internal_id : undefined, @@ -60,16 +63,18 @@ export const getApiEntityBase = async ({ id: entity.id || null, name: entity.name || null, customer_id: fullCus.id || fullCus.internal_id, - // feature_id: entity.feature_id || null, created_at: entity.created_at, env: fullCus.env, - plans: apiSubscriptions, - features: apiBalances, + subscriptions: apiSubscriptions, + balances: apiBalances, }); return { apiEntity, - legacyData: undefined, + legacyData: { + cusProductLegacyData, + cusFeatureLegacyData, + }, }; }; diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts index 5ec1a38ba..536783e66 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts @@ -1,7 +1,8 @@ -import type { EntityExpand, FullCustomer } from "@autumn/shared"; +import { CusExpand, type FullCustomer } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js"; +import { InvoiceService } from "../../../invoices/InvoiceService"; +import { invoicesToResponse } from "../../../invoices/invoiceUtils"; export type ApiEntityExpand = { invoices?: any[]; @@ -12,17 +13,17 @@ export const getApiEntityExpand = async ({ customerId, entityId, fullCus, - expand, }: { ctx: AutumnContext; customerId?: string; entityId?: string; fullCus?: FullCustomer; - expand: EntityExpand[]; }): Promise => { const { org, env, db, logger } = ctx; - if (expand.length === 0) return {}; + if (!ctx.expand.includes(CusExpand.Invoices)) { + return {}; + } if (!fullCus) { fullCus = await CusService.getFull({ @@ -30,19 +31,20 @@ export const getApiEntityExpand = async ({ idOrInternalId: customerId || "", orgId: org.id, env, - expand: expand as any, // EntityExpand is compatible with CusExpand for 'invoices' entityId, }); } - const invoices = expand.includes("invoices" as EntityExpand) - ? invoicesToResponse({ - invoices: fullCus.invoices || [], - logger, - }) - : undefined; + const invoices = await InvoiceService.list({ + db, + internalCustomerId: fullCus.internal_id, + internalEntityId: fullCus.entity?.internal_id, + }); return { - invoices, + invoices: invoicesToResponse({ + invoices, + logger, + }), }; }; diff --git a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts index f86cae5b7..e4e73731c 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts @@ -98,12 +98,10 @@ export const createEntities = async ({ clonedFullCus.entity = entity; const apiEntity = await getApiEntity({ ctx, - expand: [], customerId, entityId: entity.id, fullCus: clonedFullCus, withAutumnId, - skipCache: true, }); apiEntities.push(apiEntity); } @@ -116,9 +114,12 @@ export const handleCreateEntity = createRoute({ body: CreateEntityParamsSchema.or(z.array(CreateEntityParamsSchema)), handler: async (c) => { const ctx = c.get("ctx"); - const { customer_id } = c.req.param(); - const body = c.req.valid("json"); + + // Skip cache for entity creation + ctx.skipCache = true; + + const { customer_id } = c.req.param(); const { with_autumn_id } = c.req.valid("query"); let customerData: CustomerData | undefined; diff --git a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts index 575a42e12..165154cd3 100644 --- a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts +++ b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts @@ -21,15 +21,6 @@ export const handleDeleteEntity = createRoute({ const { customer_id, entity_id } = c.req.param(); const ctx = c.get("ctx"); - // await handleCustomerRaceCondition({ - // action: "entity", - // customerId: customer_id, - // orgId: org.id, - // env, - // res, - // logger, - // }); - const { db, org, env, features, logger } = ctx; const fullCus = await CusService.getFull({ diff --git a/server/src/internal/entities/handlers/handleGetEntity.ts b/server/src/internal/entities/handlers/handleGetEntity.ts index 719ef9f90..c9279b772 100644 --- a/server/src/internal/entities/handlers/handleGetEntity.ts +++ b/server/src/internal/entities/handlers/handleGetEntity.ts @@ -7,14 +7,12 @@ export const handleGetEntity = createRoute({ handler: async (c) => { const { customer_id, entity_id } = c.req.param(); const ctx = c.get("ctx"); - const { expand, skip_cache, with_autumn_id } = c.req.valid("query"); + const { with_autumn_id } = c.req.valid("query"); const apiEntity = await getApiEntity({ ctx, customerId: customer_id, entityId: entity_id, - expand, - skipCache: skip_cache, withAutumnId: with_autumn_id, }); diff --git a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts index 786db396f..f3479895d 100644 --- a/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts +++ b/server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts @@ -330,13 +330,6 @@ export const handleNewProductItems = async ({ }); } - console.log(` - Update Confirmations: - - New ${newEnts.length} entitlements: ${JSON.stringify(newEnts, null, 4)} - - Updated ${updatedEnts.length} entitlements: ${JSON.stringify(updatedEnts, null, 4)} - - Deleted ${deletedEnts.length} entitlements: ${JSON.stringify(deletedEnts, null, 4)} - `); - return { prices: [...newPrices, ...updatedPrices], entitlements: [...newEnts, ...updatedEnts].map((ent) => ({ diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts index 7c7b87bb8..dd4b2c236 100644 --- a/server/src/utils/cacheUtils/cacheUtils.ts +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -1,4 +1,4 @@ -import type { ApiCustomer, ApiEntity } from "@autumn/shared"; +import type { ApiCustomer, ApiEntityV1 } from "@autumn/shared"; import { redis } from "@/external/redis/initRedis.js"; import { logger } from "../../external/logtail/logtailUtils.js"; @@ -98,7 +98,7 @@ export const normalizeCachedBalance = (balance: any): any => { * - Converts empty objects {} back to [] for all array fields * - Converts usage_limit: 0 to undefined (when all sources were undefined) */ -export const normalizeCachedData = ( +export const normalizeCachedData = ( data: T, ): T => { // Normalize top-level products array @@ -133,6 +133,20 @@ export const normalizeCachedData = ( // feature.reset = null; // } + if ( + !Array.isArray(feature.breakdown) && + typeof feature.breakdown === "object" + ) { + feature.breakdown = undefined; + } + + if ( + !Array.isArray(feature.rollovers) && + typeof feature.rollovers === "object" + ) { + feature.rollovers = undefined; + } + if (feature.breakdown) { for (const breakdown of feature.breakdown) { // if (!breakdown.reset) { diff --git a/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts b/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts index 3d00ddbc5..6c18e89b9 100644 --- a/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts +++ b/server/src/utils/scriptUtils/testUtils/createSharedProduct.ts @@ -4,10 +4,9 @@ import { customers, type ProductV2, } from "@autumn/shared"; +import { createProducts } from "@tests/utils/productUtils.js"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import { and, eq, inArray } from "drizzle-orm"; - -import { createProducts } from "tests/utils/productUtils.js"; -import type { TestContext } from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; export const createSharedProducts = async ({ diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts index 2aa8f4ae3..bb2dd6399 100644 --- a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -1,6 +1,6 @@ import { ApiVersion } from "@autumn/shared"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import type { CustomerData } from "autumn-js"; -import type { TestContext } from "tests/utils/testInitUtils/createTestContext.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { CusService } from "@/internal/customers/CusService.js"; import { attachPaymentMethod } from "../initCustomer.js"; diff --git a/server/tests/attach/basic/basic2.test.ts b/server/tests/attach/basic/basic2.test.ts index 508a27a25..717e317b8 100644 --- a/server/tests/attach/basic/basic2.test.ts +++ b/server/tests/attach/basic/basic2.test.ts @@ -1,11 +1,11 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, ProductItemInterval } from "@autumn/shared"; import type { ApiCustomerV1 } from "@shared/api/customers/previousVersions/apiCustomerV1.js"; -import chalk from "chalk"; import { AutumnCli } from "@tests/cli/AutumnCli.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem, diff --git a/server/tests/attach/basic/basic3.test.ts b/server/tests/attach/basic/basic3.test.ts index 3b593dd10..1bf28329c 100644 --- a/server/tests/attach/basic/basic3.test.ts +++ b/server/tests/attach/basic/basic3.test.ts @@ -1,15 +1,19 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, CusProductStatus } from "@autumn/shared"; -import chalk from "chalk"; -import type Stripe from "stripe"; import { AutumnCli } from "@tests/cli/AutumnCli.js"; import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import type Stripe from "stripe"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { timeout } from "@/utils/genUtils.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { sharedDefaultFree, sharedProProduct, initBasicSharedProducts } from "./sharedProducts.js"; +import { + initBasicSharedProducts, + sharedDefaultFree, + sharedProProduct, +} from "./sharedProducts.js"; const testCase = "basic3"; const customerId = testCase; diff --git a/server/tests/attach/basic/basic7.test.ts b/server/tests/attach/basic/basic7.test.ts index cba077c89..a3a3304e3 100644 --- a/server/tests/attach/basic/basic7.test.ts +++ b/server/tests/attach/basic/basic7.test.ts @@ -6,12 +6,12 @@ import { FreeTrialDuration, ProductItemInterval, } from "@autumn/shared"; -import chalk from "chalk"; import { AutumnCli } from "@tests/cli/AutumnCli.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { convertProductV2ToV1 } from "@/internal/products/productUtils/productV2Utils/convertProductV2ToV1.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; diff --git a/server/tests/attach/basic/basic8.test.ts b/server/tests/attach/basic/basic8.test.ts index 55a7b09db..2a7bd5f1c 100644 --- a/server/tests/attach/basic/basic8.test.ts +++ b/server/tests/attach/basic/basic8.test.ts @@ -5,11 +5,11 @@ import { FreeTrialDuration, ProductItemInterval, } from "@autumn/shared"; -import chalk from "chalk"; import { AutumnCli } from "@tests/cli/AutumnCli.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/check/basic/check2.test.ts b/server/tests/balances/check/basic/check2.test.ts index 34a2ccf9c..2668baa05 100644 --- a/server/tests/balances/check/basic/check2.test.ts +++ b/server/tests/balances/check/basic/check2.test.ts @@ -77,6 +77,7 @@ describe(`${chalk.yellowBright("check2: test /check on boolean feature")}`, () = usage: 0, max_purchase: null, overage_allowed: false, + reset: null, }, }); }); diff --git a/server/tests/balances/check/basic/check4.test.ts b/server/tests/balances/check/basic/check4.test.ts index 67c4589e1..1f540d79b 100644 --- a/server/tests/balances/check/basic/check4.test.ts +++ b/server/tests/balances/check/basic/check4.test.ts @@ -6,9 +6,9 @@ import { type CheckResponseV2, SuccessCode, } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -70,8 +70,9 @@ describe(`${chalk.yellowBright("check4: test /check on unlimited feature")}`, () purchased_balance: 0, current_balance: 0, usage: 0, - max_purchase: 0, overage_allowed: false, + max_purchase: null, + reset: null, }, }); }); diff --git a/server/tests/balances/check/credit-systems/credit-systems4.test.ts b/server/tests/balances/check/credit-systems/credit-systems4.test.ts index 9fee702ab..e81a9ddbe 100644 --- a/server/tests/balances/check/credit-systems/credit-systems4.test.ts +++ b/server/tests/balances/check/credit-systems/credit-systems4.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type CheckResponseV1, SuccessCode } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { featureToCreditSystem } from "@/internal/features/creditSystemUtils.js"; import { timeout } from "@/utils/genUtils.js"; diff --git a/server/tests/contUse/track/track5.test.ts b/server/tests/balances/set-usage/set-usage1.test.ts similarity index 91% rename from server/tests/contUse/track/track5.test.ts rename to server/tests/balances/set-usage/set-usage1.test.ts index ba5c70b5c..d05710f56 100644 --- a/server/tests/contUse/track/track5.test.ts +++ b/server/tests/balances/set-usage/set-usage1.test.ts @@ -1,17 +1,17 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { OnDecrease, OnIncrease, ProductItemFeatureType } from "@autumn/shared"; -import chalk from "chalk"; -import { addDays, addHours } from "date-fns"; -import { Decimal } from "decimal.js"; -import type Stripe from "stripe"; import { defaultApiVersion } from "@tests/constants.js"; -import { features } from "@tests/global.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { getSubsFromCusId } from "@tests/utils/expectUtils/expectSubUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js"; +import chalk from "chalk"; +import { addDays, addHours } from "date-fns"; +import { Decimal } from "decimal.js"; +import type Stripe from "stripe"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; @@ -21,7 +21,7 @@ import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js" import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; const seatsItem = constructArrearProratedItem({ - featureId: features.seats.id, + featureId: TestFeature.Users, featureType: ProductItemFeatureType.ContinuousUse, pricePerUnit: 20, includedUsage: 3, @@ -36,7 +36,7 @@ const seatsProduct = constructProduct({ items: [seatsItem], }); -const testCase = "track5"; +const testCase = "set-usage1"; const includedUsage = seatsItem.included_usage as number; const simulateOneCycle = async ({ @@ -76,7 +76,7 @@ const simulateOneCycle = async ({ }); const customer = await autumn.customers.get(customerId); - const prevBalance = customer.features[seatsItem.feature_id!].balance!; + const prevBalance = customer.features[TestFeature.Users].balance!; const prevUsage = includedUsage - prevBalance; const usageDiff = usageValue - prevUsage; @@ -86,13 +86,13 @@ const simulateOneCycle = async ({ await autumn.track({ customer_id: customerId, - feature_id: seatsItem.feature_id!, + feature_id: TestFeature.Users, value: value1, }); await autumn.track({ customer_id: customerId, - feature_id: seatsItem.feature_id!, + feature_id: TestFeature.Users, value: value2, }); @@ -115,7 +115,7 @@ const simulateOneCycle = async ({ } const customer = await autumn.customers.get(customerId); - const balance = customer.features[seatsItem.feature_id!].balance!; + const balance = customer.features[TestFeature.Users].balance!; const overage = Math.min(0, includedUsage - balance); const usagePrice = overage * seatsItem.price!; @@ -127,7 +127,7 @@ const simulateOneCycle = async ({ .toDecimalPlaces(2) .toNumber(); - const { start, end } = subToPeriodStartEnd({ sub }); + const { end } = subToPeriodStartEnd({ sub }); curUnix = await advanceTestClock({ stripeCli, testClockId, @@ -147,7 +147,7 @@ const simulateOneCycle = async ({ }; }; -describe(`${chalk.yellowBright("conUse/track5: Testing update cont use through /usage")}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing update cont use through /usage`)}`, () => { const customerId = testCase; let testClockId = ""; const autumn = new AutumnInt({ version: defaultApiVersion }); diff --git a/server/tests/contUse/track/track4.test.ts b/server/tests/balances/set-usage/set-usage2.test.ts similarity index 96% rename from server/tests/contUse/track/track4.test.ts rename to server/tests/balances/set-usage/set-usage2.test.ts index 7d3b8c60a..a9694f9a8 100644 --- a/server/tests/contUse/track/track4.test.ts +++ b/server/tests/balances/set-usage/set-usage2.test.ts @@ -1,7 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { @@ -10,6 +8,8 @@ import { } from "@tests/utils/expectUtils/expectContUseUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addWeeks } from "date-fns"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { timeout } from "@/utils/genUtils.js"; import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; @@ -32,9 +32,9 @@ export const pro = constructProduct({ type: "pro", }); -const testCase = "track4"; +const testCase = "set-usage2"; -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing set usage for cont use, prorate next cycle`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing set usage for cont use, prorate next cycle`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); let testClockId: string; diff --git a/server/tests/balances/testBalanceUtils.ts b/server/tests/balances/testBalanceUtils.ts index 6b6b3b04a..921e3375d 100644 --- a/server/tests/balances/testBalanceUtils.ts +++ b/server/tests/balances/testBalanceUtils.ts @@ -1,5 +1,7 @@ import { type ApiCustomer, ApiVersion } from "@autumn/shared"; import { AutumnInt } from "../../src/external/autumn/autumnCli.js"; +import { EventService } from "../../src/internal/api/events/EventService.js"; +import ctx from "../utils/testInitUtils/createTestContext.js"; export const getV2Balance = async ({ customerId, @@ -15,3 +17,25 @@ export const getV2Balance = async ({ return customer.balances[featureId]; }; + +export const getCustomerEvents = async ({ + customerId, +}: { + customerId: string; +}) => { + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + console.log("Fetching customer with autumn id"); + const customer = await autumnV2.customers.get(customerId, { + with_autumn_id: true, + }); + + const events = await EventService.getByCustomerId({ + db: ctx.db, + orgId: ctx.org.id, + internalCustomerId: customer.autumn_id ?? "", + env: ctx.env, + limit: 10000, + }); + + return events; +}; diff --git a/server/tests/balances/track/allocated/track-allocated1.test.ts b/server/tests/balances/track/allocated/track-allocated1.test.ts index 2323189ce..cfc3cee5f 100644 --- a/server/tests/balances/track/allocated/track-allocated1.test.ts +++ b/server/tests/balances/track/allocated/track-allocated1.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, ProductItemFeatureType } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -88,13 +88,13 @@ describe(`${chalk.yellowBright(`track-allocated1: Tracking allocated feature `)} // Check final balance const customer = await autumnV1.customers.get(customerId); const finalBalance = customer.features[TestFeature.Users].balance; - expect(finalBalance).toBe(-4); // Get non-cached customer const nonCachedCustomer = await autumnV1.customers.get(customerId, { skip_cache: "true", }); + const nonCachedFinalBalance = nonCachedCustomer.features[TestFeature.Users].balance; diff --git a/server/tests/balances/track/allocated/track-allocated5.test.ts b/server/tests/balances/track/allocated/track-allocated5.test.ts index 2271e976f..38502716d 100644 --- a/server/tests/balances/track/allocated/track-allocated5.test.ts +++ b/server/tests/balances/track/allocated/track-allocated5.test.ts @@ -1,13 +1,15 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; 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 { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { timeout } from "../../../utils/genUtils"; +import { getCustomerEvents } from "../../testBalanceUtils"; const userItem = constructFeatureItem({ featureId: TestFeature.Users, @@ -51,10 +53,13 @@ describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with con }); const promises = []; + let totalUsage = 0; + let numberOfTracks = 0; for (let i = 0; i < 2; i++) { console.log("--------------------------------"); console.log(`Cycle ${i}`); console.log(`Starting balance: ${startingBalance}`); + const values = []; for (let i = 0; i < 10; i++) { const randomVal = @@ -66,11 +71,15 @@ describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with con value: randomVal, }), ); + totalUsage += randomVal; startingBalance -= randomVal; values.push(randomVal); + + numberOfTracks++; } console.log(`New balance: ${startingBalance}`); + console.log(`Total usage: ${totalUsage}`); const results = await Promise.all(promises); const customer = await autumn.customers.get(customerId); @@ -81,6 +90,11 @@ describe(`${chalk.yellowBright(`${testCase}: Tracking allocated feature with con } } expect(userFeature.balance).toBe(startingBalance); + + // Check that there are X events in the database + await timeout(2000); + const events = await getCustomerEvents({ customerId }); + expect(events.length).toBe(numberOfTracks); } }); }); diff --git a/server/tests/balances/track/basic/track-basic11.test.ts b/server/tests/balances/track/basic/track-basic11.test.ts index 34ec53a30..b2da3672e 100644 --- a/server/tests/balances/track/basic/track-basic11.test.ts +++ b/server/tests/balances/track/basic/track-basic11.test.ts @@ -120,7 +120,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing negative values (refunds/cr test("should reflect large refund in non-cached customer after 2s", async () => { // Wait 2 seconds for DB sync - await timeout(5000); + await timeout(8000); // Fetch customer with skip_cache=true const customer = await autumnV1.customers.get(customerId, { diff --git a/server/tests/balances/track/concurrency/concurrent-track4.test.ts b/server/tests/balances/track/concurrency/concurrent-track4.test.ts index 1ad99ee32..bfa61eee1 100644 --- a/server/tests/balances/track/concurrency/concurrent-track4.test.ts +++ b/server/tests/balances/track/concurrency/concurrent-track4.test.ts @@ -1,15 +1,14 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.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"; -import { trackWasSuccessful } from "../trackTestUtils.js"; const testCase = "concurrentTrack4"; const customerId = testCase; @@ -100,16 +99,10 @@ describe(`${chalk.yellowBright(`${testCase}: Testing usage_limits with pay_per_u }), ]; - const results = await Promise.all(promises); - // console.log(results); + const results = await Promise.allSettled(promises); - const successCount = results.filter((r) => - trackWasSuccessful({ res: r }), - ).length; - - const rejectedCount = results.filter( - (r) => !trackWasSuccessful({ res: r }), - ).length; + const successCount = results.filter((r) => r.status === "fulfilled").length; + const rejectedCount = results.filter((r) => r.status === "rejected").length; expect(successCount).toBe(3); expect(rejectedCount).toBe(2); diff --git a/server/tests/balances/track/concurrency/concurrent-track5.test.ts b/server/tests/balances/track/concurrency/concurrent-track5.test.ts index 509037625..0f1a127d8 100644 --- a/server/tests/balances/track/concurrency/concurrent-track5.test.ts +++ b/server/tests/balances/track/concurrency/concurrent-track5.test.ts @@ -12,7 +12,6 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { timeout } from "../../../utils/genUtils.js"; -import { trackWasSuccessful } from "../trackTestUtils.js"; const testCase = "concurrentTrack5"; const customerId = testCase; @@ -152,16 +151,12 @@ describe(`${chalk.yellowBright(`${testCase}: Testing per-entity track with concu }), ]; - const results = await Promise.all(promises); + const results = await Promise.allSettled(promises); - const successCount = results.filter((r) => - trackWasSuccessful({ res: r }), - ).length; - const rejectedCount = results.filter( - (r) => !trackWasSuccessful({ res: r }), - ).length; + const successCount = results.filter((r) => r.status === "fulfilled").length; + const rejectedCount = results.filter((r) => r.status === "rejected").length; console.log( - `\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`, + `\n📈 Summary: ${successCount} successful tracks, ${rejectedCount} rejected tracks`, ); expect(successCount).toBe(3); diff --git a/server/tests/balances/track/credit-systems/track-credit-system4.test.ts b/server/tests/balances/track/credit-systems/track-credit-system4.test.ts index d71a6fcc3..f121bffb7 100644 --- a/server/tests/balances/track/credit-systems/track-credit-system4.test.ts +++ b/server/tests/balances/track/credit-systems/track-credit-system4.test.ts @@ -1,10 +1,10 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; diff --git a/server/tests/balances/track/entity-balances/track-entity-balances1.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances1.test.ts index f9a540125..3a756c7d7 100644 --- a/server/tests/balances/track/entity-balances/track-entity-balances1.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances1.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/track/entity-balances/track-entity-balances2.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances2.test.ts index 947ed1abc..70909f545 100644 --- a/server/tests/balances/track/entity-balances/track-entity-balances2.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances2.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/track/entity-balances/track-entity-balances3.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances3.test.ts index b7069255a..255ae2b46 100644 --- a/server/tests/balances/track/entity-balances/track-entity-balances3.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances3.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -75,7 +75,7 @@ describe(`${chalk.yellowBright("track-entity-balances3: per-entity balance track test("customer should have initial balance of 300 messages (100 per entity)", async () => { const customer = await autumnV1.customers.get(customerId); - const balance = customer.features[TestFeature.Messages].balance; + const balance = customer.features[TestFeature.Messages]?.balance; // 3 entities × 100 messages each = 300 total expect(balance).toBe(300); diff --git a/server/tests/balances/track/entity-balances/track-entity-balances4.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances4.test.ts index 91e903fe5..61fc509d5 100644 --- a/server/tests/balances/track/entity-balances/track-entity-balances4.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances4.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/track/entity-balances/track-entity-balances5.test.ts b/server/tests/balances/track/entity-balances/track-entity-balances5.test.ts index b1419b04a..372769a9a 100644 --- a/server/tests/balances/track/entity-balances/track-entity-balances5.test.ts +++ b/server/tests/balances/track/entity-balances/track-entity-balances5.test.ts @@ -1,10 +1,10 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; -import { Decimal } from "decimal.js"; import { TestFeature } from "@tests/setup/v2Features.js"; import { timeout } from "@tests/utils/genUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { Decimal } from "decimal.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; diff --git a/server/tests/balances/track/entity-products/track-entity-products2.test.ts b/server/tests/balances/track/entity-products/track-entity-products2.test.ts index ec78185bb..5125f9dcf 100644 --- a/server/tests/balances/track/entity-products/track-entity-products2.test.ts +++ b/server/tests/balances/track/entity-products/track-entity-products2.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { ApiVersion, type LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; @@ -203,7 +203,7 @@ describe(`${chalk.yellowBright("track-entity-products2: entity product tracking ); // Each entity should have some messages deducted - expect(entityFromDb.features[TestFeature.Messages]).toEqual( + expect(entityFromDb.features[TestFeature.Messages]).toMatchObject( entityFromCache.features[TestFeature.Messages], ); diff --git a/server/tests/balances/track/legacy/track-legacy2.test.ts b/server/tests/balances/track/legacy/track-legacy2.test.ts index 0b1269219..d5693f89f 100644 --- a/server/tests/balances/track/legacy/track-legacy2.test.ts +++ b/server/tests/balances/track/legacy/track-legacy2.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, test } from "bun:test"; import type { LimitedItem } from "@autumn/shared"; -import chalk from "chalk"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { constructArrearItem } from "../../../../src/utils/scriptUtils/constructItem.js"; import { constructProduct } from "../../../../src/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "../../../../src/utils/scriptUtils/testUtils/initCustomerV3.js"; diff --git a/server/tests/balances/track/legacy/track-legacy3.test.ts b/server/tests/balances/track/legacy/track-legacy3.test.ts index 70402768b..f6c398e2e 100644 --- a/server/tests/balances/track/legacy/track-legacy3.test.ts +++ b/server/tests/balances/track/legacy/track-legacy3.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, test } from "bun:test"; import { ProductItemInterval } from "@autumn/shared"; -import chalk from "chalk"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { constructFeatureItem, constructPrepaidItem, diff --git a/server/tests/balances/track/paid-allocated/track-paid-allocated1.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated1.test.ts new file mode 100644 index 000000000..478e4e579 --- /dev/null +++ b/server/tests/balances/track/paid-allocated/track-paid-allocated1.test.ts @@ -0,0 +1,130 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, OnDecrease, OnIncrease } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructArrearProratedItem } 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"; + +/** + * Test to verify replaceable balance logic works correctly: + * + * Scenario: + * - Product: 1 included usage, $50 per user + * - Use 2 seats (1 paid) + * - Track -1 to create replaceable + * - Verify balance values match expected unused logic + * + * Expected behavior: + * Before track -1: + * granted_balance: 1 + * current_balance: 0 + * purchased_balance: 1 + * usage: 2 + * + * After track -1 (creates 1 replaceable): + * granted_balance: 1 + * current_balance: 1 (includes unused) + * purchased_balance: 1 + * usage: 1 (reduced by unused) + */ +const userItem = constructArrearProratedItem({ + featureId: TestFeature.Users, + pricePerUnit: 50, + includedUsage: 1, + config: { + on_increase: OnIncrease.BillImmediately, + on_decrease: OnDecrease.None, + }, +}); + +export const pro = constructProduct({ + items: [userItem], + type: "pro", +}); + +const testCase = "track-paid-allocated1"; + +describe(`${chalk.yellowBright(`${testCase}: Replaceable model gives correct balance values`)}`, () => { + const customerId = testCase; + + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); + + beforeAll(async () => { + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + customerId, + }); + + await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + await autumnV2.attach({ + customer_id: customerId, + product_id: pro.id, + }); + }); + + test("should track 2 and have correct balance values", async () => { + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 2, + }); + + const balance = trackRes.balance; + + expect(balance).toBeDefined(); + expect(trackRes.balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 1, + current_balance: 0, + usage: 2, + }); + }); + + test("should track -1 and balance should reflect unused (replaceable)", async () => { + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: -1, + }); + + const balance = trackRes.balance; + + expect(balance).toBeDefined(); + expect(trackRes.balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 1, + current_balance: 1, + usage: 1, + }); + }); + + test("should track 1 and have correct balance values", async () => { + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 1, + }); + + const balance = trackRes.balance; + expect(balance).toBeDefined(); + expect(trackRes.balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 1, + current_balance: 0, + usage: 2, + }); + }); +}); diff --git a/server/tests/contUse/track/track1.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated2.test.ts similarity index 73% rename from server/tests/contUse/track/track1.test.ts rename to server/tests/balances/track/paid-allocated/track-paid-allocated2.test.ts index e2ccabdb6..a25aeb172 100644 --- a/server/tests/contUse/track/track1.test.ts +++ b/server/tests/balances/track/paid-allocated/track-paid-allocated2.test.ts @@ -1,6 +1,6 @@ -import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, OnDecrease, OnIncrease } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; @@ -12,6 +12,7 @@ import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.j import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { getV2Balance } from "../../testBalanceUtils"; const userItem = constructArrearProratedItem({ featureId: TestFeature.Users, @@ -28,11 +29,11 @@ const pro = constructProduct({ type: "pro", }); -const testCase = "track1"; +const testCase = "track-paid-allocated2"; -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for cont use`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing track usage for cont use`)}`, () => { const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + const autumn: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); let testClockId: string; beforeAll(async () => { @@ -52,27 +53,20 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con }); testClockId = testClockId1!; - }); - let usage = 0; - test("should attach pro", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli: ctx.stripeCli, - db: ctx.db, - org: ctx.org, - env: ctx.env, + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, }); }); + let usage = 0; test("should create track +3 usage and have correct invoice", async () => { await advanceTestClock({ stripeCli: ctx.stripeCli, testClockId, advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 5, + waitForSeconds: 30, }); await autumn.track({ @@ -99,6 +93,18 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con const invoices = customer.invoices; expect(invoices.length).toBe(2); expect(invoices[0].total).toBe(userItem.price! * 2); + + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 2, + current_balance: 0, + usage: 3, + }); }); test("should track -3 and have no new invoice", async () => { @@ -108,8 +114,6 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con value: -3, }); - await timeout(5000); - const customer = await autumn.customers.get(customerId); const invoices = customer.invoices; expect(invoices.length).toBe(2); @@ -125,6 +129,18 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con numReplaceables: 3, itemQuantity: usage - 3, }); + + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 2, + current_balance: 3, + usage: 0, + }); }); test("should track +3 and have no new invoice", async () => { @@ -134,8 +150,6 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con value: 3, }); - await timeout(5000); - const customer = await autumn.customers.get(customerId); const invoices = customer.invoices; expect(invoices.length).toBe(2); @@ -149,5 +163,17 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con customerId, usage, }); + + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 2, + current_balance: 0, + usage: 3, + }); }); }); diff --git a/server/tests/contUse/track/track2.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated4.test.ts similarity index 80% rename from server/tests/contUse/track/track2.test.ts rename to server/tests/balances/track/paid-allocated/track-paid-allocated4.test.ts index 4998ed128..8da41f47a 100644 --- a/server/tests/contUse/track/track2.test.ts +++ b/server/tests/balances/track/paid-allocated/track-paid-allocated4.test.ts @@ -1,19 +1,21 @@ +import { beforeAll, describe, expect, test } from "bun:test"; import { + ApiVersion, LegacyVersion, OnDecrease, OnIncrease, } from "@autumn/shared"; -import { beforeAll, describe, expect, test } from "bun:test"; -import chalk from "chalk"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructArrearProratedItem } 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"; +import { getV2Balance } from "../../testBalanceUtils"; const userItem = constructArrearProratedItem({ featureId: TestFeature.Users, @@ -30,13 +32,12 @@ export const pro = constructProduct({ type: "pro", }); -const testCase = "track2"; +const testCase = "track-paid-allocated4"; -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for cont use (without overage)`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing track usage for cont use (without overage)`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - const curUnix = new Date().getTime(); + const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); beforeAll(async () => { await initProductsV0({ @@ -46,15 +47,12 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con customerId, }); - const { testClockId: testClockId1 } = await initCustomerV3({ + await initCustomerV3({ ctx, customerId, - customerData: {}, attachPm: "success", withTestClock: true, }); - - testClockId = testClockId1!; }); let usage = 0; @@ -112,5 +110,18 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con customerId, usage, }); + + // Verify balance values reflect the replaceable (unused) logic + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 0, + current_balance: 1, + usage: 0, + }); }); }); diff --git a/server/tests/contUse/track/track3.test.ts b/server/tests/balances/track/paid-allocated/track-paid-allocated5.test.ts similarity index 79% rename from server/tests/contUse/track/track3.test.ts rename to server/tests/balances/track/paid-allocated/track-paid-allocated5.test.ts index b351e23b0..c479ac345 100644 --- a/server/tests/contUse/track/track3.test.ts +++ b/server/tests/balances/track/paid-allocated/track-paid-allocated5.test.ts @@ -1,7 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { LegacyVersion, OnDecrease, OnIncrease } from "@autumn/shared"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { @@ -10,12 +8,14 @@ import { } from "@tests/utils/expectUtils/expectContUseUtils.js"; import { advanceTestClock } from "@tests/utils/stripeUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addWeeks } from "date-fns"; 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 { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { getV2Balance } from "../../testBalanceUtils"; const userItem = constructArrearProratedItem({ featureId: TestFeature.Users, @@ -32,13 +32,13 @@ export const pro = constructProduct({ type: "pro", }); -const testCase = "track3"; +const testCase = "track-paid-allocated5"; -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for cont use, prorate next cycle`)}`, () => { +describe(`${chalk.yellowBright(`${testCase}: Testing track usage for cont use, prorate next cycle`)}`, () => { const customerId = testCase; const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); let testClockId: string; - let curUnix = new Date().getTime(); + let curUnix = Date.now(); beforeAll(async () => { await initProductsV0({ @@ -77,7 +77,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con stripeCli: ctx.stripeCli, testClockId, advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 5, + waitForSeconds: 30, }); await autumn.track({ @@ -86,11 +86,9 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con value: 3, }); - await timeout(15000); - usage += 3; - const { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + const { stripeSubs, fullCus } = await expectSubQuantityCorrect({ stripeCli: ctx.stripeCli, productId: pro.id, db: ctx.db, @@ -113,6 +111,18 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con const customer = await autumn.customers.get(customerId); const invoices = customer.invoices; expect(invoices.length).toBe(1); + + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 2, + current_balance: 0, + usage: 3, + }); }); test("should track -1 and have no new invoice", async () => { @@ -120,7 +130,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con stripeCli: ctx.stripeCli, testClockId, advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 5, + waitForSeconds: 30, }); await autumn.track({ @@ -131,7 +141,7 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con usage -= 1; - const { stripeSubs, cusProduct, fullCus } = await expectSubQuantityCorrect({ + const { stripeSubs, fullCus } = await expectSubQuantityCorrect({ stripeCli: ctx.stripeCli, productId: pro.id, db: ctx.db, @@ -154,10 +164,22 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con const customer = await autumn.customers.get(customerId); const invoices = customer.invoices; expect(invoices.length).toBe(1); + + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 1, + current_balance: 0, + usage: 2, + }); }); test("should track -1 and have no new invoice", async () => { - const quantity = 2; + const quantity = -1; await autumn.track({ customer_id: customerId, feature_id: TestFeature.Users, @@ -189,5 +211,17 @@ describe(`${chalk.yellowBright(`contUse/${testCase}: Testing track usage for con const customer = await autumn.customers.get(customerId); const invoices = customer.invoices; expect(invoices.length).toBe(1); + + const v2Balance = await getV2Balance({ + customerId, + featureId: TestFeature.Users, + }); + + expect(v2Balance).toMatchObject({ + granted_balance: 1, + purchased_balance: 0, + current_balance: 0, + usage: 1, + }); }); }); diff --git a/server/tests/balances/track/trackTestUtils.ts b/server/tests/balances/track/trackTestUtils.ts deleted file mode 100644 index 0755e0d53..000000000 --- a/server/tests/balances/track/trackTestUtils.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SuccessCode, type TrackResponseV1 } from "@autumn/shared"; - -export const trackWasSuccessful = ({ res }: { res: TrackResponseV1 }) => { - return res.code === SuccessCode.EventReceived; -}; diff --git a/server/tests/cli/AutumnCli.ts b/server/tests/cli/AutumnCli.ts index 923c11a50..6d303d038 100644 --- a/server/tests/cli/AutumnCli.ts +++ b/server/tests/cli/AutumnCli.ts @@ -3,7 +3,7 @@ import RecaseError from "@/utils/errorUtils.js"; import { getAxiosInstance } from "../utils/setup.js"; const handleAxiosError = (error: any) => { - if (error.response.data) { + if (error.response?.data) { throw new RecaseError({ message: error.response.data.message, code: error.response.data.code, diff --git a/server/tests/contUse/update/updateContUse1.backup.ts b/server/tests/contUse/update/updateContUse1.backup.ts deleted file mode 100644 index 71114599e..000000000 --- a/server/tests/contUse/update/updateContUse1.backup.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; -import { expect } from "chai"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import { addPrefixToProducts, replaceItems } from "@tests/attach/utils.js"; -import { setupBefore } from "@tests/before.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { createProducts } from "@tests/utils/productUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "updateContUse1"; - -describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing update contUse, add included usage`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - 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 { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "test2", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test3", - feature_id: TestFeature.Users, - }, - ]; - - it("should create entity, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += 3; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - const extraUsage = 2; - const newItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: (userItem.included_usage as number) + extraUsage, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, - }); - - return; - - it("should update product with extra included usage", async () => { - const customItems = replaceItems({ - featureId: TestFeature.Users, - items: pro.items, - newItem, - }); - - usage += extraUsage; - - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - is_custom: true, - items: customItems, - }); - - await expectSubQuantityCorrect({ - stripeCli, - productId: pro.id, - db, - org, - env, - customerId, - usage, - numReplaceables: extraUsage, - }); - - // Will have 1 invoice because price is replaced... - }); - - const entities = [ - { - id: "4", - name: "test4", - feature_id: TestFeature.Users, - }, - { - id: "5", - name: "test5", - feature_id: TestFeature.Users, - }, - ]; - - it("should create 2 entities and have no invoice", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 2).getTime(), - waitForSeconds: 10, - }); - - await autumn.entities.create(customerId, entities); - - // Usage won't change since using replaceables... - // usage += entities.length; - - await expectSubQuantityCorrect({ - stripeCli, - productId: pro.id, - db, - org, - env, - customerId, - usage, - numReplaceables: 0, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - expect(invoices.length).to.equal(2); - }); -}); diff --git a/server/tests/contUse/update/updateContUse2.backup.ts b/server/tests/contUse/update/updateContUse2.backup.ts deleted file mode 100644 index 67674e133..000000000 --- a/server/tests/contUse/update/updateContUse2.backup.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; -import { expect } from "chai"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import { addPrefixToProducts, replaceItems } from "@tests/attach/utils.js"; -import { setupBefore } from "@tests/before.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { createProducts } from "@tests/utils/productUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "updateContUse2"; - -describe(`${chalk.yellowBright(`contUse/update/${testCase}: Testing update cont use, remove included usage`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - 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 { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - let usage = 0; - const firstEntities = [ - { - id: "1", - name: "test", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "test2", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "test3", - feature_id: TestFeature.Users, - }, - ]; - - it("should create entity, then attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += 3; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - const reduceUsageBy = 1; - const newItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: (userItem.included_usage as number) - reduceUsageBy, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, - }); - - it("should update product with reduced included usage", async () => { - await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(new Date(), 1).getTime(), - waitForSeconds: 5, - }); - - const customItems = replaceItems({ - featureId: TestFeature.Users, - items: pro.items, - newItem, - }); - - const preview = await autumn.attachPreview({ - customer_id: customerId, - product_id: pro.id, - is_custom: true, - items: customItems, - }); - - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - is_custom: true, - items: customItems, - }); - - const customer = await autumn.customers.get(customerId); - const invoices = customer.invoices; - expect(invoices.length).to.equal(2); - expect(invoices[0].total).to.equal(preview.due_today.total); - - // Usage stays the same... - await expectSubQuantityCorrect({ - stripeCli, - productId: pro.id, - db, - org, - env, - customerId, - usage, - numReplaceables: 0, - }); - }); - return; -}); diff --git a/server/tests/contUse/update/updateContUse3.backup.ts b/server/tests/contUse/update/updateContUse3.backup.ts deleted file mode 100644 index a0f057c2d..000000000 --- a/server/tests/contUse/update/updateContUse3.backup.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import { addPrefixToProducts, replaceItems } from "@tests/attach/utils.js"; -import { setupBefore } from "@tests/before.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { attachNewContUseAndExpectCorrect } from "@tests/utils/expectUtils/expectContUse/expectUpdateContUse.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { createProducts } from "@tests/utils/productUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "updateContUse3"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse included usage when no entities created`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - 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 { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - it("should attach pro", async () => { - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - }); - }); - - const extraUsage = 2; - const newItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: (userItem.included_usage as number) + extraUsage, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, - }); - - it("should update product with extra included usage", async () => { - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: 1, - }); - - const customItems = replaceItems({ - featureId: TestFeature.Users, - items: pro.items, - newItem, - }); - - await attachNewContUseAndExpectCorrect({ - autumn, - customerId, - product: pro, - customItems, - numInvoices: 2, - }); - - await expectSubQuantityCorrect({ - stripeCli, - productId: pro.id, - db, - org, - env, - customerId, - usage: 1, - numReplaceables: 0, - }); - }); -}); diff --git a/server/tests/contUse/update/updateContUse4.backup.ts b/server/tests/contUse/update/updateContUse4.backup.ts deleted file mode 100644 index 244c7311e..000000000 --- a/server/tests/contUse/update/updateContUse4.backup.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; -import { expect } from "chai"; -import chalk from "chalk"; -import { addWeeks } from "date-fns"; -import type Stripe from "stripe"; -import { addPrefixToProducts, replaceItems } from "@tests/attach/utils.js"; -import { setupBefore } from "@tests/before.js"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { attachNewContUseAndExpectCorrect } from "@tests/utils/expectUtils/expectContUse/expectUpdateContUse.js"; -import { expectSubQuantityCorrect } from "@tests/utils/expectUtils/expectContUseUtils.js"; -import { createProducts } from "@tests/utils/productUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; -import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); - -const testCase = "updateContUse4"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse included usage, prorate now`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - 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 { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - const firstEntities = [ - { - id: "1", - name: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "entity2", - feature_id: TestFeature.Users, - }, - ]; - - let usage = 0; - it("should attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += firstEntities.length; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - const extraUsage = 2; - const newItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: (userItem.included_usage as number) + extraUsage, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, - }); - - it("should update product with extra included usage", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 2).getTime(), - waitForSeconds: 15, - }); - - const customItems = replaceItems({ - featureId: TestFeature.Users, - items: pro.items, - newItem, - }); - - const { invoices } = await attachNewContUseAndExpectCorrect({ - autumn, - customerId, - product: pro, - customItems, - numInvoices: 2, - }); - - const { stripeSubs } = await expectSubQuantityCorrect({ - stripeCli, - productId: pro.id, - db, - org, - env, - customerId, - usage, - numReplaceables: 0, - }); - - // Do own calculation too.. - const sub = stripeSubs[0]; - const amount = -userItem.price!; - const { start, end } = subToPeriodStartEnd({ sub }); - let proratedAmount = calculateProrationAmount({ - amount, - periodStart: start * 1000, - periodEnd: end * 1000, - now: curUnix, - allowNegative: true, - }); - proratedAmount = Number(proratedAmount.toFixed(2)); - - expect(invoices[0].total).to.equal( - proratedAmount, - "invoice is equal to calculated prorated amount", - ); - }); - - const reducedUsage = 3; - const newItem2 = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: (newItem.included_usage as number) - reducedUsage, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, - }); - - it("should update product with reduced included usage", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 1).getTime(), - waitForSeconds: 15, - }); - - const customItems = replaceItems({ - featureId: TestFeature.Users, - items: pro.items, - newItem: newItem2, - }); - - const { invoices } = await attachNewContUseAndExpectCorrect({ - autumn, - customerId, - product: pro, - customItems, - numInvoices: 3, - }); - - const { stripeSubs } = await expectSubQuantityCorrect({ - stripeCli, - productId: pro.id, - db, - org, - env, - customerId, - usage, - numReplaceables: 0, - }); - - // Do own calculation too.. - const sub = stripeSubs[0]; - const amount = Math.min(reducedUsage, usage) * userItem.price!; - const { start, end } = subToPeriodStartEnd({ sub }); - let proratedAmount = calculateProrationAmount({ - amount, - periodStart: start * 1000, - periodEnd: end * 1000, - now: curUnix, - allowNegative: true, - }); - proratedAmount = Number(proratedAmount.toFixed(2)); - - expect(invoices[0].total).to.equal( - proratedAmount, - "invoice is equal to calculated prorated amount", - ); - }); -}); diff --git a/server/tests/contUse/update/updateContUse5.backup.ts b/server/tests/contUse/update/updateContUse5.backup.ts deleted file mode 100644 index e2f00710e..000000000 --- a/server/tests/contUse/update/updateContUse5.backup.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; -import chalk from "chalk"; -import { addWeeks } 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 { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; -import { createProducts } from "@tests/utils/productUtils.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; - -const userItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 1, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, -}); - -export const pro = constructProduct({ - items: [userItem], - type: "pro", -}); -export const proAnnual = constructProduct({ - items: [ - constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 2, - config: { - on_increase: OnIncrease.BillImmediately, - on_decrease: OnDecrease.None, - }, - }), - ], - type: "pro", - isAnnual: true, -}); - -const testCase = "updateContUse5"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: Testing update contUse included usage, prorate now`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - 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, proAnnual], - prefix: testCase, - }); - - await createProducts({ - autumn, - products: [pro, proAnnual], - customerId, - db, - orgId: org.id, - env, - }); - - const { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - const firstEntities = [ - { - id: "1", - name: "entity1", - feature_id: TestFeature.Users, - }, - { - id: "2", - name: "entity2", - feature_id: TestFeature.Users, - }, - { - id: "3", - name: "entity3", - feature_id: TestFeature.Users, - }, - ]; - - let usage = 0; - it("should attach pro", async () => { - await autumn.entities.create(customerId, firstEntities); - usage += firstEntities.length; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: pro, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); - - it("should upgrade to pro annual", async () => { - curUnix = await advanceTestClock({ - stripeCli, - testClockId, - advanceTo: addWeeks(curUnix, 2).getTime(), - waitForSeconds: 5, - }); - return; - - await attachAndExpectCorrect({ - autumn, - customerId, - product: proAnnual, - stripeCli, - db, - org, - env, - usage: [ - { - featureId: TestFeature.Users, - value: usage, - }, - ], - }); - }); -}); diff --git a/server/tests/contUse/update/updateContUse6.ts b/server/tests/contUse/update/updateContUse6.ts deleted file mode 100644 index d85f3a69e..000000000 --- a/server/tests/contUse/update/updateContUse6.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - type AppEnv, - LegacyVersion, - OnDecrease, - OnIncrease, - type Organization, -} from "@autumn/shared"; -import { expect } from "chai"; -import chalk from "chalk"; -import type Stripe from "stripe"; -import { addPrefixToProducts, replaceItems } 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 { - constructArrearProratedItem, - constructFeatureItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomer } from "@/utils/scriptUtils/initCustomer.js"; -import { timeout } from "../../utils/genUtils.js"; - -const freeItem = constructFeatureItem({ - featureId: TestFeature.Users, - includedUsage: 3, -}); - -const paidUserItem = constructArrearProratedItem({ - featureId: TestFeature.Users, - pricePerUnit: 50, - includedUsage: 10, - config: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.ProrateImmediately, - }, -}); - -const pro = constructProduct({ - items: [freeItem], - type: "pro", -}); - -const testCase = "updateContUse6"; - -describe(`${chalk.yellowBright(`contUse/${testCase}: free product, continuous use, then upgrade to pro which has MORE included usage`)}`, () => { - const customerId = testCase; - const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); - let testClockId: string; - let db: DrizzleCli, org: Organization, env: AppEnv; - let stripeCli: Stripe; - const curUnix = Date.now(); - - 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 { testClockId: testClockId1 } = await initCustomer({ - autumn: autumnJs, - customerId, - db, - org, - env, - attachPm: "success", - }); - - testClockId = testClockId1!; - }); - - const usage = 3; - it("should attach free and track usage", async () => { - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - }); - - await autumn.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: usage, - }); - await timeout(2000); - }); - - const customItems = replaceItems({ - items: pro.items, - featureId: TestFeature.Users, - newItem: paidUserItem, - }); - - it("should replace user item with paid and increased allowance", async () => { - const customProduct = { - ...pro, - items: customItems, - }; - - await autumn.attach({ - customer_id: customerId, - product_id: pro.id, - is_custom: true, - items: customItems, - }); - - const customer = await autumn.customers.get(customerId); - expect(customer.invoices?.[0].total).to.equal(0); - }); - - // const extraUsage = 2; - // const newItem = constructArrearProratedItem({ - // featureId: TestFeature.Users, - // pricePerUnit: 50, - // includedUsage: (userItem.included_usage as number) + extraUsage, - // config: { - // on_increase: OnIncrease.ProrateImmediately, - // on_decrease: OnDecrease.ProrateImmediately, - // }, - // }); -}); diff --git a/server/tests/utils/expectUtils/expectContUseUtils.ts b/server/tests/utils/expectUtils/expectContUseUtils.ts index 7b86fa543..38a62b692 100644 --- a/server/tests/utils/expectUtils/expectContUseUtils.ts +++ b/server/tests/utils/expectUtils/expectContUseUtils.ts @@ -4,9 +4,9 @@ import { type FullCustomer, type Organization, } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; import { expect } from "chai"; import type Stripe from "stripe"; -import { TestFeature } from "@tests/setup/v2Features.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnInt } from "@/external/autumn/autumnCli.js"; import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js"; diff --git a/server/tests/utils/setupUtils/setupOrg.ts b/server/tests/utils/setupUtils/setupOrg.ts index 81e1db688..bdfc6407d 100644 --- a/server/tests/utils/setupUtils/setupOrg.ts +++ b/server/tests/utils/setupUtils/setupOrg.ts @@ -1,6 +1,6 @@ import type { AppEnv } from "@autumn/shared"; -import axios from "axios"; import { getFeatures } from "@tests/setup/v2Features.js"; +import axios from "axios"; import { initDrizzle } from "@/db/initDrizzle.js"; import { FeatureService } from "@/internal/features/FeatureService.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; @@ -8,7 +8,8 @@ import { OrgService } from "@/internal/orgs/OrgService.js"; export const getAxiosInstance = (apiKey?: string) => { // Priority: 1. Passed apiKey, 2. Org secret key from context, 3. TEST_ORG_SECRET_KEY fallback // Import ctx here to avoid circular dependency issues - const ctx = require("tests/utils/testInitUtils/createTestContext.js").default; + const ctx = + require("@tests/utils/testInitUtils/createTestContext.js").default; const secretKey = apiKey || ctx?.orgSecretKey || process.env.TEST_ORG_SECRET_KEY; diff --git a/shared/api/balances/track/trackTypes/pgDeductionUpdate.ts b/shared/api/balances/track/trackTypes/pgDeductionUpdate.ts new file mode 100644 index 000000000..36dd779e9 --- /dev/null +++ b/shared/api/balances/track/trackTypes/pgDeductionUpdate.ts @@ -0,0 +1,17 @@ +import type { EntityBalance } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { + InsertReplaceable, + Replaceable, +} from "@models/cusProductModels/cusEntModels/replaceableTable.js"; + +export interface PgDeductionUpdate { + balance: number; + additional_balance: number; + additional_granted_balance?: number; + entities: Record; + adjustment: number; + deducted: number; + additional_deducted?: number; + newReplaceables?: InsertReplaceable[]; + deletedReplaceables?: Replaceable[]; +} diff --git a/shared/api/customers/apiCustomer.ts b/shared/api/customers/apiCustomer.ts index 52ad7e6d0..fe7b639da 100644 --- a/shared/api/customers/apiCustomer.ts +++ b/shared/api/customers/apiCustomer.ts @@ -1,4 +1,4 @@ -import { ApiBaseEntitySchema } from "@api/entities/apiEntity.js"; +import { ApiBaseEntitySchema } from "@api/entities/apiBaseEntity.js"; import { ApiCusRewardsSchema } from "@api/others/apiDiscount.js"; import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; import { AppEnv } from "@models/genModels/genEnums.js"; diff --git a/shared/api/customers/changes/V1.2_CustomerChange.ts b/shared/api/customers/changes/V1.2_CustomerChange.ts index 429ac8e35..91e1473e5 100644 --- a/shared/api/customers/changes/V1.2_CustomerChange.ts +++ b/shared/api/customers/changes/V1.2_CustomerChange.ts @@ -79,6 +79,15 @@ export const V1_2_CustomerChange = defineVersionChange({ metadata: input.metadata, products: v3CusProducts, features: v3_features, + + // The others + invoices: input.invoices, + entities: input.entities, + trials_used: input.trials_used, + rewards: input.rewards, + upcoming_invoice: input.upcoming_invoice, + referrals: input.referrals, + payment_method: input.payment_method, } satisfies z.infer; }, }); diff --git a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts index 147be456d..2eef52477 100644 --- a/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts +++ b/shared/api/customers/cusFeatures/changes/V1.2_CusFeatureChange.ts @@ -149,7 +149,6 @@ export function transformBalanceToCusFeatureV3({ unlimited: isUnlimited, }); - console.log("Legacy data:", legacyData); const { includedUsage, balance, usage, overageAllowed, usageLimit } = toV3BalanceParams({ input, diff --git a/shared/api/customers/previousVersions/apiCustomerV2.ts b/shared/api/customers/previousVersions/apiCustomerV2.ts index d3ec6a044..e3e843295 100644 --- a/shared/api/customers/previousVersions/apiCustomerV2.ts +++ b/shared/api/customers/previousVersions/apiCustomerV2.ts @@ -3,7 +3,7 @@ import { ApiCusUpcomingInvoiceSchema } from "@api/customers/components/apiCusUpc import { ApiTrialsUsedSchema } from "@api/customers/components/apiTrialsUsed.js"; import { ApiCusFeatureV2Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV2.js"; import { ApiCusProductV2Schema } from "@api/customers/cusPlans/previousVersions/apiCusProductV2.js"; -import { ApiEntitySchema } from "@api/entities/apiEntity.js"; +import { ApiBaseEntitySchema } from "@api/entities/apiBaseEntity.js"; import { ApiCusRewardsSchema } from "@api/models.js"; import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; import { AppEnv } from "@models/genModels/genEnums.js"; @@ -48,7 +48,7 @@ export const ApiCustomerV2Schema = z.object({ rewards: ApiCusRewardsSchema.nullish(), metadata: z.record(z.any(), z.any()).default({}), - entities: z.array(ApiEntitySchema).optional(), + entities: z.array(ApiBaseEntitySchema).optional(), referrals: z.array(ApiCusReferralSchema).optional(), upcoming_invoice: ApiCusUpcomingInvoiceSchema.nullish(), payment_method: z.any().nullish(), diff --git a/shared/api/entities/apiBaseEntity.ts b/shared/api/entities/apiBaseEntity.ts new file mode 100644 index 000000000..581301d2f --- /dev/null +++ b/shared/api/entities/apiBaseEntity.ts @@ -0,0 +1,37 @@ +// Alias for backward compatibility +import { z } from "zod/v4"; +import { AppEnv } from "../../models/genModels/genEnums.js"; + +const entityDescriptions = { + id: "The unique identifier of the entity", + name: "The name of the entity", + customer_id: "The customer ID this entity belongs to", + feature_id: "The feature ID this entity belongs to", + created_at: "Unix timestamp when the entity was created", + env: "The environment (sandbox/live)", +}; + +export const ApiBaseEntitySchema = z.object({ + autumn_id: z.string().optional(), + + id: z.string().nullable().meta({ + description: entityDescriptions.id, + }), + name: z.string().nullable().meta({ + description: entityDescriptions.name, + }), + customer_id: z.string().nullish().meta({ + description: entityDescriptions.customer_id, + }), + feature_id: z.string().nullish().meta({ + description: entityDescriptions.feature_id, + }), + created_at: z.number().meta({ + description: entityDescriptions.created_at, + }), + env: z.enum(AppEnv).meta({ + description: entityDescriptions.env, + }), +}); + +export type ApiBaseEntity = z.infer; diff --git a/shared/api/entities/apiEntity.ts b/shared/api/entities/apiEntity.ts index 8e350e04d..6ef3d79a4 100644 --- a/shared/api/entities/apiEntity.ts +++ b/shared/api/entities/apiEntity.ts @@ -1,42 +1,13 @@ import { ApiBalanceSchema } from "@api/customers/cusFeatures/apiBalance.js"; import { ApiSubscriptionSchema } from "@api/customers/cusPlans/apiSubscription.js"; import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; -import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; +import { ApiBaseEntitySchema } from "./apiBaseEntity.js"; -const entityDescriptions = { - id: "The unique identifier of the entity", - name: "The name of the entity", - customer_id: "The customer ID this entity belongs to", - feature_id: "The feature ID this entity belongs to", - created_at: "Unix timestamp when the entity was created", - env: "The environment (sandbox/live)", -}; +// Re-export for backward compatibility +export { ApiBaseEntitySchema } from "./apiBaseEntity.js"; -export const ApiBaseEntitySchema = z.object({ - autumn_id: z.string().optional(), - - id: z.string().nullable().meta({ - description: entityDescriptions.id, - }), - name: z.string().nullable().meta({ - description: entityDescriptions.name, - }), - customer_id: z.string().nullish().meta({ - description: entityDescriptions.customer_id, - }), - feature_id: z.string().nullish().meta({ - description: entityDescriptions.feature_id, - }), - created_at: z.number().meta({ - description: entityDescriptions.created_at, - }), - env: z.enum(AppEnv).meta({ - description: entityDescriptions.env, - }), -}); - -export const ApiEntitySchema = ApiBaseEntitySchema.extend({ +export const ApiEntityV1Schema = ApiBaseEntitySchema.extend({ subscriptions: z.array(ApiSubscriptionSchema).optional().meta({ description: "Plans associated with this entity", example: [], @@ -50,4 +21,7 @@ export const ApiEntitySchema = ApiBaseEntitySchema.extend({ }), }); -export type ApiEntity = z.infer; +// Alias for backward compatibility +export const ApiEntitySchema = ApiEntityV1Schema; + +export type ApiEntityV1 = z.infer; diff --git a/shared/api/entities/changes/V1.2_EntityChange.ts b/shared/api/entities/changes/V1.2_EntityChange.ts new file mode 100644 index 000000000..326d6c6af --- /dev/null +++ b/shared/api/entities/changes/V1.2_EntityChange.ts @@ -0,0 +1,89 @@ +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import type { z } from "zod/v4"; +import { transformBalanceToCusFeatureV3 } from "../../customers/cusFeatures/changes/V1.2_CusFeatureChange.js"; +import type { ApiCusFeatureV3 } from "../../customers/cusFeatures/previousVersions/apiCusFeatureV3.js"; +import type { ApiSubscription } from "../../customers/cusPlans/apiSubscription.js"; +import { transformSubscriptionToCusProductV3 } from "../../customers/cusPlans/changes/V1.2_CusPlanChange.js"; +import type { ApiCusProductV3 } from "../../customers/cusPlans/previousVersions/apiCusProductV3.js"; +import { ApiEntityV1Schema } from "../apiEntity.js"; +import { EntityLegacyDataSchema } from "../entityLegacyData.js"; +import { ApiEntityV0Schema } from "../prevVersions/apiEntityV0.js"; + +/** + * V1_2_EntityChange: Transforms entity response TO V0 format (pre-V2.0) + * + * Applied when: targetVersion <= V1_2 + * + * Breaking changes introduced in V2.0: + * + * 1. Products renamed to Plans: + * - V1+: "subscriptions" field contains ApiSubscription objects + * - V0: "products" field contains ApiCusProductV3 objects + * + * 2. Simplified feature schema: + * - V1+: "balances" field with minimal fields + optional feature object + * - V0: "features" field with verbose fields and all metadata + * + * 3. Features remain as record (no change from V1.1): + * - Both V1 and V0: Record + * + * Input: ApiEntityV1 (V2.0+ format) + * Output: ApiEntityV0 (V1.2 format) + */ + +export const V1_2_EntityChange = defineVersionChange({ + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Products renamed to plans in SDK", + "Simplified feature and plan schemas", + "Added optional nested objects for expanded data", + ], + affectedResources: [AffectedResource.Entity], + newSchema: ApiEntityV1Schema, + oldSchema: ApiEntityV0Schema, + legacyDataSchema: EntityLegacyDataSchema, + + // Response: V1 → V0 + transformResponse: ({ input, legacyData }) => { + // Step 1: Transform plans V1 → V0 (products) + const v0CusProducts: ApiCusProductV3[] | undefined = input.subscriptions + ? input.subscriptions.map((subscription: ApiSubscription) => + transformSubscriptionToCusProductV3({ + input: subscription, + legacyData: legacyData?.cusProductLegacyData[subscription.plan_id], + }), + ) + : undefined; + + // Step 2: Transform features V1 → V0 + let v0_features: Record | undefined; + if (input.balances) { + v0_features = {}; + for (const [featureId, feature] of Object.entries(input.balances)) { + v0_features[featureId] = transformBalanceToCusFeatureV3({ + input: feature, + legacyData: legacyData?.cusFeatureLegacyData[featureId], + }); + } + } + + // Step 3: Return V0 entity format + return { + autumn_id: input.autumn_id, + id: input.id, + name: input.name, + customer_id: input.customer_id, + feature_id: input.feature_id, + created_at: input.created_at, + env: input.env, + products: v0CusProducts, + features: v0_features, + invoices: input.invoices, + } satisfies z.infer; + }, +}); diff --git a/shared/api/entities/entityLegacyData.ts b/shared/api/entities/entityLegacyData.ts new file mode 100644 index 000000000..7c8f1f82a --- /dev/null +++ b/shared/api/entities/entityLegacyData.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; +import { CusFeatureLegacyDataSchema } from "../customers/cusFeatures/cusFeatureLegacyData.js"; +import { CusProductLegacyDataSchema } from "../customers/cusPlans/cusProductLegacyData.js"; + +export const EntityLegacyDataSchema = z.object({ + cusProductLegacyData: z.record(z.string(), CusProductLegacyDataSchema), + cusFeatureLegacyData: z.record(z.string(), CusFeatureLegacyDataSchema), +}); + +export type EntityLegacyData = z.infer; diff --git a/shared/api/entities/entityOpModels.ts b/shared/api/entities/entityOpModels.ts index b52e0680f..87dd72aed 100644 --- a/shared/api/entities/entityOpModels.ts +++ b/shared/api/entities/entityOpModels.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { EntityExpand } from "../../models/cusModels/entityModels/entityExpand.js"; +import { CusExpand } from "../../models/cusModels/cusExpand.js"; import { queryStringArray } from "../apiUtils.js"; import { CustomerDataSchema } from "../common/customerData.js"; @@ -19,7 +19,13 @@ export const CreateEntityParamsSchema = z.object({ // Get Entity Query Params export const GetEntityQuerySchema = z.object({ - expand: queryStringArray(z.enum(EntityExpand)).default([]), + expand: queryStringArray( + z.enum([ + CusExpand.Invoices, + CusExpand.SubscriptionPlan, + CusExpand.BalanceFeature, + ]), + ).default([]), skip_cache: z.boolean().optional(), with_autumn_id: z.boolean().optional(), }); diff --git a/shared/api/entities/prevVersions/apiEntityV0.ts b/shared/api/entities/prevVersions/apiEntityV0.ts new file mode 100644 index 000000000..357536ed1 --- /dev/null +++ b/shared/api/entities/prevVersions/apiEntityV0.ts @@ -0,0 +1,22 @@ +import { ApiCusFeatureV3Schema } from "@api/customers/cusFeatures/previousVersions/apiCusFeatureV3.js"; +import { ApiCusProductV3Schema } from "@api/customers/cusPlans/previousVersions/apiCusProductV3.js"; +import { ApiInvoiceSchema } from "@api/others/apiInvoice.js"; +import { AppEnv } from "@models/genModels/genEnums.js"; +import { z } from "zod/v4"; + +export const ApiEntityV0Schema = z.object({ + autumn_id: z.string().optional(), + id: z.string().nullable(), + name: z.string().nullable(), + customer_id: z.string().nullish(), + feature_id: z.string().nullish(), + created_at: z.number(), + env: z.enum(AppEnv), + + // V1.2 format: products and features (not subscriptions and balances) + products: z.array(ApiCusProductV3Schema).optional(), + features: z.record(z.string(), ApiCusFeatureV3Schema).optional(), + invoices: z.array(ApiInvoiceSchema).optional(), +}); + +export type ApiEntityV0 = z.infer; diff --git a/shared/api/models.ts b/shared/api/models.ts index a1023e39e..91d1b758b 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -26,20 +26,18 @@ export * from "./customers/customerOpModels.js"; export * from "./customers/previousVersions/apiCustomerV2.js"; export * from "./customers/previousVersions/apiCustomerV3.js"; -// NOTE: customersOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation - // Entities export * from "./entities/apiEntity.js"; -// NOTE: entitiesOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation +export * from "./entities/entityLegacyData.js"; export * from "./entities/entityOpModels.js"; +export * from "./entities/prevVersions/apiEntityV0.js"; export * from "./errors/classes/featureErrClasses.js"; export * from "./errors/codes/featureErrCodes.js"; + // Features export * from "./features/apiFeature.js"; export * from "./features/featureOpModels.js"; -// NOTE: featuresOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation - // Others export * from "./others/apiDiscount.js"; export * from "./others/apiInvoice.js"; @@ -71,13 +69,14 @@ export * from "./balances/check/prevVersions/CheckResponseV1.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; export * from "./balances/track/trackResponseV2.js"; -// Balances - +export * from "./balances/track/trackTypes/pgDeductionUpdate.js"; export * from "./balances/usageModels.js"; + export * from "./common/customerData.js"; export * from "./common/entityData.js"; export * from "./customers/cusFeatures/cusFeatureLegacyData.js"; export * from "./customers/cusPlans/previousVersions/apiCusProductV3.js"; +export * from "./entities/apiBaseEntity.js"; // Errors export * from "./errors/index.js"; // Models diff --git a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts index d0f5ace8d..5d7bd3457 100644 --- a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts +++ b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts @@ -6,6 +6,7 @@ import type { ApiVersion } from "../ApiVersion.js"; */ export enum AffectedResource { Customer = "customer", + Entity = "entity", CusProduct = "cus_product", CusFeature = "cus_feature", CusBalance = "cus_balance", diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index 8a30588bb..208d8e41f 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -9,6 +9,8 @@ import { V1_1_FeaturesArrayToObject } from "@api/customers/changes/V1.1_Features import { V1_2_CustomerChange } from "@api/customers/changes/V1.2_CustomerChange.js"; import { V1_2_CustomerQueryChange } from "@api/customers/requestChanges/V1.2_CustomerQueryChange.js"; +// Import entity changes +import { V1_2_EntityChange } from "@api/entities/changes/V1.2_EntityChange.js"; // Import product changes import { V1_2_ProductChanges } from "@api/products/changes/V1.2_ProductChanges.js"; import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.js"; @@ -21,6 +23,7 @@ import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js"; export const V2_CHANGES: VersionChangeConstructor[] = [ V1_2_CustomerChange, // Transforms Customer TO V1.2 format from V2 format V1_2_CustomerQueryChange, // Transforms Customer Query TO V2.0 format (adds expand options) + V1_2_EntityChange, // Transforms Entity TO V0 format from V1 format V1_2_ProductChanges, // Transforms Product TO V1.2 format from V2 Plan format V1_2_CheckChange, // Transforms Check TO V1.2 format from V0.2 format V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options) diff --git a/shared/models/cusModels/cusExpand.ts b/shared/models/cusModels/cusExpand.ts index 4b79b7168..eb5e1ced8 100644 --- a/shared/models/cusModels/cusExpand.ts +++ b/shared/models/cusModels/cusExpand.ts @@ -9,5 +9,10 @@ export enum CusExpand { // PlansPlan = "plans.plan", // FeaturesFeature = "features.feature", Plan = "plan", + + SubscriptionPlan = "subscription.plan", BalanceFeature = "balance.feature", + + // SubscriptionsPlan = "subscriptions.plan", + // BalancesFeature = "balances.feature", } diff --git a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts index 8826c9aa6..31e7964a7 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntModels.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntModels.ts @@ -12,8 +12,8 @@ export const EntityBalanceSchema = z.object({ balance: z.number(), adjustment: z.number(), - additional_balance: z.number(), - additional_granted_balance: z.number(), + additional_balance: z.number().optional(), + additional_granted_balance: z.number().optional(), }); export const CustomerEntitlementSchema = z.object({ diff --git a/shared/utils/cusEntUtils/balanceUtils.ts b/shared/utils/cusEntUtils/balanceUtils.ts index a6f09df45..659948934 100644 --- a/shared/utils/cusEntUtils/balanceUtils.ts +++ b/shared/utils/cusEntUtils/balanceUtils.ts @@ -21,11 +21,11 @@ export const getSummedEntityBalances = ({ return { additional_balance: Object.values(cusEnt.entities).reduce( - (acc, curr) => acc + curr.additional_balance, + (acc, curr) => acc + (curr.additional_balance ?? 0), 0, ), additional_granted_balance: Object.values(cusEnt.entities).reduce( - (acc, curr) => acc + curr.additional_granted_balance, + (acc, curr) => acc + (curr.additional_granted_balance ?? 0), 0, ), balance: Object.values(cusEnt.entities).reduce( diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts index fe374df45..adaf922c4 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts @@ -19,7 +19,14 @@ export const cusEntToPurchasedBalance = ({ // return 0; // 1. If prepaid const cusPrice = cusEntToCusPrice({ cusEnt }); - if (nullish(cusPrice)) return 0; + if (nullish(cusPrice)) { + const { balance } = getCusEntBalance({ + cusEnt, + entityId, + }); + + return Math.max(0, -balance); + } const billingType = getBillingType(cusPrice.price.config); const billingUnits = cusPrice.price.config.billing_units || 1; @@ -40,14 +47,10 @@ export const cusEntToPurchasedBalance = ({ return quantityWithBillingUnits; } - const { balance, adjustment } = getCusEntBalance({ + const { balance } = getCusEntBalance({ cusEnt, entityId, }); - // Balance always includes adjustment. Purchased balance should NOT include adjustment - // return new Decimal(balance).sub(adjustment).toNumber(); - - // // Return negative amount of balance... - return Math.max(0, -(cusEnt.balance || 0)); + return Math.max(0, -balance); }; diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 91c753551..7b4e095bb 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -1,7 +1,5 @@ -import type { - EntityBalance, - FullCustomerEntitlement, -} from "@models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; @@ -33,11 +31,7 @@ export const updateCusEntInFullCus = ({ }: { fullCus: FullCustomer; cusEntId: string; - update: { - balance: number; - entities: Record | undefined; - adjustment: number; - }; + update: PgDeductionUpdate; }) => { for (let i = 0; i < fullCus.customer_products.length; i++) { for ( @@ -47,11 +41,31 @@ export const updateCusEntInFullCus = ({ ) { const ce = fullCus.customer_products[i].customer_entitlements[j]; if (ce.id === cusEntId) { + let replaceables = ce.replaceables ?? []; + + if (update.newReplaceables) { + replaceables = [ + ...replaceables, + ...update.newReplaceables.map((r) => ({ + ...r, + delete_next_cycle: r.delete_next_cycle ?? true, + from_entity_id: r.from_entity_id ?? null, + })), + ]; + } + + if (update.deletedReplaceables) { + replaceables = replaceables.filter( + (r) => !update.deletedReplaceables?.map((r) => r.id).includes(r.id), + ); + } + fullCus.customer_products[i].customer_entitlements[j] = { ...ce, balance: update.balance, entities: update.entities, adjustment: update.adjustment, + replaceables, }; } } diff --git a/shared/utils/expandUtils.ts b/shared/utils/expandUtils.ts new file mode 100644 index 000000000..ebd895652 --- /dev/null +++ b/shared/utils/expandUtils.ts @@ -0,0 +1,69 @@ +import type { ApiCustomer, ApiEntityV1 } from "../api/models.js"; +import { CusExpand } from "../models/cusModels/cusExpand.js"; + +export const addToExpand = ({ + ctx, + add, +}: { + ctx: T; + add: string[]; +}): T => { + return { + ...ctx, + expand: [...ctx.expand, ...add], + }; +}; + +export const filterExpand = ({ + expand, + filter, +}: { + expand: string[]; + filter: string[]; +}) => { + return expand.filter((e) => !filter.includes(e)); +}; + +export const expandIncludes = ({ + expand, + includes, +}: { + expand: string[]; + includes: string[]; +}) => { + return includes.some((i) => expand.includes(i)); +}; + +export const filterPlanAndFeatureExpand = < + T extends ApiCustomer | ApiEntityV1, +>({ + expand, + target, +}: { + expand: string[]; + target: ApiCustomer | ApiEntityV1; +}): T => { + const expandBalanceFeature = expandIncludes({ + expand, + includes: [CusExpand.BalanceFeature], + }); + + if (!expandBalanceFeature && target.balances) { + for (const featureId in target.balances) { + target.balances[featureId].feature = undefined; + } + } + + const expandSubscriptionPlan = expandIncludes({ + expand, + includes: [CusExpand.SubscriptionPlan], + }); + + if (!expandSubscriptionPlan && target.subscriptions) { + for (let i = 0; i < target.subscriptions?.length; i++) { + target.subscriptions[i].plan = undefined; + } + } + + return target as T; +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 7080f9230..82d5ffb8e 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -22,6 +22,7 @@ export * from "./cusProductUtils/formatCusProductUtils.js"; export * from "./cusProductUtils/productIdToCusProduct.js"; // Cus utils export * from "./cusUtils/cusPlanUtils/cusPlanUtils.js"; +export * from "./expandUtils.js"; export * from "./featureUtils/apiFeatureToDbFeature.js"; export * from "./featureUtils/convertFeatureUtils.js"; // Feature utils diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index a4527f49d..8766a822b 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -24,16 +24,3 @@ export const keyToTitle = ( .replace(/[-_]/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()); }; - -export const addToExpand = ({ - ctx, - add, -}: { - ctx: T; - add: string[]; -}): T => { - return { - ...ctx, - expand: [...ctx.expand, ...add], - }; -}; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index a1ac21c11..000000000 --- a/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "files": [], - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - }, - "exclude": ["node_modules", "**/node_modules", "**/.bun"] -} diff --git a/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx b/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx index 50280d62a..f803a1b8a 100644 --- a/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx +++ b/vite/src/views/customers/customer/entitlements/CusEntBalance.tsx @@ -58,7 +58,8 @@ export const CusEntBalance = ({ if (cusEnt.entities) { const totalBalance = Object.values(cusEnt.entities).reduce( - (sum, entity) => sum + (entity.balance || 0) + entity.additional_balance, + (sum, entity) => + sum + (entity.balance || 0) + (entity.additional_balance ?? 0), 0, ); @@ -68,7 +69,9 @@ export const CusEntBalance = ({ sum + Object.values(rollover.entities).reduce( (entitySum: number, entity: any) => - entitySum + (entity.balance || 0) + entity.additional_balance, + entitySum + + (entity.balance || 0) + + (entity.additional_balance ?? 0), 0, ) );