diff --git a/scripts/start-dev.js b/scripts/start-dev.js index 01a6412ac..f6d740a21 100644 --- a/scripts/start-dev.js +++ b/scripts/start-dev.js @@ -165,6 +165,19 @@ async function startDev() { }); console.log("\nโœ… Shared package built successfully!\n"); + + // Clear Vite cache to prevent dep optimization issues + const viteCachePath = path.join( + projectRoot, + "vite", + "node_modules", + ".vite", + ); + if (fs.existsSync(viteCachePath)) { + console.log("๐Ÿงน Clearing Vite cache...\n"); + fs.rmSync(viteCachePath, { recursive: true, force: true }); + } + console.log("๐Ÿš€ Starting development servers in watch mode...\n"); // Step 2: Start server, workers, and vite first (they'll use the built shared package) diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 0264be699..052e98f67 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -314,9 +314,29 @@ export class AutumnInt { }; entities = { - get: async (customerId: string, entityId: string) => { + get: async ( + customerId: string, + entityId: string, + params?: { + expand?: EntityExpand[]; + skip_cache?: string; + }, + ) => { + const queryParams = new URLSearchParams(); + const defaultParams = { + expand: [EntityExpand.Invoices], + }; + + const finalParams = { ...defaultParams, ...params }; + if (finalParams.expand) { + queryParams.append("expand", finalParams.expand.join(",")); + } + if (finalParams.skip_cache) { + queryParams.append("skip_cache", finalParams.skip_cache); + } + const data = await this.get( - `/customers/${customerId}/entities/${entityId}?expand=${EntityExpand.Invoices}`, + `/customers/${customerId}/entities/${entityId}?${queryParams.toString()}`, ); return data; }, diff --git a/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts b/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts index 8cf43ccb7..cf9bef156 100644 --- a/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts +++ b/server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts @@ -156,6 +156,8 @@ export class BatchingManager { overageBehavior: r.overageBehavior, entityId: r.entityId, })), + orgId: batch.orgId, + env: batch.env, }); console.log(`โœ… Batch completed (${batchSize} requests)`); diff --git a/server/src/internal/balances/track/redisTrackUtils/batchDeduction.lua b/server/src/internal/balances/track/redisTrackUtils/batchDeduction.lua index 0348423b2..62f6f705c 100644 --- a/server/src/internal/balances/track/redisTrackUtils/batchDeduction.lua +++ b/server/src/internal/balances/track/redisTrackUtils/batchDeduction.lua @@ -11,9 +11,13 @@ -- }, -- ... -- ] +-- ARGV[2]: org_id +-- ARGV[3]: env local cacheKey = KEYS[1] local requestsJson = ARGV[1] +local orgId = ARGV[2] +local env = ARGV[3] -- Parse requests local requests = cjson.decode(requestsJson) @@ -199,36 +203,15 @@ local function deductFromMainBalance(cusFeature, amount) local deltas = {} local stateChanges = {} - -- Handle negative amounts (refunds) - just add to balance and subtract from usage - if amount < 0 then - local creditAmount = -amount - table.insert(deltas, {key = cusFeature._key, field = "balance", delta = creditAmount}) - table.insert(deltas, {key = cusFeature._key, field = "usage", delta = -creditAmount}) - table.insert(stateChanges, { - type = "cusFeature", - field = "balance", - delta = creditAmount - }) - table.insert(stateChanges, { - type = "cusFeature", - field = "usage", - delta = -creditAmount - }) - return { - remaining = 0, - deltas = deltas, - stateChanges = stateChanges - } - end - -- If cusFeature has breakdowns, deduct from breakdowns if #cusFeature.breakdowns > 0 then - -- Pass 1: Deduct from breakdown balances + -- Pass 1: Deduct from breakdown balances (or refund to breakdown) for index, breakdown in ipairs(cusFeature.breakdowns) do if remaining == 0 then break end local breakdownBalance = breakdown.balance or 0 - if breakdownBalance > 0 then + -- For refunds (negative amount), always apply. For deductions, only if balance > 0 + if remaining < 0 or breakdownBalance > 0 then local toDeduct = math.min(remaining, breakdownBalance) -- Collect Redis deltas @@ -324,9 +307,10 @@ local function deductFromMainBalance(cusFeature, amount) end end else - -- No breakdowns: deduct from top-level balance + -- No breakdowns: deduct from top-level balance (or refund to top-level) local topLevelBalance = cusFeature.balance or 0 - if topLevelBalance > 0 then + -- For refunds (negative amount), always apply. For deductions, only if balance > 0 + if remaining < 0 or topLevelBalance > 0 then local toDeduct = math.min(remaining, topLevelBalance) -- Collect Redis deltas @@ -434,6 +418,168 @@ local function deductFromCusFeature(cusFeature, amount) } end +-- Deduct from customer feature AND entity features +-- If targetEntityId is provided, only deduct from that entity (entity-level tracking) +-- If targetEntityId is nil, deduct from ALL entities (customer-level tracking) +-- Returns: { remaining: number, deltas: array, customerStateChanges: array, entityStateChanges: { [entityId] = array } } +local function deductFromFeatureWithEntities(customerFeature, entityFeaturesMap, amount, targetEntityId) + local allDeltas = {} + local customerStateChanges = {} + local entityStateChanges = {} + + local remaining = amount + + if targetEntityId then + -- Entity-level tracking: deduct from entity FIRST, then customer + + -- Step 1: Deduct from entity rollovers + local entityFeatures = entityFeaturesMap[targetEntityId] + if entityFeatures then + local entityFeature = entityFeatures[customerFeature.id] + if entityFeature and remaining > 0 then + local entityRolloverResult = deductFromRollovers(entityFeature, remaining) + remaining = entityRolloverResult.remaining + for _, delta in ipairs(entityRolloverResult.deltas) do + table.insert(allDeltas, delta) + end + if not entityStateChanges[targetEntityId] then + entityStateChanges[targetEntityId] = {} + end + for _, change in ipairs(entityRolloverResult.stateChanges) do + table.insert(entityStateChanges[targetEntityId], change) + end + end + end + + -- Step 2: Deduct from entity main balance + if entityFeatures then + local entityFeature = entityFeatures[customerFeature.id] + if entityFeature and remaining > 0 then + local entityMainResult = deductFromMainBalance(entityFeature, remaining) + remaining = entityMainResult.remaining + for _, delta in ipairs(entityMainResult.deltas) do + table.insert(allDeltas, delta) + end + if not entityStateChanges[targetEntityId] then + entityStateChanges[targetEntityId] = {} + end + for _, change in ipairs(entityMainResult.stateChanges) do + table.insert(entityStateChanges[targetEntityId], change) + end + end + end + + -- Step 3: Deduct from customer rollovers + if remaining > 0 then + local customerRolloverResult = deductFromRollovers(customerFeature, remaining) + remaining = customerRolloverResult.remaining + for _, delta in ipairs(customerRolloverResult.deltas) do + table.insert(allDeltas, delta) + end + for _, change in ipairs(customerRolloverResult.stateChanges) do + table.insert(customerStateChanges, change) + end + end + + -- Step 4: Deduct from customer main balance + if remaining > 0 then + local customerMainResult = deductFromMainBalance(customerFeature, remaining) + remaining = customerMainResult.remaining + for _, delta in ipairs(customerMainResult.deltas) do + table.insert(allDeltas, delta) + end + for _, change in ipairs(customerMainResult.stateChanges) do + table.insert(customerStateChanges, change) + end + end + else + -- Customer-level tracking: deduct from customer FIRST, then all entities + + -- Step 1: Deduct from customer rollovers + local customerRolloverResult = deductFromRollovers(customerFeature, remaining) + remaining = customerRolloverResult.remaining + for _, delta in ipairs(customerRolloverResult.deltas) do + table.insert(allDeltas, delta) + end + for _, change in ipairs(customerRolloverResult.stateChanges) do + table.insert(customerStateChanges, change) + end + + -- Step 2: Deduct from customer main balance + if remaining > 0 then + local customerMainResult = deductFromMainBalance(customerFeature, remaining) + remaining = customerMainResult.remaining + for _, delta in ipairs(customerMainResult.deltas) do + table.insert(allDeltas, delta) + end + for _, change in ipairs(customerMainResult.stateChanges) do + table.insert(customerStateChanges, change) + end + end + + -- Step 3: Deduct from all entity rollovers (sorted for consistency) + if remaining > 0 then + local sortedEntityIds = {} + for entityId in pairs(entityFeaturesMap) do + table.insert(sortedEntityIds, entityId) + end + table.sort(sortedEntityIds) + + for _, entityId in ipairs(sortedEntityIds) do + local entityFeatures = entityFeaturesMap[entityId] + local entityFeature = entityFeatures[customerFeature.id] + if entityFeature and remaining > 0 then + local entityRolloverResult = deductFromRollovers(entityFeature, remaining) + remaining = entityRolloverResult.remaining + for _, delta in ipairs(entityRolloverResult.deltas) do + table.insert(allDeltas, delta) + end + if not entityStateChanges[entityId] then + entityStateChanges[entityId] = {} + end + for _, change in ipairs(entityRolloverResult.stateChanges) do + table.insert(entityStateChanges[entityId], change) + end + end + end + end + + -- Step 4: Deduct from all entity main balances (sorted for consistency) + if remaining > 0 then + local sortedEntityIds = {} + for entityId in pairs(entityFeaturesMap) do + table.insert(sortedEntityIds, entityId) + end + table.sort(sortedEntityIds) + + for _, entityId in ipairs(sortedEntityIds) do + local entityFeatures = entityFeaturesMap[entityId] + local entityFeature = entityFeatures[customerFeature.id] + if entityFeature and remaining > 0 then + local entityMainResult = deductFromMainBalance(entityFeature, remaining) + remaining = entityMainResult.remaining + for _, delta in ipairs(entityMainResult.deltas) do + table.insert(allDeltas, delta) + end + if not entityStateChanges[entityId] then + entityStateChanges[entityId] = {} + end + for _, change in ipairs(entityMainResult.stateChanges) do + table.insert(entityStateChanges[entityId], change) + end + end + end + end + end + + return { + remaining = remaining, + deltas = allDeltas, + customerStateChanges = customerStateChanges, + entityStateChanges = entityStateChanges + } +end + -- ============================================================================ -- REQUEST PROCESSING -- ============================================================================ @@ -471,9 +617,10 @@ end -- Process a single request (one unit with multiple cusFeature deductions) -- Returns: { success: boolean, error?: string } -local function processRequest(request, loadedCusFeatures) +local function processRequest(request, loadedCusFeatures, entityFeatureStates) local featureDeductions = request.featureDeductions local overageBehavior = request.overageBehavior or "cap" + local entityId = request.entityId -- nil for customer-level tracking, set for entity-level tracking -- Collect all deltas and state changes for this request local requestDeltas = {} @@ -489,28 +636,105 @@ local function processRequest(request, loadedCusFeatures) local remainingAmount = amount if cusFeature then - -- DEPRECATED: Will be removed in future version - -- Continuous use features are now allowed to dip below 0 - -- Previously required PostgreSQL tracking, now handled in Redis - + -- Customer has this feature - deduct from customer + entities if not cusFeature.unlimited then - local result = deductFromCusFeature(cusFeature, amount) + local result = deductFromFeatureWithEntities(cusFeature, entityFeatureStates, amount, entityId) - -- Collect deltas and state changes + -- Collect deltas for _, delta in ipairs(result.deltas) do table.insert(requestDeltas, delta) end + + -- Collect customer state changes table.insert(requestStateChanges, { + target = "customer", cusFeature = cusFeature, - changes = result.stateChanges + changes = result.customerStateChanges }) + -- Collect entity state changes + for entityIdKey, changes in pairs(result.entityStateChanges) do + table.insert(requestStateChanges, { + target = "entity", + entityId = entityIdKey, + cusFeature = entityFeatureStates[entityIdKey][cusFeature.id], + changes = changes + }) + end + -- Update remaining amount remainingAmount = result.remaining else -- Unlimited feature covers everything remainingAmount = 0 end + else + -- Entity-only feature - customer doesn't have it, only entities do + -- Deduct directly from entity/entities + if entityId then + -- Entity-level tracking: deduct from specific entity only + local entityFeatures = entityFeatureStates[entityId] + if entityFeatures and entityFeatures[featureId] then + local entityFeature = entityFeatures[featureId] + if not entityFeature.unlimited then + local result = deductFromCusFeature(entityFeature, amount) + + -- Collect deltas + for _, delta in ipairs(result.deltas) do + table.insert(requestDeltas, delta) + end + + -- Collect entity state changes + table.insert(requestStateChanges, { + target = "entity", + entityId = entityId, + cusFeature = entityFeature, + changes = result.stateChanges + }) + + remainingAmount = result.remaining + else + remainingAmount = 0 + end + end + else + -- Customer-level tracking: deduct from ALL entities (sorted for consistency) + local sortedEntityIds = {} + for entId in pairs(entityFeatureStates) do + table.insert(sortedEntityIds, entId) + end + table.sort(sortedEntityIds) + + local totalDeducted = 0 + for _, entId in ipairs(sortedEntityIds) do + local entityFeatures = entityFeatureStates[entId] + local entityFeature = entityFeatures[featureId] + if entityFeature and remainingAmount > 0 then + if not entityFeature.unlimited then + local result = deductFromCusFeature(entityFeature, remainingAmount) + + -- Collect deltas + for _, delta in ipairs(result.deltas) do + table.insert(requestDeltas, delta) + end + + -- Collect entity state changes + table.insert(requestStateChanges, { + target = "entity", + entityId = entId, + cusFeature = entityFeature, + changes = result.stateChanges + }) + + totalDeducted = totalDeducted + (amount - result.remaining) + remainingAmount = result.remaining + else + remainingAmount = 0 + break + end + end + end + end end -- Step 2: If there's remaining amount, try credit systems @@ -525,17 +749,30 @@ local function processRequest(request, loadedCusFeatures) local creditAmount = remainingAmount * creditItem.credit_amount if not otherCusFeature.unlimited then - local result = deductFromCusFeature(otherCusFeature, creditAmount) + local result = deductFromFeatureWithEntities(otherCusFeature, entityFeatureStates, creditAmount, entityId) - -- Collect deltas and state changes + -- Collect deltas for _, delta in ipairs(result.deltas) do table.insert(requestDeltas, delta) end + + -- Collect customer state changes table.insert(requestStateChanges, { + target = "customer", cusFeature = otherCusFeature, - changes = result.stateChanges + changes = result.customerStateChanges }) + -- Collect entity state changes + for entityId, changes in pairs(result.entityStateChanges) do + table.insert(requestStateChanges, { + target = "entity", + entityId = entityId, + cusFeature = entityFeatureStates[entityId][otherCusFeature.id], + changes = changes + }) + end + -- Update remaining based on what credit system could cover -- If credit system couldn't cover all, calculate how much of original remains if result.remaining ~= 0 then @@ -615,10 +852,111 @@ for _, featureId in ipairs(allFeatureIds) do end end +-- Get entity IDs from customer +local baseCustomer = cjson.decode(baseJson) +local entityIds = baseCustomer._entityIds or {} + +-- Load all entity features: { [entityId] = { [featureId] = entityFeature } } +local entityFeatureStates = {} +for _, entityId in ipairs(entityIds) do + local entityCacheKey = orgId .. ":" .. env .. ":entity:" .. entityId + local entityBaseJson = redis.call("GET", entityCacheKey) + + if entityBaseJson then + local entityBase = cjson.decode(entityBaseJson) + local entityFeatureIds = entityBase._featureIds or {} + entityFeatureStates[entityId] = {} + + for _, featureId in ipairs(entityFeatureIds) do + -- Load entity feature inline (similar to loadCusFeature but with entity keys) + local entityFeatureKey = entityCacheKey .. ":features:" .. featureId + local entityFeatureHash = redis.call("HGETALL", entityFeatureKey) + + if #entityFeatureHash > 0 then + local entityFeature = { id = featureId, _key = entityFeatureKey } + + -- Parse entity feature fields + for i = 1, #entityFeatureHash, 2 do + local key = entityFeatureHash[i] + local value = entityFeatureHash[i + 1] + + if key == "balance" or key == "usage" or key == "usage_limit" or key == "included_usage" or key == "_breakdown_count" or key == "_rollover_count" then + entityFeature[key] = tonumber(value) + elseif key == "unlimited" or key == "overage_allowed" then + entityFeature[key] = (value == "true") + elseif key == "type" then + entityFeature[key] = value + elseif key == "credit_schema" then + if value ~= "null" and value ~= "" then + entityFeature[key] = cjson.decode(value) + else + entityFeature[key] = nil + end + elseif value == "null" then + entityFeature[key] = nil + else + entityFeature[key] = value + end + end + + -- Load entity breakdowns + local breakdownCount = entityFeature._breakdown_count or 0 + entityFeature.breakdowns = {} + for i = 0, breakdownCount - 1 do + local breakdownKey = entityCacheKey .. ":features:" .. featureId .. ":breakdown:" .. i + local breakdownHash = redis.call("HGETALL", breakdownKey) + + if #breakdownHash > 0 then + local breakdown = { _index = i, _key = breakdownKey } + for j = 1, #breakdownHash, 2 do + local key = breakdownHash[j] + local value = breakdownHash[j + 1] + + if key == "balance" or key == "usage" or key == "usage_limit" then + breakdown[key] = tonumber(value) + elseif key == "overage_allowed" then + breakdown[key] = (value == "true") + else + breakdown[key] = value + end + end + table.insert(entityFeature.breakdowns, breakdown) + end + end + + -- Load entity rollovers + local rolloverCount = entityFeature._rollover_count or 0 + entityFeature.rollovers = {} + for i = 0, rolloverCount - 1 do + local rolloverKey = entityCacheKey .. ":features:" .. featureId .. ":rollover:" .. i + local rolloverHash = redis.call("HGETALL", rolloverKey) + + if #rolloverHash > 0 then + local rollover = { _index = i, _key = rolloverKey } + for j = 1, #rolloverHash, 2 do + local key = rolloverHash[j] + local value = rolloverHash[j + 1] + + if key == "balance" or key == "expires_at" then + rollover[key] = tonumber(value) + else + rollover[key] = value + end + end + table.insert(entityFeature.rollovers, rollover) + end + end + + entityFeatureStates[entityId][featureId] = entityFeature + end + end + end +end + -- Process all requests local results = {} for i, request in ipairs(requests) do - local result = processRequest(request, loadedCusFeatures) + local result = processRequest(request, loadedCusFeatures, entityFeatureStates) table.insert(results, result) end diff --git a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts index 9dbc493ec..98c6c257d 100644 --- a/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts +++ b/server/src/internal/balances/track/redisTrackUtils/executeBatchDeduction.ts @@ -32,10 +32,14 @@ export const executeBatchDeduction = async ({ redis, cacheKey, requests, + orgId, + env, }: { redis: Redis; cacheKey: string; requests: BatchRequest[]; + orgId: string; + env: string; }): Promise => { try { // Execute Lua script (hot reload in dev) @@ -44,6 +48,8 @@ export const executeBatchDeduction = async ({ 1, // number of keys cacheKey, // KEYS[1] JSON.stringify(requests), // ARGV[1] + orgId, // ARGV[2] + env, // ARGV[3] ); // Parse result diff --git a/server/src/internal/balances/track/syncUtils/SyncBatchingManager.ts b/server/src/internal/balances/track/syncUtils/SyncBatchingManager.ts index 61014a3f1..5ff46ee31 100644 --- a/server/src/internal/balances/track/syncUtils/SyncBatchingManager.ts +++ b/server/src/internal/balances/track/syncUtils/SyncBatchingManager.ts @@ -7,6 +7,7 @@ interface SyncPairContext { orgId: string; env: string; entityId?: string; + timestamp: number; } interface Batch { @@ -42,7 +43,7 @@ export class SyncBatchingManager { orgId, env, entityId, - }: SyncPairContext): void { + }: Omit): void { // Create unique key for this pair const pairKey = `${orgId}:${env}:${customerId}:${featureId}${entityId ? `:${entityId}` : ""}`; @@ -52,12 +53,15 @@ export class SyncBatchingManager { } // Add or update pair (Map handles deduplication) + // Use the earliest timestamp if the pair already exists, otherwise use current time + const existingPair = this.batch.pairs.get(pairKey); this.batch.pairs.set(pairKey, { customerId, featureId, orgId, env, entityId, + timestamp: existingPair?.timestamp ?? Date.now(), }); // Force flush if batch is full diff --git a/server/src/internal/balances/track/syncUtils/runSyncBalanceBatch.ts b/server/src/internal/balances/track/syncUtils/runSyncBalanceBatch.ts index 56d715c40..6667d8a27 100644 --- a/server/src/internal/balances/track/syncUtils/runSyncBalanceBatch.ts +++ b/server/src/internal/balances/track/syncUtils/runSyncBalanceBatch.ts @@ -58,40 +58,60 @@ export const runSyncBalanceBatch = async ({ } } - // Step 2: Process each sync item + // Step 2: Sort items by timestamp (oldest first) to maintain chronological order + const sortedItems = items.sort((a, b) => a.timestamp - b.timestamp); + + // Step 3: Group items by customer to process sequentially per customer + const itemsByCustomer = new Map(); + for (const item of sortedItems) { + const customerKey = `${item.orgId}:${item.env}:${item.customerId}`; + if (!itemsByCustomer.has(customerKey)) { + itemsByCustomer.set(customerKey, []); + } + itemsByCustomer.get(customerKey)!.push(item); + } + + // Step 4: Process each customer's items sequentially (customers can run in parallel) let successCount = 0; let errorCount = 0; - for (const item of items) { - try { - const key = `${item.orgId}:${item.env}`; - const orgData = orgMap.get(key); + const customerPromises = Array.from(itemsByCustomer.entries()).map( + async ([customerKey, customerItems]) => { + for (const item of customerItems) { + try { + const key = `${item.orgId}:${item.env}`; + const orgData = orgMap.get(key); - if (!orgData) { - logger.warn(`Organization not found: ${key}`); - errorCount++; - continue; + if (!orgData) { + logger.warn(`Organization not found: ${key}`); + errorCount++; + continue; + } + + // Create worker context + const ctx = createWorkerContext({ + db, + org: orgData.org, + env: item.env as AppEnv, + features: orgData.features, + logger, + }); + + // Sync the item sequentially + await syncItem({ item, ctx }); + successCount++; + } catch (error) { + errorCount++; + logger.error( + `โŒ Failed to sync item ${item.customerId}:${item.featureId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } + }, + ); - // Create worker context - const ctx = createWorkerContext({ - db, - org: orgData.org, - env: item.env as AppEnv, - features: orgData.features, - logger, - }); - - // Sync the item - await syncItem({ item, ctx }); - successCount++; - } catch (error) { - errorCount++; - logger.error( - `โŒ Failed to sync item ${item.customerId}:${item.featureId}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } + // Wait for all customer syncs to complete + await Promise.all(customerPromises); logger.info( `Sync batch complete: ${successCount} succeeded, ${errorCount} failed`, diff --git a/server/src/internal/balances/track/syncUtils/syncItem.ts b/server/src/internal/balances/track/syncUtils/syncItem.ts index e0aebd593..c8a68b334 100644 --- a/server/src/internal/balances/track/syncUtils/syncItem.ts +++ b/server/src/internal/balances/track/syncUtils/syncItem.ts @@ -1,8 +1,13 @@ -import { getRelevantFeatures } from "@autumn/shared"; +import { + type ApiCustomer, + type ApiEntity, + getRelevantFeatures, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; import { RELEVANT_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; import { getCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js"; +import { getCachedApiEntity } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js"; import type { FeatureDeduction } from "../trackUtils/getFeatureDeductions.js"; import { deductFromCusEnts } from "../trackUtils/runDeductionTx.js"; @@ -12,6 +17,7 @@ export interface SyncItem { orgId: string; env: string; entityId?: string; + timestamp: number; } /** @@ -29,10 +35,21 @@ export const syncItem = async ({ const { db, org, env } = ctx; // Get cached customer from Redis - const { apiCustomer: redisCustomer } = await getCachedApiCustomer({ - ctx, - customerId, - }); + let redisEntity: ApiCustomer | ApiEntity; + if (entityId) { + const { apiEntity } = await getCachedApiEntity({ + ctx, + customerId, + entityId, + }); + redisEntity = apiEntity; + } else { + const { apiCustomer } = await getCachedApiCustomer({ + ctx, + customerId, + }); + redisEntity = apiCustomer; + } // Get fresh customer from DB (no locking - let deduction handle it) const fullCus = await CusService.getFull({ @@ -63,8 +80,9 @@ export const syncItem = async ({ // "SYNC LAYER, REDIS CUSTOMER FEATURES:", // JSON.stringify(redisCustomer.features, null, 2), // ); + for (const relevantFeature of relevantFeatures) { - const redisCusFeature = redisCustomer.features[relevantFeature.id]; + const redisCusFeature = redisEntity.features?.[relevantFeature.id]; featureDeductions.push({ feature: relevantFeature, deduction: 0, @@ -72,16 +90,8 @@ export const syncItem = async ({ }); } - // console.log( - // `SYNC LAYER, FEATURE DEDUCTIONS:`, - // featureDeductions.map((d) => ({ - // feature_id: d.feature.id, - // deduction: d.deduction, - // targetBalance: d.targetBalance, - // })), - // ); - // Sync from Redis to Postgres - deduct using target balance + await deductFromCusEnts({ ctx, customerId, diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql index 9737ee35e..077029a68 100644 --- a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromMainBalance.sql @@ -51,8 +51,8 @@ BEGIN result_entities := current_entities; deducted_amount := 0; - -- Loop through all entities and deduct iteratively - FOR entity_key IN SELECT jsonb_object_keys(current_entities) + -- Loop through all entities and deduct iteratively (sorted for consistency with Redis) + FOR entity_key IN SELECT jsonb_object_keys(current_entities) ORDER BY 1 LOOP EXIT WHEN remaining = 0; diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql index 165a6e884..6aa53b4f9 100644 --- a/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql +++ b/server/src/internal/balances/track/trackUtils/deductRpc/deductFromRollovers.sql @@ -76,7 +76,7 @@ BEGIN new_entities := current_entities; deduct_amount := 0; - FOR entity_key IN SELECT jsonb_object_keys(current_entities) + FOR entity_key IN SELECT jsonb_object_keys(current_entities) ORDER BY 1 LOOP EXIT WHEN remaining_amount <= 0; diff --git a/server/src/internal/balances/track/trackUtils/deductRpc/performDeductionV2.sql b/server/src/internal/balances/track/trackUtils/deductRpc/performDeductionV2.sql index a86438e99..37d83beb7 100644 --- a/server/src/internal/balances/track/trackUtils/deductRpc/performDeductionV2.sql +++ b/server/src/internal/balances/track/trackUtils/deductRpc/performDeductionV2.sql @@ -4,14 +4,15 @@ -- Two-pass strategy: -- Pass 1: Deduct all entitlements to 0 -- Pass 2: Allow usage_allowed=true entitlements to go negative -DROP FUNCTION IF EXISTS deduct_allowance_from_entitlements(jsonb, numeric, numeric, text, text[]); +DROP FUNCTION IF EXISTS deduct_allowance_from_entitlements(jsonb, numeric, numeric, text, text[], text[]); CREATE FUNCTION deduct_allowance_from_entitlements( sorted_entitlements jsonb, amount_to_deduct numeric DEFAULT NULL, target_balance numeric DEFAULT NULL, target_entity_id text DEFAULT NULL, - rollover_ids text[] DEFAULT NULL + rollover_ids text[] DEFAULT NULL, + cus_ent_ids text[] DEFAULT NULL ) RETURNS jsonb LANGUAGE plpgsql @@ -55,16 +56,12 @@ BEGIN -- STEP 0: Lock all rows upfront to prevent deadlocks -- ============================================================================ - -- Lock all entitlement rows - FOR ent_obj IN SELECT * FROM jsonb_array_elements(sorted_entitlements) - LOOP - ent_id := ent_obj->>'customer_entitlement_id'; - - -- Lock the row - PERFORM 1 FROM customer_entitlements ce WHERE ce.id = ent_id FOR UPDATE; - END LOOP; + -- Lock all entitlement rows at once (prevents interleaved locking deadlocks) + IF cus_ent_ids IS NOT NULL AND array_length(cus_ent_ids, 1) > 0 THEN + PERFORM 1 FROM customer_entitlements ce WHERE ce.id = ANY(cus_ent_ids) FOR UPDATE; + END IF; - -- Lock all rollover rows + -- Lock all rollover rows at once IF rollover_ids IS NOT NULL AND array_length(rollover_ids, 1) > 0 THEN PERFORM 1 FROM rollovers r WHERE r.id = ANY(rollover_ids) FOR UPDATE; END IF; @@ -93,8 +90,8 @@ BEGIN entity_balance := COALESCE((current_entities->target_entity_id->>'balance')::numeric, 0); total_balance := total_balance + entity_balance; ELSE - -- All entities - FOR entity_key IN SELECT jsonb_object_keys(current_entities) + -- All entities (sorted for consistency with Redis) + FOR entity_key IN SELECT jsonb_object_keys(current_entities) ORDER BY 1 LOOP entity_balance := COALESCE((current_entities->entity_key->>'balance')::numeric, 0); total_balance := total_balance + entity_balance; @@ -126,8 +123,8 @@ BEGIN entity_balance := COALESCE((current_entities->target_entity_id->>'balance')::numeric, 0); total_balance := total_balance + entity_balance; ELSE - -- All entities - FOR entity_key IN SELECT jsonb_object_keys(current_entities) + -- All entities (sorted for consistency with Redis) + FOR entity_key IN SELECT jsonb_object_keys(current_entities) ORDER BY 1 LOOP entity_balance := COALESCE((current_entities->entity_key->>'balance')::numeric, 0); total_balance := total_balance + entity_balance; diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index 46ee60ab9..32f3f091b 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -137,6 +137,9 @@ export const deductFromCusEnts = async ({ const rolloverIds = sortedRollovers.map((r) => r.id); + // Extract entitlement IDs for locking + const cusEntIds = cusEntInput.map((ce) => ce.customer_entitlement_id); + // Call the stored function to deduct from entitlements with credit costs const result = await db.execute( sql`SELECT * FROM deduct_allowance_from_entitlements( @@ -144,7 +147,8 @@ export const deductFromCusEnts = async ({ ${toDeduct}, ${targetBalance ?? null}, ${entityId || null}, - ${rolloverIds.length > 0 ? sql.raw(`ARRAY[${rolloverIds.map((id) => `'${id}'`).join(",")}]`) : null} + ${rolloverIds.length > 0 ? sql.raw(`ARRAY[${rolloverIds.map((id) => `'${id}'`).join(",")}]`) : null}, + ${cusEntIds.length > 0 ? sql.raw(`ARRAY[${cusEntIds.map((id) => `'${id}'`).join(",")}]`) : null} )`, ); @@ -184,8 +188,11 @@ export const deductFromCusEnts = async ({ (sum, update) => sum + update.deducted, 0, ); + const entityInfo = entityId + ? `Entity: ${entityId}` + : "Entity: customer-level"; ctx.logger.info( - `[Sync] Feature ${feature.id} | Target: ${targetBalance} | Deducted: ${totalDeducted} | Updated ${ + `[Sync] Feature ${feature.id} | ${entityInfo} | Target: ${targetBalance} | Deducted: ${totalDeducted} | Updated ${ Object.keys(updates).length } entitlements | Remaining: ${remaining}`, ); @@ -304,7 +311,10 @@ export const runDeductionTx = async ( const newEvent = await constructEvent({ ctx: txParams.ctx, eventInfo: params.eventInfo, - fullCus, + internalCustomerId: fullCus.internal_id, + internalEntityId: fullCus.entity?.internal_id, + customerId: fullCus.id ?? "", + entityId: fullCus.entity?.id, }); event = await EventService.insert({ diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts index 9ea129475..6246dace1 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.ts @@ -3,9 +3,14 @@ import { ApiCustomerSchema, type AppEnv, type CustomerLegacyData, + filterEntityLevelCusProducts, + filterOutEntitiesFromCusProducts, } from "@autumn/shared"; import { redis } from "../../../../external/redis/initRedis.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { normalizeCachedData } from "../../../../utils/cacheUtils/cacheUtils.js"; +import { SET_ENTITIES_BATCH_SCRIPT } from "../../../entities/entityUtils/apiEntityCacheUtils/luaScripts.js"; +import { getApiEntityBase } from "../../../entities/entityUtils/apiEntityUtils/getApiEntityBase.js"; import { CusService } from "../../CusService.js"; import { RELEVANT_STATUSES } from "../../cusProducts/CusProductService.js"; import { getApiCustomerBase } from "../apiCusUtils/getApiCustomerBase.js"; @@ -53,13 +58,17 @@ export const getCachedApiCustomer = async ({ GET_CUSTOMER_SCRIPT, 1, // number of keys cacheKey, // KEYS[1] + org.id, // ARGV[1] + env, // ARGV[2] ); // If found in cache, parse and return if (cachedResult) { - const cached = JSON.parse(cachedResult as string) as ApiCustomer & { - legacyData: CustomerLegacyData; - }; + const cached = normalizeCachedData( + JSON.parse(cachedResult as string) as ApiCustomer & { + legacyData: CustomerLegacyData; + }, + ); // Extract legacyData and reconstruct apiCustomer with correct key order const { legacyData, ...rest } = cached; @@ -75,13 +84,14 @@ export const getCachedApiCustomer = async ({ } // Cache miss or skipCache - fetch from DB + const fullCus = await CusService.getFull({ db, idOrInternalId: customerId, orgId: org.id, env: env as AppEnv, inStatuses: RELEVANT_STATUSES, - withEntities: false, + withEntities: true, withSubs: true, }); @@ -92,19 +102,77 @@ export const getCachedApiCustomer = async ({ withAutumnId: !skipCache, }); - // Store in cache (only if not skipping cache) + // Build master api customer (customer-level features only) + const { apiCustomer: masterApiCustomer } = await getApiCustomerBase({ + ctx, + fullCus: { + ...structuredClone(fullCus), + customer_products: filterOutEntitiesFromCusProducts({ + cusProducts: fullCus.customer_products, + }), + }, + withAutumnId: !skipCache, + }); + + // Build entity api customers (entity-level features only) + const entityLevelCusProducts = filterEntityLevelCusProducts({ + cusProducts: fullCus.customer_products, + }); + + // Store master customer cache (only if not skipping cache) if (!skipCache) { await redis.eval( SET_CUSTOMER_SCRIPT, 1, // number of keys cacheKey, // KEYS[1] - JSON.stringify({ ...apiCustomer, legacyData }), // ARGV[1] + JSON.stringify({ + ...masterApiCustomer, + entities: fullCus.entities, // Include entities array for merging in Lua + legacyData, + }), // ARGV[1] - Store master, not merged + org.id, // ARGV[2] + env, // ARGV[3] ); + + // Build all entities in batch + const entityBatch = []; + + // Create a single shallow copy with entity-level products + // getApiEntityBase will filter products per entity internally + const entityFullCus = { + ...fullCus, + customer_products: entityLevelCusProducts, + }; + + for (const entity of fullCus.entities) { + const { apiEntity } = await getApiEntityBase({ + ctx, + fullCus: entityFullCus, + entity, + }); + + entityBatch.push({ + entityId: entity.id, + entityData: apiEntity, + }); + } + + // Store all entities in a single Redis call + if (entityBatch.length > 0) { + await redis.eval( + SET_ENTITIES_BATCH_SCRIPT, + 0, // number of keys (we build them dynamically in Lua) + JSON.stringify(entityBatch), // ARGV[1] + org.id, // ARGV[2] + env, // ARGV[3] + ); + } } return { apiCustomer: ApiCustomerSchema.parse({ ...apiCustomer, + autumn_id: withAutumnId ? fullCus.internal_id : undefined, }), legacyData, diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCustomer.lua b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCustomer.lua index f2ad2791f..24baf0b43 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCustomer.lua +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/getCustomer.lua @@ -1,9 +1,14 @@ -- getCustomer.lua -- Atomically retrieves a customer object from Redis, reconstructing from base JSON and feature HSETs +-- Merges master customer features with entity features -- KEYS[1]: cache key (e.g., "org_id:env:customer:customer_id") +-- ARGV[1]: org_id (for building entity cache keys) +-- ARGV[2]: env (for building entity cache keys) local cacheKey = KEYS[1] local baseKey = cacheKey +local orgId = ARGV[1] +local env = ARGV[2] -- Get base customer JSON local baseJson = redis.call("GET", baseKey) @@ -13,6 +18,7 @@ end local baseCustomer = cjson.decode(baseJson) local featureIds = baseCustomer._featureIds or {} +local entityIds = baseCustomer._entityIds or {} -- Build features object local features = {} @@ -126,8 +132,243 @@ for _, featureId in ipairs(featureIds) do features[featureId] = featureData end +-- ============================================================================ +-- FETCH AND MERGE ENTITY FEATURES +-- ============================================================================ + +-- Fetch all entity features and aggregate balances +local entityFeatureData = {} -- {[entityId][featureId] = featureData} + +for _, entityId in ipairs(entityIds) do + local entityCacheKey = orgId .. ":" .. env .. ":entity:" .. entityId + local entityBaseJson = redis.call("GET", entityCacheKey) + + if entityBaseJson then + local entityBase = cjson.decode(entityBaseJson) + local entityFeatureIds = entityBase._featureIds or {} + entityFeatureData[entityId] = {} + + for _, featureId in ipairs(entityFeatureIds) do + local entityFeatureKey = entityCacheKey .. ":features:" .. featureId + local entityFeatureHash = redis.call("HGETALL", entityFeatureKey) + + if #entityFeatureHash > 0 then + -- Parse entity feature + local entityFeature = {} + for i = 1, #entityFeatureHash, 2 do + local key = entityFeatureHash[i] + local value = entityFeatureHash[i + 1] + + if key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then + entityFeature[key] = tonumber(value) + elseif key == "unlimited" or key == "overage_allowed" then + entityFeature[key] = (value == "true") + elseif value == "null" then + entityFeature[key] = cjson.null + else + entityFeature[key] = value + end + end + + -- Fetch breakdown items for this entity feature + local breakdownCount = entityFeature._breakdown_count or 0 + entityFeature._breakdown_count = nil + entityFeature.breakdowns = {} + + for i = 0, breakdownCount - 1 do + local breakdownKey = entityFeatureKey .. ":breakdown:" .. i + local breakdownHash = redis.call("HGETALL", breakdownKey) + + if #breakdownHash > 0 then + local breakdownData = {} + for j = 1, #breakdownHash, 2 do + local key = breakdownHash[j] + local value = breakdownHash[j + 1] + + if key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then + breakdownData[key] = tonumber(value) + elseif key == "overage_allowed" then + breakdownData[key] = (value == "true") + elseif value == "null" then + breakdownData[key] = cjson.null + else + breakdownData[key] = value + end + end + table.insert(entityFeature.breakdowns, breakdownData) + end + end + + -- Fetch rollover items for this entity feature + local rolloverCount = entityFeature._rollover_count or 0 + entityFeature._rollover_count = nil + entityFeature.rollovers = {} + + for i = 0, rolloverCount - 1 do + local rolloverKey = entityFeatureKey .. ":rollover:" .. i + local rolloverHash = redis.call("HGETALL", rolloverKey) + + if #rolloverHash > 0 then + local rolloverData = {} + for j = 1, #rolloverHash, 2 do + local key = rolloverHash[j] + local value = rolloverHash[j + 1] + + if key == "balance" or key == "expires_at" then + rolloverData[key] = tonumber(value) + elseif value == "null" then + rolloverData[key] = cjson.null + else + rolloverData[key] = value + end + end + table.insert(entityFeature.rollovers, rolloverData) + end + end + + entityFeatureData[entityId][featureId] = entityFeature + end + end + end +end + +-- ============================================================================ +-- MERGE ENTITY BALANCES INTO CUSTOMER FEATURES +-- ============================================================================ + +for featureId, customerFeature in pairs(features) do + -- Skip if unlimited + if not customerFeature.unlimited then + -- Aggregate entity balances for this feature + local entityTotalBalance = 0 + local entityTotalUsage = 0 + local entityTotalIncludedUsage = 0 + local entityTotalUsageLimit = 0 + + for entityId, entityFeatures in pairs(entityFeatureData) do + local entityFeature = entityFeatures[featureId] + if entityFeature then + entityTotalBalance = entityTotalBalance + (entityFeature.balance or 0) + entityTotalUsage = entityTotalUsage + (entityFeature.usage or 0) + entityTotalIncludedUsage = entityTotalIncludedUsage + (entityFeature.included_usage or 0) + entityTotalUsageLimit = entityTotalUsageLimit + (entityFeature.usage_limit or 0) + end + end + + -- Merge top-level balance and usage + customerFeature.balance = (customerFeature.balance or 0) + entityTotalBalance + customerFeature.usage = (customerFeature.usage or 0) + entityTotalUsage + customerFeature.included_usage = (customerFeature.included_usage or 0) + entityTotalIncludedUsage + customerFeature.usage_limit = (customerFeature.usage_limit or 0) + entityTotalUsageLimit + + -- Merge breakdown balances and usage + if customerFeature.breakdown and #customerFeature.breakdown > 0 then + for i, breakdown in ipairs(customerFeature.breakdown) do + local entityBreakdownBalance = 0 + local entityBreakdownUsage = 0 + local entityBreakdownIncludedUsage = 0 + local entityBreakdownUsageLimit = 0 + + for entityId, entityFeatures in pairs(entityFeatureData) do + local entityFeature = entityFeatures[featureId] + if entityFeature and entityFeature.breakdowns and entityFeature.breakdowns[i] then + entityBreakdownBalance = entityBreakdownBalance + (entityFeature.breakdowns[i].balance or 0) + entityBreakdownUsage = entityBreakdownUsage + (entityFeature.breakdowns[i].usage or 0) + entityBreakdownIncludedUsage = entityBreakdownIncludedUsage + (entityFeature.breakdowns[i].included_usage or 0) + entityBreakdownUsageLimit = entityBreakdownUsageLimit + (entityFeature.breakdowns[i].usage_limit or 0) + end + end + + breakdown.balance = (breakdown.balance or 0) + entityBreakdownBalance + breakdown.usage = (breakdown.usage or 0) + entityBreakdownUsage + breakdown.included_usage = (breakdown.included_usage or 0) + entityBreakdownIncludedUsage + breakdown.usage_limit = (breakdown.usage_limit or 0) + entityBreakdownUsageLimit + end + end + + -- Merge rollover balances + if customerFeature.rollovers and #customerFeature.rollovers > 0 then + for i, rollover in ipairs(customerFeature.rollovers) do + local entityRolloverBalance = 0 + + for entityId, entityFeatures in pairs(entityFeatureData) do + local entityFeature = entityFeatures[featureId] + if entityFeature and entityFeature.rollovers and entityFeature.rollovers[i] then + entityRolloverBalance = entityRolloverBalance + (entityFeature.rollovers[i].balance or 0) + end + end + + rollover.balance = (rollover.balance or 0) + entityRolloverBalance + end + end + end +end + +-- Add entity-only features (features that exist in entities but not in customer) +for entityId, entityFeatures in pairs(entityFeatureData) do + for featureId, entityFeature in pairs(entityFeatures) do + if not features[featureId] then + -- This feature doesn't exist in customer, add it + -- Initialize with zero balance, then we'll aggregate all entity balances + if not features[featureId] then + features[featureId] = { + id = entityFeature.id, + type = entityFeature.type, + name = entityFeature.name, + interval = entityFeature.interval, + interval_count = entityFeature.interval_count, + unlimited = entityFeature.unlimited, + balance = 0, + usage = 0, + included_usage = 0, + next_reset_at = cjson.null, + overage_allowed = entityFeature.overage_allowed, + usage_limit = entityFeature.usage_limit, + credit_schema = entityFeature.credit_schema + } + end + end + end +end + +-- Now aggregate balances for entity-only features +for featureId, customerFeature in pairs(features) do + -- Only process if this was an entity-only feature (balance is still 0 from initialization) + if customerFeature.balance == 0 and customerFeature.usage == 0 then + local entityTotalBalance = 0 + local entityTotalUsage = 0 + local entityTotalIncludedUsage = 0 + local entityTotalUsageLimit = 0 + local minNextResetAt = nil + + for entityId, entityFeatures in pairs(entityFeatureData) do + local entityFeature = entityFeatures[featureId] + if entityFeature then + entityTotalBalance = entityTotalBalance + (entityFeature.balance or 0) + entityTotalUsage = entityTotalUsage + (entityFeature.usage or 0) + entityTotalIncludedUsage = entityTotalIncludedUsage + (entityFeature.included_usage or 0) + entityTotalUsageLimit = entityTotalUsageLimit + (entityFeature.usage_limit or 0) + + -- Find minimum next_reset_at across all entities + if entityFeature.next_reset_at then + if not minNextResetAt or entityFeature.next_reset_at < minNextResetAt then + minNextResetAt = entityFeature.next_reset_at + end + end + end + end + + customerFeature.balance = entityTotalBalance + customerFeature.usage = entityTotalUsage + customerFeature.included_usage = entityTotalIncludedUsage + customerFeature.usage_limit = entityTotalUsageLimit + customerFeature.next_reset_at = minNextResetAt or cjson.null + end +end + -- Build final customer object baseCustomer._featureIds = nil -- Remove tracking field +baseCustomer._entityIds = nil -- Remove tracking field baseCustomer.features = features return cjson.encode(baseCustomer) diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/refreshCachedApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusCacheUtils/refreshCachedApiCustomer.ts index d17acd6b2..f8b83e276 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/refreshCachedApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/refreshCachedApiCustomer.ts @@ -52,6 +52,8 @@ export const refreshCachedApiCustomer = async ({ 1, // number of keys cacheKey, // KEYS[1] JSON.stringify({ ...apiCustomer, legacyData }), // ARGV[1] + org.id, // ARGV[2] + env, // ARGV[3] ); return { diff --git a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCustomer.lua b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCustomer.lua index bf3350444..5ebcccaed 100644 --- a/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCustomer.lua +++ b/server/src/internal/customers/cusUtils/apiCusCacheUtils/setCustomer.lua @@ -1,10 +1,15 @@ -- setCustomer.lua -- Atomically stores a customer object with base data as JSON and features/breakdowns as HSETs +-- Separates master customer features from entity features -- KEYS[1]: cache key (e.g., "org_id:env:customer:customer_id") -- ARGV[1]: serialized customer data JSON string +-- ARGV[2]: org_id (for building entity cache keys) +-- ARGV[3]: env (for building entity cache keys) local cacheKey = KEYS[1] local customerDataJson = ARGV[1] +local orgId = ARGV[2] +local env = ARGV[3] -- Decode the customer data local customerData = cjson.decode(customerDataJson) @@ -17,8 +22,19 @@ if customerData.features then end end --- Store feature IDs in the base data for retrieval +-- Extract entity IDs from entities array +local entityIds = {} +if customerData.entities then + for _, entity in ipairs(customerData.entities) do + if entity.id then + table.insert(entityIds, entity.id) + end + end +end + +-- Store feature IDs and entity IDs in the base data for retrieval customerData._featureIds = featureIds +customerData._entityIds = entityIds -- Build base customer object (everything except features) local baseCustomer = { @@ -33,7 +49,9 @@ local baseCustomer = { products = customerData.products, invoices = customerData.invoices, legacyData = customerData.legacyData, - _featureIds = featureIds + entities = customerData.entities, + _featureIds = featureIds, + _entityIds = entityIds } -- Store base customer as JSON diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/cusEntsToEntityBreakdown.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/cusEntsToEntityBreakdown.ts new file mode 100644 index 000000000..c7c4902a1 --- /dev/null +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/cusEntsToEntityBreakdown.ts @@ -0,0 +1,71 @@ +import { + filterEntityProductCusEnts, + filterOutEntityCusEnts, + filterPerEntityCusEnts, + getCusEntBalance, + sumValues, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type { FullCustomer } from "../../../../../../../shared/models/cusModels/fullCusModel.js"; +import type { FullCusEntWithFullCusProduct } from "../../../../../../../shared/models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { RequestContext } from "../../../../../honoUtils/HonoEnv.js"; + +export const cusEntsToEntityBreakdown = ({ + ctx, + fullCus, + cusEnts, +}: { + ctx: RequestContext; + cusEnts: FullCusEntWithFullCusProduct[]; + fullCus: FullCustomer; +}) => { + if (fullCus.entity) return undefined; // We don't need to show entity breakdown for a single entity. + // Entity breakdown. + + const masterBalance = sumValues( + filterOutEntityCusEnts({ cusEnts }).map((ce) => { + const { balance } = getCusEntBalance({ + cusEnt: ce, + }); + return balance; + }), + ); + + const entityBalances: Record = {}; + const perEntityCusEnts = filterPerEntityCusEnts({ cusEnts }); + + for (const cusEnt of perEntityCusEnts) { + for (const entityId in cusEnt.entities) { + if (!entityBalances[entityId]) { + entityBalances[entityId] = 0; + } + entityBalances[entityId] = new Decimal(entityBalances[entityId]) + .add(cusEnt.entities[entityId].balance) + .toNumber(); + } + } + + const entityProductCusEnts = filterEntityProductCusEnts({ cusEnts }); + for (const cusEnt of entityProductCusEnts) { + const entityId = + fullCus.entities.find( + (e) => e.internal_id === cusEnt.customer_product?.internal_entity_id, + )?.id || cusEnt.customer_product?.entity_id; + + if (!entityId) continue; + + if (!entityBalances[entityId]) { + entityBalances[entityId] = 0; + } + entityBalances[entityId] = new Decimal(entityBalances[entityId]) + .add(cusEnt.balance ?? 0) + .toNumber(); + } + + if (Object.keys(entityBalances).length === 0) return undefined; + + return { + master: masterBalance, + entities: sumValues(Object.values(entityBalances)), + }; +}; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts index 52ad99259..87c830ad9 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeature.ts @@ -133,7 +133,7 @@ export const getApiCusFeature = ({ const nextResetAt = cusEntsToNextResetAt({ cusEnts }); const totalUsageLimit = sumValues( - cusEnts.map((cusEnt) => cusEntToUsageLimit({ cusEnt })), + cusEnts.map((cusEnt) => cusEntToUsageLimit({ cusEnt, entityId })), ); const totalIncludedUsage = sumValues( diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeatures.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeatures.ts index 2c03563ad..76a5d3542 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeatures.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCusFeature/getApiCusFeatures.ts @@ -8,7 +8,7 @@ import { import type { RequestContext } from "@/honoUtils/HonoEnv.js"; import { getApiCusFeature } from "./getApiCusFeature.js"; -export const getApiCusFeaturesObject = async ({ +export const getApiCusFeatures = async ({ ctx, fullCus, }: { @@ -22,6 +22,7 @@ export const getApiCusFeaturesObject = async ({ inStatuses: org.config.include_past_due ? [CusProductStatus.Active, CusProductStatus.PastDue] : [CusProductStatus.Active], + entity: fullCus.entity, }); const featureToCusEnt: Record = {}; @@ -52,13 +53,3 @@ export const getApiCusFeaturesObject = async ({ return apiCusFeatures; }; - -export const getApiCusFeatures = async ({ - ctx, - fullCus, -}: { - ctx: RequestContext; - fullCus: FullCustomer; -}) => { - return getApiCusFeaturesObject({ ctx, fullCus }); -}; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts index f6d7962d2..6529d164d 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomer.ts @@ -29,6 +29,14 @@ export const getApiCustomer = async ({ skipCache?: boolean; }) => { // Get base customer (cacheable or direct from DB) + // await redis.del( + // buildCachedApiCustomerKey({ + // customerId: customerId || "", + // orgId: ctx.org.id, + // env: ctx.env, + // }), + // ); + const { apiCustomer: baseCustomer, legacyData: cusLegacyData } = await getCachedApiCustomer({ ctx, diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index a44697f32..7d595ab82 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -1,6 +1,5 @@ -import { AppEnv } from "@autumn/shared"; -import { CusProductStatus } from "@autumn/shared"; -import { sql, SQL } from "drizzle-orm"; +import type { AppEnv, CusProductStatus } from "@autumn/shared"; +import { type SQL, sql } from "drizzle-orm"; const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { const withStatusFilter = () => { @@ -91,9 +90,12 @@ const buildEntitiesCTE = (withEntities: boolean) => { json_agg(row_to_json(e) ORDER BY e.internal_id DESC), '[]'::json ) AS entities - FROM entities e - WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record) - LIMIT 100 + FROM ( + SELECT * FROM entities e + WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record) + ORDER BY e.internal_id DESC + LIMIT 1000 + ) e ) `; }; @@ -171,7 +173,7 @@ const buildSubscriptionsCTE = ( }; const buildInvoicesCTE = (hasEntityCTE: boolean) => { - let entityFilter = hasEntityCTE + const entityFilter = hasEntityCTE ? sql`AND ( NOT EXISTS (SELECT 1 FROM entity_record) OR i.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1) diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts index bbb6fd51a..1149f8d64 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.ts @@ -1,8 +1,14 @@ -import { type ApiEntity, ApiEntitySchema, type AppEnv } from "@autumn/shared"; +import { + type ApiEntity, + ApiEntitySchema, + type AppEnv, + filterEntityLevelCusProducts, +} from "@autumn/shared"; 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 { normalizeCachedData } from "@/utils/cacheUtils/cacheUtils.js"; import { getApiEntityBase } from "../apiEntityUtils/getApiEntityBase.js"; import { GET_ENTITY_SCRIPT, SET_ENTITY_SCRIPT } from "./luaScripts.js"; @@ -44,6 +50,8 @@ export const getCachedApiEntity = async ({ env, }); + // await redis.del(cacheKey); + // Try to get from cache using Lua script (unless skipCache is true) if (!skipCache) { const cachedResult = await redis.eval( @@ -54,7 +62,9 @@ export const getCachedApiEntity = async ({ // If found in cache, parse and return if (cachedResult) { - const cached = JSON.parse(cachedResult as string) as ApiEntity; + const cached = normalizeCachedData( + JSON.parse(cachedResult as string) as ApiEntity, + ); return { apiEntity: ApiEntitySchema.parse({ @@ -82,16 +92,22 @@ export const getCachedApiEntity = async ({ throw new Error(`Entity ${entityId} not found`); } - // Build ApiEntity (base only, no expand) - const { apiEntity } = await getApiEntityBase({ - ctx, - entity, - fullCus, - withAutumnId: !skipCache, - }); - // Store in cache (only if not skipping cache) if (!skipCache) { + // Build ApiEntity (base only, no expand) + const entityCusProducts = filterEntityLevelCusProducts({ + cusProducts: fullCus.customer_products, + }); + const { apiEntity } = await getApiEntityBase({ + ctx, + entity, + fullCus: { + ...fullCus, + customer_products: entityCusProducts, + }, + withAutumnId: !skipCache, + }); + await redis.eval( SET_ENTITY_SCRIPT, 1, // number of keys @@ -100,6 +116,14 @@ export const getCachedApiEntity = async ({ ); } + // Build ApiEntity (base only, no expand) + const { apiEntity } = await getApiEntityBase({ + ctx, + entity, + fullCus: fullCus, + withAutumnId: !skipCache, + }); + return { apiEntity: ApiEntitySchema.parse({ ...apiEntity, diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getEntity.lua b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getEntity.lua index bae0f225b..73e6c2aa9 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getEntity.lua +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/getEntity.lua @@ -1,5 +1,6 @@ -- getEntity.lua -- Atomically retrieves an entity object from Redis, reconstructing from base JSON and feature HSETs +-- Merges entity features with customer features -- KEYS[1]: cache key (e.g., "org_id:env:entity:entity_id") local cacheKey = KEYS[1] @@ -12,12 +13,22 @@ if not baseJson then end local baseEntity = cjson.decode(baseJson) -local featureIds = baseEntity._featureIds or {} +local entityFeatureIds = baseEntity._featureIds or {} --- Build features object -local features = {} +-- Extract orgId and env from cache key (format: "orgId:env:entity:entityId") +local keyParts = {} +for part in string.gmatch(cacheKey, "[^:]+") do + table.insert(keyParts, part) +end +local orgId = keyParts[1] +local env = keyParts[2] -for _, featureId in ipairs(featureIds) do +-- ============================================================================ +-- FETCH ENTITY FEATURES +-- ============================================================================ +local entityFeatures = {} + +for _, featureId in ipairs(entityFeatureIds) do local featureKey = cacheKey .. ":features:" .. featureId local featureHash = redis.call("HGETALL", featureKey) @@ -123,12 +134,194 @@ for _, featureId in ipairs(featureIds) do featureData.breakdown = breakdown end - features[featureId] = featureData + entityFeatures[featureId] = featureData +end + +-- ============================================================================ +-- FETCH CUSTOMER MASTER FEATURES (no entity aggregation) +-- ============================================================================ +local customerFeatures = {} +local customerId = baseEntity.customer_id + +if customerId then + local customerCacheKey = orgId .. ":" .. env .. ":customer:" .. customerId + local customerBaseJson = redis.call("GET", customerCacheKey) + + if customerBaseJson then + local customerBase = cjson.decode(customerBaseJson) + local customerFeatureIds = customerBase._featureIds or {} + + for _, featureId in ipairs(customerFeatureIds) do + local customerFeatureKey = customerCacheKey .. ":features:" .. featureId + local customerFeatureHash = redis.call("HGETALL", customerFeatureKey) + + if #customerFeatureHash > 0 then + -- Parse customer feature + local customerFeature = {} + for i = 1, #customerFeatureHash, 2 do + local key = customerFeatureHash[i] + local value = customerFeatureHash[i + 1] + + if key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" or key == "_breakdown_count" or key == "_rollover_count" then + customerFeature[key] = tonumber(value) + elseif key == "unlimited" or key == "overage_allowed" then + customerFeature[key] = (value == "true") + elseif key == "credit_schema" then + if value ~= "null" and value ~= "" then + customerFeature[key] = cjson.decode(value) + else + customerFeature[key] = cjson.null + end + elseif value == "null" then + customerFeature[key] = cjson.null + else + customerFeature[key] = value + end + end + + -- Fetch rollover items + local rolloverCount = customerFeature._rollover_count or 0 + customerFeature._rollover_count = nil + local rollovers = {} + + for i = 0, rolloverCount - 1 do + local rolloverKey = customerFeatureKey .. ":rollover:" .. i + local rolloverHash = redis.call("HGETALL", rolloverKey) + + if #rolloverHash > 0 then + local rolloverData = {} + for j = 1, #rolloverHash, 2 do + local key = rolloverHash[j] + local value = rolloverHash[j + 1] + + if key == "balance" or key == "expires_at" then + rolloverData[key] = tonumber(value) + elseif value == "null" then + rolloverData[key] = cjson.null + else + rolloverData[key] = value + end + end + table.insert(rollovers, rolloverData) + end + end + + if #rollovers > 0 then + customerFeature.rollovers = rollovers + end + + -- Fetch breakdown items + local breakdownCount = customerFeature._breakdown_count or 0 + customerFeature._breakdown_count = nil + local breakdown = {} + + for i = 0, breakdownCount - 1 do + local breakdownKey = customerFeatureKey .. ":breakdown:" .. i + local breakdownHash = redis.call("HGETALL", breakdownKey) + + if #breakdownHash > 0 then + local breakdownData = {} + for j = 1, #breakdownHash, 2 do + local key = breakdownHash[j] + local value = breakdownHash[j + 1] + + if key == "balance" or key == "usage" or key == "included_usage" or key == "usage_limit" or key == "interval_count" or key == "next_reset_at" then + breakdownData[key] = tonumber(value) + elseif key == "overage_allowed" then + breakdownData[key] = (value == "true") + elseif value == "null" then + breakdownData[key] = cjson.null + else + breakdownData[key] = value + end + end + table.insert(breakdown, breakdownData) + end + end + + if #breakdown > 0 then + customerFeature.breakdown = breakdown + end + + customerFeatures[featureId] = customerFeature + end + end + end +end + +-- ============================================================================ +-- MERGE CUSTOMER AND ENTITY FEATURES +-- ============================================================================ +local mergedFeatures = {} + +-- First, add all customer features (inherited) +for featureId, customerFeature in pairs(customerFeatures) do + mergedFeatures[featureId] = customerFeature +end + +-- Then, merge or add entity features +for featureId, entityFeature in pairs(entityFeatures) do + local customerFeature = customerFeatures[featureId] + + if customerFeature then + -- Both customer and entity have this feature - merge balances + if not entityFeature.unlimited and not customerFeature.unlimited then + entityFeature.balance = (entityFeature.balance or 0) + (customerFeature.balance or 0) + entityFeature.usage = (entityFeature.usage or 0) + (customerFeature.usage or 0) + entityFeature.included_usage = (entityFeature.included_usage or 0) + (customerFeature.included_usage or 0) + entityFeature.usage_limit = (entityFeature.usage_limit or 0) + (customerFeature.usage_limit or 0) + + -- Use minimum next_reset_at (earliest reset time) + if entityFeature.next_reset_at and customerFeature.next_reset_at then + if customerFeature.next_reset_at < entityFeature.next_reset_at then + entityFeature.next_reset_at = customerFeature.next_reset_at + end + elseif customerFeature.next_reset_at then + entityFeature.next_reset_at = customerFeature.next_reset_at + end + + -- Merge breakdown balances + if entityFeature.breakdown and customerFeature.breakdown then + for i, entityBreakdown in ipairs(entityFeature.breakdown) do + local customerBreakdown = customerFeature.breakdown[i] + if customerBreakdown then + entityBreakdown.balance = (entityBreakdown.balance or 0) + (customerBreakdown.balance or 0) + entityBreakdown.usage = (entityBreakdown.usage or 0) + (customerBreakdown.usage or 0) + entityBreakdown.included_usage = (entityBreakdown.included_usage or 0) + (customerBreakdown.included_usage or 0) + entityBreakdown.usage_limit = (entityBreakdown.usage_limit or 0) + (customerBreakdown.usage_limit or 0) + + -- Use minimum next_reset_at for breakdown + if entityBreakdown.next_reset_at and customerBreakdown.next_reset_at then + if customerBreakdown.next_reset_at < entityBreakdown.next_reset_at then + entityBreakdown.next_reset_at = customerBreakdown.next_reset_at + end + elseif customerBreakdown.next_reset_at then + entityBreakdown.next_reset_at = customerBreakdown.next_reset_at + end + end + end + end + + -- Merge rollover balances + if entityFeature.rollovers and customerFeature.rollovers then + for i, entityRollover in ipairs(entityFeature.rollovers) do + local customerRollover = customerFeature.rollovers[i] + if customerRollover then + entityRollover.balance = (entityRollover.balance or 0) + (customerRollover.balance or 0) + end + end + end + end + mergedFeatures[featureId] = entityFeature + else + -- Only entity has this feature - use entity's feature + mergedFeatures[featureId] = entityFeature + end end -- Build final entity object baseEntity._featureIds = nil -- Remove tracking field -baseEntity.features = features +baseEntity.features = mergedFeatures return cjson.encode(baseEntity) diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/luaScripts.ts b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/luaScripts.ts index c4e0ab67a..ab95e10af 100644 --- a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/luaScripts.ts +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/luaScripts.ts @@ -15,3 +15,8 @@ export const SET_ENTITY_SCRIPT = readFileSync( join(__dirname, "setEntity.lua"), "utf-8", ); + +export const SET_ENTITIES_BATCH_SCRIPT = readFileSync( + join(__dirname, "setEntitiesBatch.lua"), + "utf-8", +); diff --git a/server/src/internal/entities/entityUtils/apiEntityCacheUtils/setEntitiesBatch.lua b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/setEntitiesBatch.lua new file mode 100644 index 000000000..8bb081d7a --- /dev/null +++ b/server/src/internal/entities/entityUtils/apiEntityCacheUtils/setEntitiesBatch.lua @@ -0,0 +1,129 @@ +-- setEntitiesBatch.lua +-- Atomically stores multiple entity objects in a single call +-- KEYS: none (we'll build keys dynamically) +-- ARGV[1]: JSON array of entity data objects: [{entityId: "...", entityData: {...}}, ...] +-- ARGV[2]: org_id +-- ARGV[3]: env + +local entitiesJson = ARGV[1] +local orgId = ARGV[2] +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 + local entityData = entityWrapper.entityData + + -- Build cache key for this entity + local cacheKey = orgId .. ":" .. env .. ":entity:" .. entityId + + -- Extract feature IDs for tracking + local featureIds = {} + if entityData.features then + for featureId, _ in pairs(entityData.features) do + table.insert(featureIds, featureId) + end + end + + -- Build base entity object (everything except features) + local baseEntity = { + id = entityData.id, + name = entityData.name, + customer_id = entityData.customer_id, + created_at = entityData.created_at, + env = entityData.env, + products = entityData.products, + _featureIds = featureIds + } + + -- Store base entity as JSON + redis.call("SET", cacheKey, cjson.encode(baseEntity)) + + -- 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 + 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) + ) + + -- Store each rollover item as separate HSET (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) + ) + end + end + + -- Store each breakdown item as separate HSET (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) + ) + end + end + end + end +end + +return "OK" + diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts index 96f29defc..9e7a85b79 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntity.ts @@ -1,8 +1,4 @@ -import { - type ApiEntity, - type EntityExpand, - type FullCustomer, -} from "@autumn/shared"; +import type { ApiEntity, EntityExpand, FullCustomer } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { getCachedApiEntity } from "../apiEntityCacheUtils/getCachedApiEntity.js"; import { getApiEntityExpand } from "./getApiEntityExpand.js"; diff --git a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts index 6c3ec1bfe..687691dc6 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/handleCreateEntity2.ts @@ -12,7 +12,6 @@ import { createRoute } from "../../../../honoMiddlewares/routeHandler.js"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; import type { ExtendedRequest } from "../../../../utils/models/Request.js"; import { EntityService } from "../../../api/entities/EntityService.js"; -import { getApiEntity } from "../../entityUtils/apiEntityUtils/getApiEntity.js"; import { constructEntity } from "../../entityUtils/entityUtils.js"; import { createEntityForCusProduct } from "./createEntityForCusProduct.js"; import { validateAndGetInputEntities } from "./getInputEntities.js"; @@ -90,24 +89,25 @@ export const createEntities = async ({ newEntities.push(...insertedEntities); - // Get api entity for each entity... - const apiEntities = []; - for (const entity of newEntities) { - // Cloned fullCus - const clonedFullCus = structuredClone(fullCus); - clonedFullCus.entity = entity; - const apiEntity = await getApiEntity({ - ctx, - expand: [], - customerId, - entityId: entity.id, - fullCus: clonedFullCus, - withAutumnId, - }); - apiEntities.push(apiEntity); - } + // // Get api entity for each entity... + // const apiEntities = []; + // for (const entity of newEntities) { + // // Cloned fullCus + // const clonedFullCus = structuredClone(fullCus); + // clonedFullCus.entity = entity; + // const apiEntity = await getApiEntity({ + // ctx, + // expand: [], + // customerId, + // entityId: entity.id, + // fullCus: clonedFullCus, + // withAutumnId, + // }); + // apiEntities.push(apiEntity); + // } + return newEntities; - return apiEntities; + // return apiEntities; }; export const handleCreateEntity = createRoute({ diff --git a/server/src/internal/entities/handlers/handleGetEntity.ts b/server/src/internal/entities/handlers/handleGetEntity.ts index 4b61d3084..3a1dc865f 100644 --- a/server/src/internal/entities/handlers/handleGetEntity.ts +++ b/server/src/internal/entities/handlers/handleGetEntity.ts @@ -7,13 +7,14 @@ export const handleGetEntity = createRoute({ handler: async (c) => { const { customer_id, entity_id } = c.req.param(); const ctx = c.get("ctx"); - const { expand } = c.req.valid("query"); + const { expand, skip_cache } = c.req.valid("query"); const apiEntity = await getApiEntity({ ctx, customerId: customer_id, entityId: entity_id, expand, + skipCache: skip_cache, }); return c.json(apiEntity); diff --git a/server/src/utils/cacheUtils/cacheUtils.ts b/server/src/utils/cacheUtils/cacheUtils.ts new file mode 100644 index 000000000..e528d8c30 --- /dev/null +++ b/server/src/utils/cacheUtils/cacheUtils.ts @@ -0,0 +1,50 @@ +import type { ApiCustomer, ApiEntity } from "@autumn/shared"; + +/** + * Fix Lua cjson quirks when parsing cached data: + * - Converts products[].items from {} back to [] if it's an empty object + * - Converts usage_limit: 0 to undefined (when all sources were undefined) + */ +export const normalizeCachedData = ( + data: T, +): T => { + if (data.products) { + for (const product of data.products) { + if ( + product.items && + typeof product.items === "object" && + !Array.isArray(product.items) && + Object.keys(product.items).length === 0 + ) { + product.items = []; + } + } + } + + // Fix usage_limit: 0 -> undefined + // Fix missing credit_schema -> null + if (data.features) { + for (const featureId in data.features) { + const feature = data.features[featureId]; + if (feature.usage_limit === 0) { + feature.usage_limit = undefined; + } + + // Ensure credit_schema is null if undefined (for consistent schema) + if (feature.credit_schema === null) { + feature.credit_schema = undefined; + } + + // Fix breakdown usage_limit + if (feature.breakdown) { + for (const breakdown of feature.breakdown) { + if (breakdown.usage_limit === 0) { + breakdown.usage_limit = undefined; + } + } + } + } + } + + return data; +}; 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 b67d2e9e0..70583357d 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 @@ -29,17 +29,17 @@ describe(`${chalk.yellowBright("track-entity-balances1: basic entity cache test" const entities = [ { - id: "user-1", + id: "track-entity-balances1-user-1", name: "User 1", feature_id: TestFeature.Users, }, { - id: "user-2", + id: "track-entity-balances1-user-2", name: "User 2", feature_id: TestFeature.Users, }, { - id: "user-3", + id: "track-entity-balances1-user-3", name: "User 3", feature_id: TestFeature.Users, }, @@ -106,4 +106,35 @@ describe(`${chalk.yellowBright("track-entity-balances1: basic entity cache test" }); } }); + + test("verify database state matches cache", async () => { + // Wait for database sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customerId, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customerId); + + // Customer should match + expect(customerFromDb.features[TestFeature.Dashboard]).toEqual( + customerFromCache.features[TestFeature.Dashboard], + ); + + // All entities should match + for (const entity of entities) { + const entityFromDb = await autumnV1.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const entityFromCache = await autumnV1.entities.get( + customerId, + entity.id, + ); + + expect(entityFromDb.features[TestFeature.Dashboard]).toEqual( + entityFromCache.features[TestFeature.Dashboard], + ); + } + }); }); 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 bec1a1238..d77500497 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 @@ -11,8 +11,7 @@ import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js" const messagesItem = constructFeatureItem({ featureId: TestFeature.Messages, - includedUsage: 100, - entityFeatureId: TestFeature.Users, + includedUsage: 300, }); const freeProd = constructProduct({ @@ -29,17 +28,17 @@ describe(`${chalk.yellowBright("track-entity-balances2: customer-level tracking const entities = [ { - id: "user-1", + id: "track-entity-balances2-user-1", name: "User 1", feature_id: TestFeature.Users, }, { - id: "user-2", + id: "track-entity-balances2-user-2", name: "User 2", feature_id: TestFeature.Users, }, { - id: "user-3", + id: "track-entity-balances2-user-3", name: "User 3", feature_id: TestFeature.Users, }, @@ -72,14 +71,19 @@ describe(`${chalk.yellowBright("track-entity-balances2: customer-level tracking } }); - test("customer should have initial balance of 300 messages", async () => { + test("customer and entities should have initial balance of 300 messages", async () => { const customer = await autumnV1.customers.get(customerId); const balance = customer.features[TestFeature.Messages].balance; expect(balance).toBe(300); + + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + expect(fetchedEntity.features[TestFeature.Messages].balance).toBe(300); + } }); - test("should track 10 messages at customer level", async () => { + test("should track 50 messages at customer level", async () => { await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, @@ -93,7 +97,6 @@ describe(`${chalk.yellowBright("track-entity-balances2: customer-level tracking expect(balance).toBe(250); expect(usage).toBe(50); }); - return; test("all entities should reflect customer-level deduction", async () => { // When customer tracks, all entity caches should be synced to show the same balance @@ -102,32 +105,41 @@ describe(`${chalk.yellowBright("track-entity-balances2: customer-level tracking const balance = fetchedEntity.features[TestFeature.Messages].balance; // All entities should see the customer's updated balance (90) - expect(balance).toBe(90); + expect(balance).toBe(250); } }); - test("should track 5 more messages at customer level", async () => { - await autumnV1.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 5, + test("verify database state matches cache after customer-level tracking", async () => { + // Wait for database sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customerId, { + skip_cache: "true", }); + const customerFromCache = await autumnV1.customers.get(customerId); - const customer = await autumnV1.customers.get(customerId); - const balance = customer.features[TestFeature.Messages].balance; - const usage = customer.features[TestFeature.Messages].usage; + // Customer balance and usage should match + expect(customerFromDb.features[TestFeature.Messages].balance).toBe(250); + expect(customerFromDb.features[TestFeature.Messages].usage).toBe(50); + expect(customerFromDb.features[TestFeature.Messages]).toEqual( + customerFromCache.features[TestFeature.Messages], + ); - expect(balance).toBe(85); - expect(usage).toBe(15); - }); - - test("all entities should reflect second customer-level deduction", async () => { + // All entities should match for (const entity of entities) { - const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); - const balance = fetchedEntity.features[TestFeature.Messages].balance; + const entityFromDb = await autumnV1.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const entityFromCache = await autumnV1.entities.get( + customerId, + entity.id, + ); - // All entities should see the customer's updated balance (85) - expect(balance).toBe(85); + expect(entityFromDb.features[TestFeature.Messages].balance).toBe(250); + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); } }); }); 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 1fcadbf16..798c086bf 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 @@ -29,17 +29,17 @@ describe(`${chalk.yellowBright("track-entity-balances3: per-entity balance track const entities = [ { - id: "user-1", + id: "track-entity-balances3-user-1", name: "User 1", feature_id: TestFeature.Users, }, { - id: "user-2", + id: "track-entity-balances3-user-2", name: "User 2", feature_id: TestFeature.Users, }, { - id: "user-3", + id: "track-entity-balances3-user-3", name: "User 3", feature_id: TestFeature.Users, }, @@ -74,6 +74,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; // 3 entities ร— 100 messages each = 300 total @@ -140,4 +141,49 @@ describe(`${chalk.yellowBright("track-entity-balances3: per-entity balance track } expect(totalEntityBalance).toBe(260); }); + + test("verify database state matches cache after per-entity and customer-level tracking", async () => { + // Wait for database sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customerId, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customerId); + + // Customer balance should be 260 (started at 300, deducted 30 for entity tracking + 10 for customer tracking) + expect(customerFromDb.features[TestFeature.Messages].balance).toBe(260); + expect(customerFromDb.features[TestFeature.Messages]).toMatchObject( + customerFromCache.features[TestFeature.Messages], + ); + + // Verify each entity's balance + let totalEntityBalanceFromDb = 0; + let totalEntityBalanceFromCache = 0; + + for (const entity of entities) { + const entityFromDb = await autumnV1.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const entityFromCache = await autumnV1.entities.get( + customerId, + entity.id, + ); + + // Each entity should have some messages deducted + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); + + totalEntityBalanceFromDb += + entityFromDb.features[TestFeature.Messages].balance; + totalEntityBalanceFromCache += + entityFromCache.features[TestFeature.Messages].balance; + } + + // Sum of entity balances should be 260 + expect(totalEntityBalanceFromDb).toBe(260); + expect(totalEntityBalanceFromCache).toBe(260); + }); }); 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 new file mode 100644 index 000000000..44cf2bdd7 --- /dev/null +++ b/server/tests/balances/track/entity-balances/track-entity-balances4.test.ts @@ -0,0 +1,206 @@ +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 { 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"; + +const lifetimeMessagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 50, + entityFeatureId: TestFeature.Users, +}) as LimitedItem; + +const monthlyMessagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + interval: "month" as any, + intervalCount: 1, +}) as LimitedItem; + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [lifetimeMessagesItem, monthlyMessagesItem], +}); + +const testCase = "track-entity-balances4"; + +describe(`${chalk.yellowBright("track-entity-balances4: customer balance with entity balances")}`, () => { + const customerId = "track-entity-balances4"; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + const entities = [ + { + id: "track-entity-balances4-user-1", + name: "User 1", + feature_id: TestFeature.Users, + }, + { + id: "track-entity-balances4-user-2", + name: "User 2", + feature_id: TestFeature.Users, + }, + { + id: "track-entity-balances4-user-3", + name: "User 3", + feature_id: TestFeature.Users, + }, + ]; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: freeProd.id, + }); + + await autumnV1.entities.create(customerId, entities); + + // Initialize cache + for (const entity of entities) { + await autumnV1.entities.get(customerId, entity.id); + } + await autumnV1.customers.get(customerId); + }); + + test("should have correct customer / entity balances", async () => { + const customer = await autumnV1.customers.get(customerId, { + skip_cache: "true", + }); + + expect(customer.features[TestFeature.Messages].balance).toBe( + monthlyMessagesItem.included_usage + + lifetimeMessagesItem.included_usage * 3, + ); + + for (const entity of entities) { + const _entity = await autumnV1.entities.get(customerId, entity.id); + expect(_entity.features[TestFeature.Messages].balance).toBe( + lifetimeMessagesItem.included_usage + + monthlyMessagesItem.included_usage, + ); + } + }); + + test("should track 50 messages at customer level", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + const customer = await autumnV1.customers.get(customerId); + const balance = customer.features[TestFeature.Messages].balance; + const usage = customer.features[TestFeature.Messages].usage; + + expect(balance).toBe(200); + expect(usage).toBe(50); + }); + + test("all entities should reflect customer-level deduction", async () => { + // When customer tracks, all entity caches should be synced to show the same balance + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + const balance = fetchedEntity.features[TestFeature.Messages].balance; + + // All entities should see the customer's updated balance (90) + expect(balance).toBe(100); + } + }); + + // Should draw 50 from customer level monthly, then 10 from entity lifetime + test("track 60 messages at customer level -- draw from customer and entity simultaneously", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 60, + }); + + const customer = await autumnV1.customers.get(customerId); + const balance = customer.features[TestFeature.Messages].balance; + const usage = customer.features[TestFeature.Messages].usage; + + expect(balance).toBe(250 - 110); + expect(usage).toBe(110); + + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + const balance = fetchedEntity.features[TestFeature.Messages].balance; + + if (entity.id === "track-entity-balances4-user-1") { + expect(balance).toBe(40); + } else { + expect(balance).toBe(50); + } + } + }); + + test("track 10 messages each at entity level -- draw from entity level", async () => { + for (const entity of entities) { + await autumnV1.track({ + customer_id: customerId, + entity_id: entity.id, + feature_id: TestFeature.Messages, + value: 10, + }); + } + + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + const balance = fetchedEntity.features[TestFeature.Messages].balance; + + if (entity.id === "track-entity-balances4-user-1") { + expect(balance).toBe(30); + } else { + expect(balance).toBe(40); + } + } + }); + + test("verify database state matches cache after all tracking", async () => { + // Wait for database sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customerId, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customerId); + + // Customer features should match + expect(customerFromDb.features[TestFeature.Messages]).toEqual( + customerFromCache.features[TestFeature.Messages], + ); + + // All entities should match + for (const entity of entities) { + const entityFromDb = await autumnV1.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const entityFromCache = await autumnV1.entities.get( + customerId, + entity.id, + ); + + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); + } + }); +}); 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 new file mode 100644 index 000000000..d2e9f94fe --- /dev/null +++ b/server/tests/balances/track/entity-balances/track-entity-balances5.test.ts @@ -0,0 +1,389 @@ +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 { 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"; + +const testCase = "track-entity-balances5"; + +// Customer-level messages (monthly) - kept low so it dips into entity balance +const customerMessagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + interval: "month" as any, + intervalCount: 1, +}) as LimitedItem; + +// Entity-level messages (monthly, per entity) +const entityMessagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 5000, + entityFeatureId: TestFeature.Users, + interval: "month" as any, + intervalCount: 1, +}) as LimitedItem; + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [customerMessagesItem, entityMessagesItem], +}); + +const NUM_REQUESTS = 5000; +const NUM_CUSTOMERS = 1; +const NUM_ENTITIES = 2; + +// Helper to generate random decimal between min and max +const randomDecimal = (min: number, max: number): Decimal => { + const value = Math.random() * (max - min) + min; + return new Decimal(value).toDecimalPlaces(2); +}; + +// Helper to randomly choose an entity or null (for customer-level) +const randomEntityOrNull = (entities: { id: string }[]): string | null => { + // 50% chance customer-level, 50% chance entity-level + if (Math.random() < 0.5) { + return null; // Customer-level + } + // Randomly pick an entity + const randomIndex = Math.floor(Math.random() * entities.length); + return entities[randomIndex].id; +}; + +describe(`${chalk.yellowBright(`${testCase}: Concurrent per entity tracking`)}`, () => { + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + // Create multiple customers with their entities + const customers = Array.from({ length: NUM_CUSTOMERS }, (_, i) => { + const customerId = `${testCase}_customer${i + 1}`; + return { + id: customerId, + entities: Array.from({ length: NUM_ENTITIES }, (_, i) => ({ + id: `${customerId}_user${i + 1}`, + name: `User ${i + 1}`, + feature_id: TestFeature.Users, + })), + }; + }); + + // Track expected balances per customer + const expectedCustomerBalances: Record = {}; + const expectedEntityBalances: Record = {}; + + // Initialize expected balances + for (const customer of customers) { + expectedCustomerBalances[customer.id] = new Decimal(0); + for (const entity of customer.entities) { + expectedEntityBalances[entity.id] = new Decimal(0); + } + } + + beforeAll(async () => { + for (const customer of customers) { + await initCustomerV3({ + ctx, + customerId: customer.id, + withTestClock: false, + }); + } + // Initialize products once + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Initialize all customers + for (const customer of customers) { + await autumnV1.attach({ + customer_id: customer.id, + product_id: freeProd.id, + }); + + await autumnV1.entities.create(customer.id, customer.entities); + + // Initialize cache + for (const entity of customer.entities) { + await autumnV1.entities.get(customer.id, entity.id); + } + await autumnV1.customers.get(customer.id); + } + }); + + test("should have initial balances", async () => { + for (const customer of customers) { + const customerData = await autumnV1.customers.get(customer.id); + + console.log(`\n๐Ÿ” Initial state for ${customer.id}:`); + console.log( + ` Customer balance: ${customerData.features[TestFeature.Messages].balance}`, + ); + console.log( + ` Customer usage: ${customerData.features[TestFeature.Messages].usage}`, + ); + + // Customer should have: 200 (customer-level) + 1000*3 (entity-level) = 3200 + expect(customerData.features[TestFeature.Messages].balance).toBe( + customerMessagesItem.included_usage + + entityMessagesItem.included_usage * NUM_ENTITIES, + ); + + // Each entity should have: 1000 (entity-level) + 200 (customer-level inherited) = 1200 + for (const entity of customer.entities) { + const _entity = await autumnV1.entities.get(customer.id, entity.id); + console.log( + ` Entity ${entity.id} balance: ${_entity.features[TestFeature.Messages].balance}`, + ); + expect(_entity.features[TestFeature.Messages].balance).toBe( + entityMessagesItem.included_usage + + customerMessagesItem.included_usage, + ); + } + } + }); + + test(`should handle ${NUM_REQUESTS} concurrent requests with mixed entity/customer tracking`, async () => { + console.log( + `\n๐Ÿš€ Starting ${NUM_REQUESTS} concurrent track requests across ${NUM_CUSTOMERS} customers...`, + ); + + const allPromises: Promise[] = []; + const trackingLogs: Record< + string, + Array<{ entityId: string | null; value: Decimal }> + > = {}; + + // Initialize tracking logs per customer + for (const customer of customers) { + trackingLogs[customer.id] = []; + } + + for (let i = 0; i < NUM_REQUESTS; i++) { + // Randomly pick a customer + const customer = customers[Math.floor(Math.random() * customers.length)]; + + // Generate random value between 0.01 and 2.00 + const decimalValue = randomDecimal(0.01, 2.0); + const value = decimalValue.toNumber(); + + // Randomly choose entity or customer-level + const entityId = randomEntityOrNull(customer.entities); + + // Store for tracking + trackingLogs[customer.id].push({ entityId, value: decimalValue }); + + // Create track request + const promise = autumnV1.track({ + customer_id: customer.id, + entity_id: entityId || undefined, + feature_id: TestFeature.Messages, + value: value, + skip_event: true, + }); + + allPromises.push(promise); + } + + // Execute all requests concurrently + const startTime = Date.now(); + await Promise.all(allPromises); + const endTime = Date.now(); + + console.log( + `\nโœ… Completed ${NUM_REQUESTS} requests in ${endTime - startTime}ms`, + ); + console.log( + ` Average: ${((endTime - startTime) / NUM_REQUESTS).toFixed(2)}ms per request`, + ); + + // Calculate expected balances by simulating deduction logic for each customer + console.log(`\n๐Ÿ“Š Calculating expected balances per customer...`); + + for (const customer of customers) { + const trackingLog = trackingLogs[customer.id]; + + console.log(`\n ${customer.id}:`); + console.log(` Tracks: ${trackingLog.length}`); + + // Initialize balances (separate customer and entity balances) + let customerBalance = new Decimal(customerMessagesItem.included_usage); + const entityBalances: Record = {}; + for (const entity of customer.entities) { + entityBalances[entity.id] = new Decimal( + entityMessagesItem.included_usage, + ); + } + + let customerLevelTracks = 0; + let entityLevelTracks = 0; + + // Process each track sequentially to calculate expected state + for (const log of trackingLog) { + let remaining = log.value; + + if (log.entityId === null) { + // Customer-level tracking: deduct from customer balance first, then entities in order + customerLevelTracks++; + + // 1. Deduct from customer balance + if (customerBalance.gt(0)) { + const deducted = Decimal.min(customerBalance, remaining); + customerBalance = customerBalance.minus(deducted); + remaining = remaining.minus(deducted); + } + + // 2. If remaining, deduct from entities in alphabetical order + if (remaining.gt(0)) { + const sortedEntityIds = Object.keys(entityBalances).sort(); + for (const entityId of sortedEntityIds) { + if (remaining.lte(0)) break; + + const entityBalance = entityBalances[entityId]; + const deducted = Decimal.min(entityBalance, remaining); + entityBalances[entityId] = entityBalance.minus(deducted); + remaining = remaining.minus(deducted); + } + } + } else { + // Entity-level tracking: deduct from entity balance first, then customer balance + entityLevelTracks++; + + // 1. Deduct from specific entity's balance first + const entityBalance = entityBalances[log.entityId]; + if (entityBalance.gt(0)) { + const deducted = Decimal.min(entityBalance, remaining); + entityBalances[log.entityId] = entityBalance.minus(deducted); + remaining = remaining.minus(deducted); + } + + // 2. If remaining, deduct from customer balance + if (remaining.gt(0)) { + const deducted = Decimal.min(customerBalance, remaining); + customerBalance = customerBalance.minus(deducted); + remaining = remaining.minus(deducted); + } + } + } + + console.log(` Customer-level tracks: ${customerLevelTracks}`); + console.log(` Entity-level tracks: ${entityLevelTracks}`); + console.log( + ` Expected customer balance: ${customerBalance.toFixed(2)}`, + ); + for (const entity of customer.entities) { + console.log( + ` Expected ${entity.id} balance: ${entityBalances[entity.id].toFixed(2)}`, + ); + } + + // Store expected values for next test + expectedCustomerBalances[customer.id] = customerBalance; + for (const entity of customer.entities) { + expectedEntityBalances[entity.id] = entityBalances[entity.id]; + } + } + }); + + test("should have correct cached balances after concurrent tracking", async () => { + for (const customer of customers) { + const customerData = await autumnV1.customers.get(customer.id); + + console.log(`\n๐Ÿ” Final cached state for ${customer.id}:`); + + // Get expected customer balance for this customer + const expectedCusBalance = expectedCustomerBalances[customer.id]; + + // Get expected entity balances for this customer + const expectedCusEntityBalances = customer.entities.reduce( + (acc, entity) => { + acc[entity.id] = expectedEntityBalances[entity.id]; + return acc; + }, + {} as Record, + ); + + // Customer cache shows aggregated balance (customer + all entities) + const expectedAggregatedBalance = expectedCusBalance.plus( + Object.values(expectedCusEntityBalances).reduce( + (sum, b) => sum.plus(b), + new Decimal(0), + ), + ); + + console.log( + ` Actual customer balance: ${customerData.features[TestFeature.Messages].balance}`, + ); + console.log( + ` Expected customer balance: ${expectedAggregatedBalance.toFixed(2)}`, + ); + + expect(customerData.features[TestFeature.Messages].balance).toBe( + expectedAggregatedBalance.toNumber(), + ); + + // Each entity cache shows merged balance (entity + customer) + for (const entity of customer.entities) { + const _entity = await autumnV1.entities.get(customer.id, entity.id); + const expectedEntityMergedBalance = + expectedEntityBalances[entity.id].plus(expectedCusBalance); + + console.log( + ` Actual ${entity.id} balance: ${_entity.features[TestFeature.Messages].balance}`, + ); + console.log( + ` Expected ${entity.id} balance: ${expectedEntityMergedBalance.toFixed(2)}`, + ); + + expect(_entity.features[TestFeature.Messages].balance).toBe( + expectedEntityMergedBalance.toNumber(), + ); + } + } + }); + + test("verify database state matches cache after all tracking", async () => { + console.log("\nโณ Waiting 4s for DB sync..."); + await timeout(4000); + + for (const customer of customers) { + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customer.id, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customer.id); + + // Customer features should match + expect(customerFromDb.features[TestFeature.Messages]).toEqual( + customerFromCache.features[TestFeature.Messages], + ); + + // All entities should match + for (const entity of customer.entities) { + const entityFromDb = await autumnV1.entities.get( + customer.id, + entity.id, + { + skip_cache: "true", + }, + ); + const entityFromCache = await autumnV1.entities.get( + customer.id, + entity.id, + ); + + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); + } + } + + console.log("\nโœ… All balances verified successfully!"); + }); +}); diff --git a/server/tests/balances/track/entity-products/track-entity-products1.test.ts b/server/tests/balances/track/entity-products/track-entity-products1.test.ts new file mode 100644 index 000000000..9ab63ec43 --- /dev/null +++ b/server/tests/balances/track/entity-products/track-entity-products1.test.ts @@ -0,0 +1,191 @@ +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 { 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"; + +const messagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, +}); + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [messagesItem], +}); + +const testCase = "track-entity-products1"; + +describe(`${chalk.yellowBright("track-entity-products1: entity product tracking")}`, () => { + const customerId = "track-entity-products1"; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + const entities = [ + { + id: `${customerId}-user-1`, + name: "User 1", + feature_id: TestFeature.Users, + }, + { + id: `${customerId}-user-2`, + name: "User 2", + feature_id: TestFeature.Users, + }, + { + id: `${customerId}-user-3`, + name: "User 3", + feature_id: TestFeature.Users, + }, + ]; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + await autumnV1.entities.create(customerId, entities); + + for (const entity of entities) { + await autumnV1.attach({ + customer_id: customerId, + entity_id: entity.id, + product_id: freeProd.id, + }); + } + + // Initialize caches + await autumnV1.customers.get(customerId); + for (const entity of entities) { + await autumnV1.entities.get(customerId, entity.id); + } + }); + + 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; + + // 3 entities ร— 100 messages each = 300 total + expect(balance).toBe(300); + }); + + test("each entity should have initial balance of 100 messages", async () => { + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + const balance = fetchedEntity.features[TestFeature.Messages].balance; + + expect(balance).toBe(100); + } + }); + + // Track 10 messages on each entity + for (let i = 0; i < entities.length; i++) { + test(`track 10 messages on ${entities[i].id}`, async () => { + await autumnV1.track({ + customer_id: customerId, + entity_id: entities[i].id, + feature_id: TestFeature.Messages, + value: 10, + }); + + // Customer should have 10 less + const expectedCustomerBalance = 300 - (i + 1) * 10; + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.Messages].balance).toBe( + expectedCustomerBalance, + ); + + // Check all entity balances + for (let j = 0; j < entities.length; j++) { + const fetchedEntity = await autumnV1.entities.get( + customerId, + entities[j].id, + ); + const expectedBalance = j <= i ? 90 : 100; + expect(fetchedEntity.features[TestFeature.Messages].balance).toBe( + expectedBalance, + ); + } + }); + } + + test("track 10 messages at customer level", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + // Customer should have 10 less (now 260) + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.Messages].balance).toBe(260); + + // Sum of entity balances should be 10 less (was 270, now 260) + let totalEntityBalance = 0; + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + totalEntityBalance += + fetchedEntity.features[TestFeature.Messages].balance; + } + expect(totalEntityBalance).toBe(260); + }); + + test("verify database state matches cache after per-entity and customer-level tracking", async () => { + // Wait for database sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customerId, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customerId); + + // Customer balance should be 260 (started at 300, deducted 30 for entity tracking + 10 for customer tracking) + expect(customerFromDb.features[TestFeature.Messages].balance).toBe(260); + expect(customerFromDb.features[TestFeature.Messages]).toMatchObject( + customerFromCache.features[TestFeature.Messages], + ); + + // Verify each entity's balance + let totalEntityBalanceFromDb = 0; + let totalEntityBalanceFromCache = 0; + + for (const entity of entities) { + const entityFromDb = await autumnV1.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const entityFromCache = await autumnV1.entities.get( + customerId, + entity.id, + ); + + // Each entity should have some messages deducted + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); + + totalEntityBalanceFromDb += + entityFromDb.features[TestFeature.Messages].balance; + totalEntityBalanceFromCache += + entityFromCache.features[TestFeature.Messages].balance; + } + + // Sum of entity balances should be 260 + expect(totalEntityBalanceFromDb).toBe(260); + expect(totalEntityBalanceFromCache).toBe(260); + }); +}); 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 new file mode 100644 index 000000000..138d957b4 --- /dev/null +++ b/server/tests/balances/track/entity-products/track-entity-products2.test.ts @@ -0,0 +1,220 @@ +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 { 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"; + +// const lifetimeMessagesItem = constructFeatureItem({ +// featureId: TestFeature.Messages, +// includedUsage: 50, +// interval: null, +// }) as LimitedItem; + +const entityItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + interval: "month" as any, + intervalCount: 1, +}) as LimitedItem; + +const customerItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 50, + interval: "month" as any, + intervalCount: 1, +}) as LimitedItem; + +const customerProd = constructProduct({ + type: "free", + isDefault: false, + items: [customerItem], +}); + +const entityProd = constructProduct({ + type: "free", + id: "entity_free", + isDefault: false, + items: [entityItem], +}); + +const testCase = "track-entity-products2"; + +describe(`${chalk.yellowBright("track-entity-products2: entity product tracking with mixed intervals")}`, () => { + const customerId = "track-entity-products2"; + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + const entities = [ + { + id: `${customerId}-user-1`, + name: "User 1", + feature_id: TestFeature.Users, + }, + { + id: `${customerId}-user-2`, + name: "User 2", + feature_id: TestFeature.Users, + }, + { + id: `${customerId}-user-3`, + name: "User 3", + feature_id: TestFeature.Users, + }, + ]; + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + await initProductsV0({ + ctx, + products: [customerProd, entityProd], + prefix: testCase, + }); + + await autumnV1.entities.create(customerId, entities); + + await autumnV1.attach({ + customer_id: customerId, + product_id: customerProd.id, + }); + + // Attach product to each entity + for (const entity of entities) { + await autumnV1.attach({ + customer_id: customerId, + entity_id: entity.id, + product_id: entityProd.id, + }); + } + + // Initialize caches + await autumnV1.customers.get(customerId); + for (const entity of entities) { + await autumnV1.entities.get(customerId, entity.id); + } + }); + + test("customer should have initial balance of 350 messages (50 customer + 100 monthly per entity)", async () => { + const customer = await autumnV1.customers.get(customerId); + + // 3 entities ร— (50 lifetime + 100 monthly) = 450 total + expect(customer.features[TestFeature.Messages].balance).toBe(350); + }); + + test("each entity should have initial balance of 150 messages (50 customer + 100 monthly)", async () => { + for (const entity of entities) { + const _entity = await autumnV1.entities.get(customerId, entity.id); + expect(_entity.features[TestFeature.Messages].balance).toBe(150); + } + }); + + // Track 20 messages on each entity (should deduct from monthly first, then lifetime) + for (let i = 0; i < entities.length; i++) { + test(`track 20 messages on ${entities[i].id}`, async () => { + await autumnV1.track({ + customer_id: customerId, + entity_id: entities[i].id, + feature_id: TestFeature.Messages, + value: 20, + }); + + // // Customer should have 20 less + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.Messages].balance).toBe( + 350 - (i + 1) * 20, + ); + + // Check all entity balances + for (let j = 0; j < entities.length; j++) { + const fetchedEntity = await autumnV1.entities.get( + customerId, + entities[j].id, + ); + // const total = customerItem.included_usage + entityItem.included_usage; + const expectedBalance = j <= i ? 150 - 20 : 150; + expect(fetchedEntity.features[TestFeature.Messages].balance).toBe( + expectedBalance, + ); + } + }); + } + + test("track 60 messages at customer level (draw from customer then entity...)", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 60, + }); + + // Customer should have 50 less (was 290, now 240) + const customer = await autumnV1.customers.get(customerId); + expect(customer.features[TestFeature.Messages].balance).toBe(230); + + // Sum of entity balances should be 50 less (was 390, now 340) + let totalEntityBalance = 0; + for (const entity of entities) { + const fetchedEntity = await autumnV1.entities.get(customerId, entity.id); + totalEntityBalance += + fetchedEntity.features[TestFeature.Messages].balance; + + console.log( + `Entity ${entity.id} balance: ${fetchedEntity.features[TestFeature.Messages].balance}`, + ); + } + + expect(totalEntityBalance).toBe(230); + }); + + test("verify database state matches cache after per-entity and customer-level tracking", async () => { + // Wait for database sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customerId, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customerId); + + // Customer balance should be 230 (started at 290, deducted 60 at customer level: 50 from customer + 10 from entity) + expect(customerFromDb.features[TestFeature.Messages].balance).toBe(230); + expect(customerFromDb.features[TestFeature.Messages]).toMatchObject( + customerFromCache.features[TestFeature.Messages], + ); + + // Verify each entity's balance + let totalEntityBalanceFromDb = 0; + let totalEntityBalanceFromCache = 0; + + for (const entity of entities) { + const entityFromDb = await autumnV1.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const entityFromCache = await autumnV1.entities.get( + customerId, + entity.id, + ); + + // Each entity should have some messages deducted + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); + + totalEntityBalanceFromDb += + entityFromDb.features[TestFeature.Messages].balance; + totalEntityBalanceFromCache += + entityFromCache.features[TestFeature.Messages].balance; + } + + // Sum of entity balances should be 230 + expect(totalEntityBalanceFromDb).toBe(230); + expect(totalEntityBalanceFromCache).toBe(230); + }); +}); diff --git a/server/tests/balances/track/entity-products/track-entity-products3.test.ts b/server/tests/balances/track/entity-products/track-entity-products3.test.ts new file mode 100644 index 000000000..0239d178e --- /dev/null +++ b/server/tests/balances/track/entity-products/track-entity-products3.test.ts @@ -0,0 +1,350 @@ +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 { 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"; + +const testCase = "track-entity-products3"; + +// Entity-level messages (monthly, per entity) +const entityMessagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 5000, + interval: "month" as any, + intervalCount: 1, +}) as LimitedItem; + +const freeProd = constructProduct({ + type: "free", + isDefault: false, + items: [entityMessagesItem], +}); + +const NUM_REQUESTS = 5000; +const NUM_CUSTOMERS = 1; +const NUM_ENTITIES = 2; + +// Helper to generate random decimal between min and max +const randomDecimal = (min: number, max: number): Decimal => { + const value = Math.random() * (max - min) + min; + return new Decimal(value).toDecimalPlaces(2); +}; + +// Helper to randomly choose an entity or null (for customer-level) +const randomEntityOrNull = (entities: { id: string }[]): string | null => { + // 50% chance customer-level, 50% chance entity-level + if (Math.random() < 0.5) { + return null; // Customer-level + } + // Randomly pick an entity + const randomIndex = Math.floor(Math.random() * entities.length); + return entities[randomIndex].id; +}; + +describe(`${chalk.yellowBright(`${testCase}: Concurrent entity product tracking`)}`, () => { + const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + + // Create multiple customers with their entities + const customers = Array.from({ length: NUM_CUSTOMERS }, (_, i) => { + const customerId = `${testCase}-customer-${i + 1}`; + return { + id: customerId, + entities: Array.from({ length: NUM_ENTITIES }, (_, i) => ({ + id: `${customerId}-user-${i + 1}`, + name: `User ${i + 1}`, + feature_id: TestFeature.Users, + })), + }; + }); + + // Track expected balances per customer + const expectedCustomerBalances: Record = {}; + const expectedEntityBalances: Record = {}; + + // Initialize expected balances + for (const customer of customers) { + expectedCustomerBalances[customer.id] = new Decimal(0); + for (const entity of customer.entities) { + expectedEntityBalances[entity.id] = new Decimal(0); + } + } + + beforeAll(async () => { + // Initialize products once + await initProductsV0({ + ctx, + products: [freeProd], + prefix: testCase, + }); + + // Initialize all customers and attach products to entities + for (const customer of customers) { + await initCustomerV3({ + ctx, + customerId: customer.id, + withTestClock: false, + }); + + await autumnV1.entities.create(customer.id, customer.entities); + + // Attach product to each entity + for (const entity of customer.entities) { + await autumnV1.attach({ + customer_id: customer.id, + entity_id: entity.id, + product_id: freeProd.id, + }); + } + + // Initialize caches + await autumnV1.customers.get(customer.id); + for (const entity of customer.entities) { + await autumnV1.entities.get(customer.id, entity.id); + } + } + }); + + test("should have initial balances", async () => { + for (const customer of customers) { + const customerData = await autumnV1.customers.get(customer.id); + + console.log(`\n๐Ÿ” Initial state for ${customer.id}:`); + console.log( + ` Customer balance: ${customerData.features[TestFeature.Messages].balance}`, + ); + console.log( + ` Customer usage: ${customerData.features[TestFeature.Messages].usage}`, + ); + + // Customer should have: 5000 * NUM_ENTITIES (entity-level products attached to entities) + expect(customerData.features[TestFeature.Messages].balance).toBe( + entityMessagesItem.included_usage * NUM_ENTITIES, + ); + + // Each entity should have: 5000 (entity-level) + for (const entity of customer.entities) { + const _entity = await autumnV1.entities.get(customer.id, entity.id); + console.log( + ` Entity ${entity.id} balance: ${_entity.features[TestFeature.Messages].balance}`, + ); + expect(_entity.features[TestFeature.Messages].balance).toBe( + entityMessagesItem.included_usage, + ); + } + } + }); + + test(`should handle ${NUM_REQUESTS} concurrent requests with mixed entity/customer tracking`, async () => { + console.log( + `\n๐Ÿš€ Starting ${NUM_REQUESTS} concurrent track requests across ${NUM_CUSTOMERS} customers...`, + ); + + const allPromises: Promise[] = []; + const trackingLogs: Record< + string, + Array<{ entityId: string | null; value: Decimal }> + > = {}; + + // Initialize tracking logs per customer + for (const customer of customers) { + trackingLogs[customer.id] = []; + } + + for (let i = 0; i < NUM_REQUESTS; i++) { + // Randomly pick a customer + const customer = customers[Math.floor(Math.random() * customers.length)]; + + // Generate random value between 0.01 and 2.00 + const decimalValue = randomDecimal(0.01, 2.0); + const value = decimalValue.toNumber(); + + // Randomly choose entity or customer-level + const entityId = randomEntityOrNull(customer.entities); + + // Store for tracking + trackingLogs[customer.id].push({ entityId, value: decimalValue }); + + // Create track request + const promise = autumnV1.track({ + customer_id: customer.id, + entity_id: entityId || undefined, + feature_id: TestFeature.Messages, + value: value, + skip_event: true, + }); + + allPromises.push(promise); + } + + // Execute all requests concurrently + const startTime = Date.now(); + await Promise.all(allPromises); + const endTime = Date.now(); + + console.log( + `\nโœ… Completed ${NUM_REQUESTS} requests in ${endTime - startTime}ms`, + ); + console.log( + ` Average: ${((endTime - startTime) / NUM_REQUESTS).toFixed(2)}ms per request`, + ); + + // Calculate expected balances by simulating deduction logic for each customer + console.log(`\n๐Ÿ“Š Calculating expected balances per customer...`); + + for (const customer of customers) { + const trackingLog = trackingLogs[customer.id]; + + console.log(`\n ${customer.id}:`); + console.log(` Tracks: ${trackingLog.length}`); + + // Initialize balances (entity-only, no customer-level entitlements) + const entityBalances: Record = {}; + for (const entity of customer.entities) { + entityBalances[entity.id] = new Decimal( + entityMessagesItem.included_usage, + ); + } + + let customerLevelTracks = 0; + let entityLevelTracks = 0; + + // Process each track sequentially to calculate expected state + for (const log of trackingLog) { + let remaining = log.value; + + if (log.entityId === null) { + // Customer-level tracking: deduct from entities in alphabetical order + customerLevelTracks++; + + const sortedEntityIds = Object.keys(entityBalances).sort(); + for (const entityId of sortedEntityIds) { + if (remaining.lte(0)) break; + + const entityBalance = entityBalances[entityId]; + const deducted = Decimal.min(entityBalance, remaining); + entityBalances[entityId] = entityBalance.minus(deducted); + remaining = remaining.minus(deducted); + } + } else { + // Entity-level tracking: deduct from specific entity's balance + entityLevelTracks++; + + const entityBalance = entityBalances[log.entityId]; + const deducted = Decimal.min(entityBalance, remaining); + entityBalances[log.entityId] = entityBalance.minus(deducted); + remaining = remaining.minus(deducted); + } + } + + console.log(` Customer-level tracks: ${customerLevelTracks}`); + console.log(` Entity-level tracks: ${entityLevelTracks}`); + for (const entity of customer.entities) { + console.log( + ` Expected ${entity.id} balance: ${entityBalances[entity.id].toFixed(2)}`, + ); + } + + // Store expected values for next test (no separate customer balance) + expectedCustomerBalances[customer.id] = new Decimal(0); + for (const entity of customer.entities) { + expectedEntityBalances[entity.id] = entityBalances[entity.id]; + } + } + }); + + test("should have correct cached balances after concurrent tracking", async () => { + for (const customer of customers) { + const customerData = await autumnV1.customers.get(customer.id); + + console.log(`\n๐Ÿ” Final cached state for ${customer.id}:`); + + // Get expected entity balances for this customer + const expectedCusEntityBalances = customer.entities.reduce( + (acc, entity) => { + acc[entity.id] = expectedEntityBalances[entity.id]; + return acc; + }, + {} as Record, + ); + + // Customer cache shows aggregated balance (sum of all entity balances) + const expectedAggregatedBalance = Object.values( + expectedCusEntityBalances, + ).reduce((sum, b) => sum.plus(b), new Decimal(0)); + + console.log( + ` Actual customer balance: ${customerData.features[TestFeature.Messages].balance}`, + ); + console.log( + ` Expected customer balance: ${expectedAggregatedBalance.toFixed(2)}`, + ); + + expect(customerData.features[TestFeature.Messages].balance).toBe( + expectedAggregatedBalance.toNumber(), + ); + + // Each entity cache shows entity balance only + for (const entity of customer.entities) { + const _entity = await autumnV1.entities.get(customer.id, entity.id); + const expectedEntityBalance = expectedEntityBalances[entity.id]; + + console.log( + ` Actual ${entity.id} balance: ${_entity.features[TestFeature.Messages].balance}`, + ); + console.log( + ` Expected ${entity.id} balance: ${expectedEntityBalance.toFixed(2)}`, + ); + + expect(_entity.features[TestFeature.Messages].balance).toBe( + expectedEntityBalance.toNumber(), + ); + } + } + }); + + test("verify database state matches cache after all tracking", async () => { + console.log("\nโณ Waiting 4s for DB sync..."); + await timeout(4000); + + for (const customer of customers) { + // Read from database (skip cache) + const customerFromDb = await autumnV1.customers.get(customer.id, { + skip_cache: "true", + }); + const customerFromCache = await autumnV1.customers.get(customer.id); + + // Customer features should match + expect(customerFromDb.features[TestFeature.Messages]).toEqual( + customerFromCache.features[TestFeature.Messages], + ); + + // All entities should match + for (const entity of customer.entities) { + const entityFromDb = await autumnV1.entities.get( + customer.id, + entity.id, + { + skip_cache: "true", + }, + ); + const entityFromCache = await autumnV1.entities.get( + customer.id, + entity.id, + ); + + expect(entityFromDb.features[TestFeature.Messages]).toEqual( + entityFromCache.features[TestFeature.Messages], + ); + } + } + + console.log("\nโœ… All balances verified successfully!"); + }); +}); diff --git a/shared/api/customers/cusFeatures/apiCusFeature.ts b/shared/api/customers/cusFeatures/apiCusFeature.ts index ea309c064..b5f0c7ca1 100644 --- a/shared/api/customers/cusFeatures/apiCusFeature.ts +++ b/shared/api/customers/cusFeatures/apiCusFeature.ts @@ -54,6 +54,12 @@ export const ApiCusFeatureBreakdownSchema = z.object({ description: "Array of rollover balances from previous periods", example: [{ balance: 100, expires_at: 1759247877000 }], }), + entity_breakdown: z + .object({ + master: z.number(), + entities: z.number(), + }) + .optional(), }); export const CoreCusFeatureSchema = z.object({ @@ -131,6 +137,13 @@ export const CoreCusFeatureSchema = z.object({ description: "Array of rollover balances from previous periods", example: [{ balance: 100, expires_at: 1759247877000 }], }), + + entity_breakdown: z + .object({ + master: z.number(), + entities: z.number(), + }) + .optional(), }); export const ApiCusFeatureSchema = z diff --git a/shared/api/entities/entityOpModels.ts b/shared/api/entities/entityOpModels.ts index c1f83dbd4..7af76887c 100644 --- a/shared/api/entities/entityOpModels.ts +++ b/shared/api/entities/entityOpModels.ts @@ -20,6 +20,7 @@ export const CreateEntityParamsSchema = z.object({ // Get Entity Query Params export const GetEntityQuerySchema = z.object({ expand: queryStringArray(z.enum(EntityExpand)).default([]), + skip_cache: z.boolean().optional(), }); export const CreateEntityQuerySchema = z.object({ diff --git a/shared/utils/cusEntUtils/balanceUtils.ts b/shared/utils/cusEntUtils/balanceUtils.ts index de61b9db3..e66e5a14e 100644 --- a/shared/utils/cusEntUtils/balanceUtils.ts +++ b/shared/utils/cusEntUtils/balanceUtils.ts @@ -18,14 +18,12 @@ export const getSummedEntityBalances = ({ } return { - balance: Object.values(cusEnt.entities).reduce( - (acc, curr) => acc + curr.balance, - 0, - ), - adjustment: Object.values(cusEnt.entities).reduce( - (acc, curr) => acc + curr.adjustment, - 0, - ), + balance: Object.values(cusEnt.entities) + .reduce((acc, curr) => acc.add(curr.balance), new Decimal(0)) + .toNumber(), + adjustment: Object.values(cusEnt.entities) + .reduce((acc, curr) => acc.add(curr.adjustment), new Decimal(0)) + .toNumber(), unused: 0, count: Object.values(cusEnt.entities).length, }; diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 97f2d20b5..2b4b5e7ad 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -95,11 +95,14 @@ export const cusEntToIncludedUsage = ({ export const cusEntToUsageLimit = ({ cusEnt, + entityId, }: { cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; }) => { const startingBalance = cusEntToIncludedUsage({ cusEnt, + entityId, }); if (cusEnt.entitlement.usage_limit) return cusEnt.entitlement.usage_limit; diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 772b43524..786bbf273 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -2,11 +2,8 @@ import type { EntityBalance, FullCustomerEntitlement, } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import type { Feature } from "../../models/featureModels/featureModels.js"; -import { notNullish } from "../utils.js"; export const formatCusEnt = ({ cusEnt, @@ -16,6 +13,17 @@ export const formatCusEnt = ({ return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`; }; +export const isEntityCusEnt = ({ + cusEnt, +}: { + cusEnt: FullCusEntWithFullCusProduct; +}): boolean => { + return !!( + cusEnt.entitlement.entity_feature_id || + cusEnt.customer_product?.internal_entity_id + ); +}; + export const updateCusEntInFullCus = ({ fullCus, cusEntId, @@ -47,33 +55,3 @@ export const updateCusEntInFullCus = ({ } } }; -export const cusEntMatchesEntity = ({ - cusEnt, - entity, - features, -}: { - cusEnt: FullCusEntWithFullCusProduct; - entity?: Entity; - features?: Feature[]; -}) => { - if (!entity) return true; - - let cusProductMatch = true; - - if (notNullish(cusEnt.customer_product?.internal_entity_id)) { - cusProductMatch = - cusEnt.customer_product.internal_entity_id === entity.internal_id; - } - - let entityFeatureIdMatch = true; - // let feature = features?.find( - // (f) => f.id == cusEnt.entitlement.entity_feature_id, - // ); - - if (notNullish(cusEnt.entitlement.entity_feature_id)) { - entityFeatureIdMatch = - cusEnt.entitlement.entity_feature_id === entity.feature_id; - } - - return cusProductMatch && entityFeatureIdMatch; -}; diff --git a/shared/utils/cusEntUtils/filterCusEntUtils.ts b/shared/utils/cusEntUtils/filterCusEntUtils.ts new file mode 100644 index 000000000..f1cbcdc13 --- /dev/null +++ b/shared/utils/cusEntUtils/filterCusEntUtils.ts @@ -0,0 +1,64 @@ +import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { Feature } from "../../models/featureModels/featureModels.js"; +import { notNullish, nullish } from "../utils.js"; +export const cusEntMatchesEntity = ({ + cusEnt, + entity, + features, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entity?: Entity; + features?: Feature[]; +}) => { + if (!entity) return true; + + let cusProductMatch = true; + + if (notNullish(cusEnt.customer_product?.internal_entity_id)) { + cusProductMatch = + cusEnt.customer_product.internal_entity_id === entity.internal_id; + } + + let entityFeatureIdMatch = true; + // let feature = features?.find( + // (f) => f.id == cusEnt.entitlement.entity_feature_id, + // ); + + if (notNullish(cusEnt.entitlement.entity_feature_id)) { + entityFeatureIdMatch = + cusEnt.entitlement.entity_feature_id === entity.feature_id; + } + + return cusProductMatch && entityFeatureIdMatch; +}; + +export const filterOutEntityCusEnts = ({ + cusEnts, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; +}) => { + return cusEnts.filter( + (ce) => + nullish(ce.entitlement.entity_feature_id) && + nullish(ce.customer_product?.internal_entity_id), + ); +}; + +export const filterPerEntityCusEnts = ({ + cusEnts, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; +}) => { + return cusEnts.filter((ce) => notNullish(ce.entitlement.entity_feature_id)); +}; + +export const filterEntityProductCusEnts = ({ + cusEnts, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; +}) => { + return cusEnts.filter((ce) => + notNullish(ce.customer_product?.internal_entity_id), + ); +}; diff --git a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts index c1c4fa13a..cffdc1bb2 100644 --- a/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts +++ b/shared/utils/cusEntUtils/sortCusEntsForDeduction.ts @@ -2,15 +2,33 @@ import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels import { FeatureType } from "../../models/featureModels/featureEnums.js"; import { AllowanceType } from "../../models/productModels/entModels/entModels.js"; import { entIntervalToValue } from "../intervalUtils.js"; +import { isEntityCusEnt } from "./cusEntUtils.js"; export const sortCusEntsForDeduction = ( cusEnts: FullCusEntWithFullCusProduct[], reverseOrder: boolean = false, + entityId?: string, ) => { cusEnts.sort((a, b) => { const aEnt = a.entitlement; const bEnt = b.entitlement; + // 0. Sort customer-level vs entity-level based on tracking context + // If entityId is provided: entity-level goes first (deduct entity's own resources first) + // If entityId is null: customer-level goes first (deduct customer resources first) + const aIsEntity = isEntityCusEnt({ cusEnt: a }); + const bIsEntity = isEntityCusEnt({ cusEnt: b }); + + if (aIsEntity !== bIsEntity) { + if (entityId) { + // Entity-level tracking: entity entitlements go first + return aIsEntity ? -1 : 1; + } else { + // Customer-level tracking: customer entitlements go first + return aIsEntity ? 1 : -1; + } + } + // 1. If boolean, go first if (aEnt.feature.type === FeatureType.Boolean) { return -1; @@ -85,6 +103,19 @@ export const sortCusEntsForDeduction = ( } } + // 0a. If both are entity products (attached to entities), sort by entity_id for consistent ordering + const aIsProductEntity = !!a.customer_product?.internal_entity_id; + const bIsProductEntity = !!b.customer_product?.internal_entity_id; + + if (aIsProductEntity && bIsProductEntity) { + const aEntityId = a.customer_product?.entity_id; + const bEntityId = b.customer_product?.entity_id; + + if (aEntityId && bEntityId && aEntityId !== bEntityId) { + return aEntityId.localeCompare(bEntityId); + } + } + // Check if a is main product const aIsAddOn = a.customer_product?.product?.is_add_on; const bIsAddOn = b.customer_product?.product?.is_add_on; diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index 881025a68..945318152 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -5,7 +5,7 @@ import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums. import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; import type { FullProduct } from "../../models/productModels/productModels.js"; -import { cusEntMatchesEntity } from "../cusEntUtils/cusEntUtils.js"; +import { cusEntMatchesEntity } from "../cusEntUtils/filterCusEntUtils.js"; import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js"; import { getBillingType } from "../productUtils/priceUtils.js"; @@ -97,7 +97,7 @@ export const cusProductsToCusEnts = ({ ); } - sortCusEntsForDeduction(cusEnts, reverseOrder); + sortCusEntsForDeduction(cusEnts, reverseOrder, entity?.id); return cusEnts as FullCusEntWithFullCusProduct[]; }; diff --git a/shared/utils/cusProductUtils/filterCusProductUtils.ts b/shared/utils/cusProductUtils/filterCusProductUtils.ts index d27ea3b20..f35a22474 100644 --- a/shared/utils/cusProductUtils/filterCusProductUtils.ts +++ b/shared/utils/cusProductUtils/filterCusProductUtils.ts @@ -1,5 +1,6 @@ import { notNullish, nullish } from "@utils/utils.js"; import type { Entity } from "../../models/cusModels/entityModels/entityModels.js"; +import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; import type { Organization } from "../../models/orgModels/orgTable.js"; @@ -31,24 +32,65 @@ export const filterCusProductsByEntity = ({ }); }; -// export const filterOutEntitiesFromCusProducts = ({ -// cusProducts, -// }: { -// cusProducts: FullCusProduct[]; -// }): FullCusProduct[] => { -// // 1. Remove cus products with internal_entity_id -// const finalCusProducts = cusProducts.filter((p: FullCusProduct) => { -// return nullish(p.internal_entity_id); -// }); +export const filterEntityLevelCusProducts = ({ + cusProducts, +}: { + cusProducts: FullCusProduct[]; +}): FullCusProduct[] => { + const finalCusProducts: FullCusProduct[] = structuredClone(cusProducts); + for (let i = 0; i < finalCusProducts.length; i++) { + if (notNullish(finalCusProducts[i].internal_entity_id)) continue; -// // 2. Remove cus products with entity balances... -// for (let i = 0; i < finalCusProducts.length; i++) { -// finalCusProducts[i].customer_entitlements = finalCusProducts[ -// i -// ].customer_entitlements.filter((cusEnt: FullCustomerEntitlement) => { -// return nullish(cusEnt.entitlement.entity_feature_id); -// }); -// } + const newCusEnts = cusProducts[i].customer_entitlements.filter((ce) => + notNullish(ce.entitlement.entity_feature_id), + ); -// return finalCusProducts; -// }; + finalCusProducts[i].customer_entitlements = newCusEnts; + } + + // finalCusProducts = finalCusProducts.filter((cp: FullCusProduct) => { + // // 1. If no cusEnts, return false + // const cusEnts = cp.customer_entitlements; + // if (cusEnts.length === 0) return false; + + // // 2. If any cusEnt has an entity feature id, return true + // if ( + // cusEnts.some((cusEnt: FullCustomerEntitlement) => + // notNullish(cusEnt.entitlement.entity_feature_id), + // ) + // ) + // return true; + + // if (cp.internal_entity_id) { + // return true; + // } + + // return false; + // }); + + return finalCusProducts; +}; + +export const filterOutEntitiesFromCusProducts = ({ + cusProducts, +}: { + cusProducts: FullCusProduct[]; +}): FullCusProduct[] => { + // 1. Remove cus products with internal_entity_id + const finalCusProducts = structuredClone(cusProducts).filter( + (p: FullCusProduct) => { + return nullish(p.internal_entity_id); + }, + ); + + // 2. Remove cus products with entity balances... + for (let i = 0; i < finalCusProducts.length; i++) { + finalCusProducts[i].customer_entitlements = finalCusProducts[ + i + ].customer_entitlements.filter((cusEnt: FullCustomerEntitlement) => { + return nullish(cusEnt.entitlement.entity_feature_id); + }); + } + + return finalCusProducts; +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 89b99a55f..63b27f468 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -3,6 +3,7 @@ export * from "./cusEntUtils/balanceUtils.js"; export * from "./cusEntUtils/convertCusEntUtils.js"; export * from "./cusEntUtils/cusEntUtils.js"; +export * from "./cusEntUtils/filterCusEntUtils.js"; // Cus ent utils export * from "./cusEntUtils/getRolloverFields.js"; export * from "./cusEntUtils/getStartingBalance.js"; @@ -13,6 +14,7 @@ export * from "./cusProductUtils/convertCusProduct.js"; export * from "./cusProductUtils/cusProductConstants.js"; export * from "./cusProductUtils/cusProductUtils.js"; export * from "./cusProductUtils/filterCusProductUtils.js"; +export * from "./cusProductUtils/filterCusProductUtils.js"; export * from "./cusProductUtils/formatCusProductUtils.js"; export * from "./cusProductUtils/productIdToCusProduct.js"; export * from "./featureUtils/apiFeatureToDbFeature.js"; diff --git a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx index c17947ef6..3fa168288 100644 --- a/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/vite/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -1,30 +1,26 @@ import { - AllowanceType, FeatureType, - FullCusEntWithFullCusProduct, - FullCusProduct, - FullCustomerEntitlement, + type FullCusEntWithFullCusProduct, + type FullCusProduct, + type FullCustomerEntitlement, } from "@autumn/shared"; +import { useState } from "react"; +import { AdminHover } from "@/components/general/AdminHover"; +import { Item, Row } from "@/components/general/TableGrid"; -import { useCustomerContext } from "../CustomerContext"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; import { formatUnixToDate, formatUnixToDateTime, } from "@/utils/formatUtils/formatDateUtils"; - -import { useState } from "react"; - -import { Badge } from "@/components/ui/badge"; -import UpdateCusEntitlement from "./UpdateCusEntitlement"; -import { AdminHover } from "@/components/general/AdminHover"; -import { Item, Row } from "@/components/general/TableGrid"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { useCustomerContext } from "../CustomerContext"; import { CusProductEntityItem } from "../components/CusProductEntityItem"; -import { CusEntBalance } from "./CusEntBalance"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useCusQuery } from "../hooks/useCusQuery"; +import { CusEntBalance } from "./CusEntBalance"; +import UpdateCusEntitlement from "./UpdateCusEntitlement"; export const CustomerEntitlementsList = () => { const [featureType, setFeatureType] = useState(