diff --git a/AGENTS.md b/AGENTS.md index 201f9a40d..593f69f01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,19 @@ in the test logs. Use your common sense - Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +- For regular functions, use inline object types in the function signature rather than creating separate type definitions. Only create named types when they're reused across multiple functions or exported. + ```typescript + // ❌ BAD - Unnecessary type definition for single-use params + type DoSomethingParams = { + ctx: AutumnContext; + customerId: string; + }; + const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... } + + // ✅ GOOD - Inline object type + const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... } + ``` + - This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why ` which will tell you why a certain package was installed and by who. - Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better diff --git a/CLAUDE.md b/CLAUDE.md index df9f18017..9e62b1414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,19 @@ in the test logs. Use your common sense - Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand. +- For regular functions, use inline object types in the function signature rather than creating separate type definitions. Only create named types when they're reused across multiple functions or exported. + ```typescript + // ❌ BAD - Unnecessary type definition for single-use params + type DoSomethingParams = { + ctx: AutumnContext; + customerId: string; + }; + const doSomething = async ({ ctx, customerId }: DoSomethingParams) => { ... } + + // ✅ GOOD - Inline object type + const doSomething = async ({ ctx, customerId }: { ctx: AutumnContext; customerId: string }) => { ... } + ``` + - This codebase uses Bun for all of its operations in `/server`, `/vite` and `/shared`. It uses Bun for the package management, Bun for the workspace management and Bun for the runtime. Prefer Bun over PNPM. If you ever want to trace a package dependency tree, run `bun why ` which will tell you why a certain package was installed and by who. - Prefer Guard clauses "if(!admin) return;" over explicity "if(admin) do X;" Early returns are better diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index e45359fea..ea6fedc3d 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -11,21 +11,21 @@ source "$(dirname "$0")/config.sh" # Adjust --max to control concurren.cy (default: 6) BUN_PARALLEL_COMPACT \ 'server/tests/balances/track/basic' \ - 'server/tests/balances/track/breakdown' \ - 'server/tests/balances/track/credit-systems' \ - 'server/tests/balances/track/entity-products' \ - 'server/tests/balances/track/legacy' \ - 'server/tests/balances/track/allocated' \ - 'server/tests/balances/track/entity-balances' \ - 'server/tests/balances/track/concurrency' \ - 'server/tests/balances/track/negative' \ - 'server/tests/balances/check/breakdown' \ - 'server/tests/balances/check/basic' \ - 'server/tests/balances/check/credit-systems' \ - 'server/tests/balances/check/misc' \ - 'server/tests/balances/check/prepaid' \ - 'server/tests/balances/check/send-event' \ - --max=6 + # 'server/tests/balances/track/breakdown' \ + # 'server/tests/balances/track/credit-systems' \ + # 'server/tests/balances/track/entity-products' \ + # 'server/tests/balances/track/legacy' \ + # 'server/tests/balances/track/allocated' \ + # 'server/tests/balances/track/entity-balances' \ + # 'server/tests/balances/track/concurrency' \ + # 'server/tests/balances/track/negative' \ + # 'server/tests/balances/check/breakdown' \ + # 'server/tests/balances/check/basic' \ + # 'server/tests/balances/check/credit-systems' \ + # 'server/tests/balances/check/misc' \ + # 'server/tests/balances/check/prepaid' \ + # 'server/tests/balances/check/send-event' \ + # --max=6 # BUN_PARALLEL_COMPACT \ diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua index ae1c2323b..2824f0a59 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua @@ -212,54 +212,53 @@ local function process_pass(pass_config) if usage_allowed == cjson.null then usage_allowed = false end usage_allowed = usage_allowed or overage_behavior_is_allow - -- Apply filter if needed - if skip_if_not_usage_allowed and not usage_allowed then + -- Apply filter: only process if usage is allowed (or skip filter is disabled) + local should_process = not skip_if_not_usage_allowed or usage_allowed + + if not should_process then table.insert(logs, pass_name .. " skipping " .. ent_id .. " - usage_allowed=false") - goto continue - end - - local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(full_customer, ent_id) - - if cus_ent then - local cp_idx_0 = cp_idx - 1 - local ce_idx_0 = ce_idx - 1 - local base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' + else + local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(full_customer, ent_id) - -- Get current adjustment from cus_ent for ceiling calculation - local current_adjustment = cus_ent.adjustment or 0 - - local deducted = deduct_from_main_balance({ - cache_key = cache_key, - base_path = base_path, - has_entity_scope = has_entity_scope, - target_entity_id = target_entity_id, - amount = remaining_amount, - credit_cost = credit_cost, - allow_negative = allow_negative, - min_balance = min_balance, - max_balance = max_balance, - adjustment = current_adjustment, - alter_granted_balance = alter_granted_balance, - overage_behavior_is_allow = overage_behavior_is_allow, - logs = logs, - log_prefix = pass_name, - }) - - -- Update remaining_amount - remaining_amount = remaining_amount - (deducted / credit_cost) - - -- Track in updates - if deducted ~= 0 then - if not updates[ent_id] then - updates[ent_id] = { deducted = 0, additional_deducted = 0 } + if cus_ent then + local cp_idx_0 = cp_idx - 1 + local ce_idx_0 = ce_idx - 1 + local base_path = '$.customer_products[' .. cp_idx_0 .. '].customer_entitlements[' .. ce_idx_0 .. ']' + + -- Get current adjustment from cus_ent for ceiling calculation + local current_adjustment = cus_ent.adjustment or 0 + + local deducted = deduct_from_main_balance({ + cache_key = cache_key, + base_path = base_path, + has_entity_scope = has_entity_scope, + target_entity_id = target_entity_id, + amount = remaining_amount, + credit_cost = credit_cost, + allow_negative = allow_negative, + min_balance = min_balance, + max_balance = max_balance, + adjustment = current_adjustment, + alter_granted_balance = alter_granted_balance, + overage_behavior_is_allow = overage_behavior_is_allow, + logs = logs, + log_prefix = pass_name, + }) + + -- Update remaining_amount + remaining_amount = remaining_amount - (deducted / credit_cost) + + -- Track in updates + if deducted ~= 0 then + if not updates[ent_id] then + updates[ent_id] = { deducted = 0, additional_deducted = 0 } + end + updates[ent_id].deducted = (updates[ent_id].deducted or 0) + deducted end - updates[ent_id].deducted = (updates[ent_id].deducted or 0) + deducted + + table.insert(logs, pass_name .. " ent " .. ent_id .. " deducted=" .. tostring(deducted) .. " remaining=" .. tostring(remaining_amount)) end - - table.insert(logs, pass_name .. " ent " .. ent_id .. " deducted=" .. tostring(deducted) .. " remaining=" .. tostring(remaining_amount)) end - - ::continue:: end table.insert(logs, "=== " .. pass_name .. " END === remaining=" .. tostring(remaining_amount)) diff --git a/server/src/internal/balances/utils/redis/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts similarity index 87% rename from server/src/internal/balances/utils/redis/luaScriptsV2.ts rename to server/src/_luaScriptsV2/luaScriptsV2.ts index c4c994e4d..a1bc97a3c 100644 --- a/server/src/internal/balances/utils/redis/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -5,9 +5,8 @@ import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Path to _luaScriptsV2 folder (4 levels up from this file) -const LUA_SCRIPTS_V2_DIR = join(__dirname, "../../../../_luaScriptsV2"); -const DEDUCT_DIR = join(LUA_SCRIPTS_V2_DIR, "deductFromCustomerEntitlements"); +// Path to deductFromCustomerEntitlements folder (same directory as this file) +const DEDUCT_DIR = join(__dirname, "deductFromCustomerEntitlements"); // ============================================================================ // HELPER MODULES diff --git a/server/src/db/initializeDatabaseFunctions.ts b/server/src/db/initializeDatabaseFunctions.ts index 607374fcc..811397c14 100644 --- a/server/src/db/initializeDatabaseFunctions.ts +++ b/server/src/db/initializeDatabaseFunctions.ts @@ -27,6 +27,7 @@ export const initializeDatabaseFunctions = async () => { "deductFromAdditionalBalance.sql", "performDeduction.sql", "syncBalances.sql", + "syncBalancesV2.sql", ]; for (const file of sqlFiles) { diff --git a/server/src/internal/balances/FOLDER_STRUCTURE.md b/server/src/internal/balances/FOLDER_STRUCTURE.md new file mode 100644 index 000000000..b7c6f3b55 --- /dev/null +++ b/server/src/internal/balances/FOLDER_STRUCTURE.md @@ -0,0 +1,74 @@ +# Balances Folder Structure + +## Target Structure + +``` +balances/ +├── balancesRouter.ts +├── handlers/ +│ ├── handleTrack.ts +│ └── handleUpdateBalance.ts +│ +├── track/ # Top-level business logic +│ ├── runTrack.ts # Single entry point (merge V1/V2) +│ ├── getFeatureDeductions.ts +│ ├── getTrackBalancesResponse.ts +│ └── TRACK_RULES.md +│ +├── setUsage/ # Top-level business logic +│ ├── handleSetUsage.ts +│ └── getSetUsageDeductions.ts +│ +├── updateBalance/ # Top-level business logic +│ ├── runAddToBalance.ts +│ ├── runUpdateBalance.ts +│ └── runRedisUpdateBalance.ts +│ +├── updateGrantedBalance/ # Top-level business logic +│ └── updateGrantedBalance.ts +│ +└── utils/ # Infrastructure & shared utilities + │ + ├── deduction/ # Core deduction logic + │ ├── deductionTypes.ts # Shared types (DeductionParams, etc.) + │ ├── prepareDeductionInput.ts # Shared cusEntInput + rollover prep + │ ├── executePostgresDeduction.ts # Postgres-specific deduction + │ ├── executeRedisDeduction.ts # Redis-specific deduction + │ ├── handlePaidAllocatedCusEnt.ts# Shared post-deduction logic + │ ├── rollbackDeduction.ts # Shared rollback + │ └── validateDeduction.ts # Pre-deduction validation + │ + ├── sync/ # Consolidated sync logic + │ ├── SyncBatchingManager.ts # Batches sync operations + │ ├── syncItem.ts # Single sync item handler + │ └── runSyncBalanceBatch.ts + │ + ├── events/ # Event batching & insertion + │ ├── EventBatchingManager.ts + │ ├── runInsertEventBatch.ts + │ └── constructEvent.ts + │ + ├── redis/ # Redis-specific utilities + │ └── luaScripts.ts # Lua script loader + │ + └── sql/ # SQL scripts + ├── performDeduction.sql + ├── deductFromMainBalance.sql + ├── deductFromRollovers.sql + ├── deductFromAdditionalBalance.sql + ├── getTotalBalance.sql + └── syncBalances.sql +``` + +## Design Principles + +- **Top-level folders** (`track/`, `setUsage/`, `updateBalance/`, `updateGrantedBalance/`) = Business logic entry points +- **`utils/`** = Infrastructure code that supports those top-level functions +- **`utils/deduction/`** = Core deduction logic shared between Redis and Postgres paths + + + +TODOS: +1. Remove cus_ent_ids from script input +2. Deal with actualDeductions in deductFromRedisCusEnts +3. Deal with unlimited features \ No newline at end of file diff --git a/server/src/internal/balances/handlers/handleTrack.ts b/server/src/internal/balances/handlers/handleTrack.ts index af68fff20..a09107f1d 100644 --- a/server/src/internal/balances/handlers/handleTrack.ts +++ b/server/src/internal/balances/handlers/handleTrack.ts @@ -41,12 +41,5 @@ export const handleTrack = createRoute({ featureDeductions, }), ); - // const response = await runTrack({ - // ctx, - // body, - // featureDeductions, - // }); - - // return c.json(response); }, }); diff --git a/server/src/internal/balances/track/redisTrackUtils/runRedisDeductionV2.ts b/server/src/internal/balances/track/redisTrackUtils/runRedisDeductionV2.ts deleted file mode 100644 index f544ff19d..000000000 --- a/server/src/internal/balances/track/redisTrackUtils/runRedisDeductionV2.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { - ApiBalance, - FullCustomer, - TrackParams, - TrackResponseV2, -} from "@autumn/shared"; -import { RecaseError } from "@autumn/shared"; -import { currentRegion } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { getApiCustomerBase } from "../../../customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; -import { deleteCachedFullCustomer } from "../../../customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; -import { deductFromRedisCusEnts } from "../../utils/redis/deductFromRedisCusEnts.js"; -import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManagerV2.js"; -import { globalEventBatchingManager } from "../eventUtils/EventBatchingManager.js"; -import { constructEvent, type EventInfo } from "../trackUtils/eventUtils.js"; -import { executePostgresTracking } from "../trackUtils/executePostgresTracking.js"; -import type { FeatureDeduction } from "../trackUtils/getFeatureDeductions.js"; -import { getTrackBalancesResponse } from "../trackUtils/getTrackBalancesResponse.js"; - -type RunRedisDeductionParams = { - ctx: AutumnContext; - fullCustomer: FullCustomer; - featureDeductions: FeatureDeduction[]; - overageBehavior: "cap" | "reject"; - body: TrackParams; -}; - -type RedisDeductionResult = Awaited>; - -const isRedisResult = ( - result: - | RedisDeductionResult - | Awaited> - | undefined, -): result is RedisDeductionResult => { - return !!result && "fullCus" in result && !!result.fullCus; -}; - -const queueSyncItem = ({ - ctx, - body, - modifiedCusEntIds, -}: { - ctx: AutumnContext; - body: TrackParams; - modifiedCusEntIds: string[]; -}): void => { - if (modifiedCusEntIds.length === 0) return; - - globalSyncBatchingManagerV2.addSyncItem({ - customerId: body.customer_id, - orgId: ctx.org.id, - env: ctx.env, - cusEntIds: modifiedCusEntIds, - region: currentRegion, - }); -}; - -const queueEvent = ({ - ctx, - body, - fullCustomer, -}: { - ctx: AutumnContext; - body: TrackParams; - fullCustomer: FullCustomer; -}): void => { - if (body.skip_event || body.idempotency_key) return; - - const eventInfo: EventInfo = { - event_name: body.feature_id || body.event_name || "", - value: body.value ?? 1, - properties: body.properties, - timestamp: body.timestamp, - }; - - globalEventBatchingManager.addEvent( - constructEvent({ - ctx, - eventInfo, - internalCustomerId: fullCustomer.internal_id, - internalEntityId: fullCustomer.entity?.internal_id, - customerId: body.customer_id, - entityId: body.entity_id, - }), - ); -}; - -const buildBalancesResponse = ({ - result, - apiCustomer, - featureDeductions, - features, -}: { - result: RedisDeductionResult; - apiCustomer: { balances: Record }; - featureDeductions: FeatureDeduction[]; - features: AutumnContext["features"]; -}) => { - const balancesRes: Record = {}; - - // Add primary features (always - they were requested to be tracked) - for (const deduction of featureDeductions) { - const balance = apiCustomer.balances[deduction.feature.id]; - if (balance) { - balancesRes[deduction.feature.id] = balance; - } - } - - // Add credit systems only if they were actually used - for (const featureId of Object.keys(result.actualDeductions)) { - if (!balancesRes[featureId]) { - const balance = apiCustomer.balances[featureId]; - if (balance) { - balancesRes[featureId] = balance; - } - } - } - - return getTrackBalancesResponse({ - featureDeductions, - features, - balances: balancesRes, - }); -}; - -/** - * Executes deductions against cached customer data in Redis. - * Queues sync to Postgres and event insertion after successful deduction. - */ -export const runRedisDeductionV2 = async ({ - ctx, - fullCustomer, - featureDeductions, - overageBehavior, - body, -}: RunRedisDeductionParams): Promise => { - let result: - | RedisDeductionResult - | Awaited> - | undefined; - - try { - result = await deductFromRedisCusEnts({ - ctx, - fullCus: fullCustomer, - deductions: featureDeductions, - overageBehaviour: overageBehavior || "cap", - entityId: fullCustomer.entity?.id, - }); - } catch (error) { - // Pass through RecaseError (user-facing errors like insufficient_balance) - if (error instanceof RecaseError) { - throw error; - } - - // For InternalError and other errors, check if we should fallback to Postgres - const errorStr = JSON.stringify(error); - const shouldFallback = - errorStr.includes("PAID_ALLOCATED") || - errorStr.includes("CUSTOMER_NOT_FOUND") || - errorStr.includes("customer_not_in_cache"); - - if (shouldFallback) { - ctx.logger.warn(`Falling back to Postgres for track operation.`); - result = await executePostgresTracking({ - ctx, - body, - featureDeductions, - }); - - // Delete stale FullCustomer cache after Postgres fallback - // so subsequent requests fetch fresh data from DB - await deleteCachedFullCustomer({ - customerId: body.customer_id, - orgId: ctx.org.id, - env: ctx.env, - source: "runRedisDeductionV2-postgres-fallback", - }); - } else { - throw error; - } - } - - if (isRedisResult(result)) { - queueSyncItem({ - ctx, - body, - modifiedCusEntIds: result.modifiedCusEntIds, - }); - - queueEvent({ ctx, body, fullCustomer }); - - const { apiCustomer } = await getApiCustomerBase({ - ctx, - fullCus: result.fullCus!, - }); - - const finalBalances = buildBalancesResponse({ - result, - apiCustomer, - featureDeductions, - features: ctx.features, - }); - - return { - customer_id: body.customer_id, - entity_id: body.entity_id, - event_name: body.event_name, - value: body.value ?? 1, - balance: finalBalances.balance, - balances: finalBalances.balances, - }; - } - - // Fallback: result is from executePostgresTracking (already returns TrackResponseV2) - return result as TrackResponseV2; -}; diff --git a/server/src/internal/balances/track/runRedisDeduction/deductionUpdatesToModifiedIds.ts b/server/src/internal/balances/track/runRedisDeduction/deductionUpdatesToModifiedIds.ts new file mode 100644 index 000000000..48dacc702 --- /dev/null +++ b/server/src/internal/balances/track/runRedisDeduction/deductionUpdatesToModifiedIds.ts @@ -0,0 +1,14 @@ +import type { DeductionUpdate } from "../../utils/types/deductionUpdate"; + +/** + * Convert deduction updates to modified customer entitlement IDs. + * @param updates - The deduction updates. + * @returns The modified customer entitlement IDs. + */ +export const deductionUpdatesToModifiedIds = ({ + updates, +}: { + updates: Record; +}): string[] => { + return Object.keys(updates).filter((id) => updates[id].deducted > 0); +}; diff --git a/server/src/internal/balances/track/runRedisDeduction/handleRedisDeductionError.ts b/server/src/internal/balances/track/runRedisDeduction/handleRedisDeductionError.ts new file mode 100644 index 000000000..6935bdbe8 --- /dev/null +++ b/server/src/internal/balances/track/runRedisDeduction/handleRedisDeductionError.ts @@ -0,0 +1,40 @@ +import type { TrackParams, TrackResponseV2 } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { RedisDeductionError } from "../../utils/types/redisDeductionError.js"; +import { executePostgresTracking } from "../trackUtils/executePostgresTracking.js"; +import type { FeatureDeduction } from "../trackUtils/getFeatureDeductions.js"; + +export type HandleRedisDeductionErrorParams = { + ctx: AutumnContext; + error: Error; + body: TrackParams; + featureDeductions: FeatureDeduction[]; +}; + +/** + * Handles errors from Redis deduction. + * - Falls back to Postgres for RedisDeductionError (if shouldFallback) + * - Rethrows all other errors + */ +export const handleRedisDeductionError = async ({ + ctx, + error, + body, + featureDeductions, +}: HandleRedisDeductionErrorParams): Promise => { + // Check if it's a Redis deduction error that should fallback + if (error instanceof RedisDeductionError && error.shouldFallback()) { + ctx.logger.warn( + `Falling back to Postgres for track operation: ${error.code}`, + ); + + return await executePostgresTracking({ + ctx, + body, + featureDeductions, + }); + } + + // All other errors - rethrow + throw error; +}; diff --git a/server/src/internal/balances/track/runRedisDeduction/runRedisDeductionV2.ts b/server/src/internal/balances/track/runRedisDeduction/runRedisDeductionV2.ts new file mode 100644 index 000000000..14a479547 --- /dev/null +++ b/server/src/internal/balances/track/runRedisDeduction/runRedisDeductionV2.ts @@ -0,0 +1,135 @@ +import type { + FullCustomer, + TrackParams, + TrackResponseV2, +} from "@autumn/shared"; +import { tryCatch } from "@autumn/shared"; +import { currentRegion } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { deductionToTrackResponse } from "../../utils/deduction/deductionToTrackResponse.js"; +import { deductFromRedisCusEnts } from "../../utils/redis/deductFromRedisCusEnts.js"; +import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManagerV2.js"; +import type { DeductionUpdate } from "../../utils/types/deductionUpdate"; +import { globalEventBatchingManager } from "../eventUtils/EventBatchingManager.js"; +import { constructEvent, type EventInfo } from "../trackUtils/eventUtils.js"; +import type { FeatureDeduction } from "../trackUtils/getFeatureDeductions.js"; +import { deductionUpdatesToModifiedIds } from "./deductionUpdatesToModifiedIds.js"; +import { handleRedisDeductionError } from "./handleRedisDeductionError.js"; + +type RunRedisDeductionParams = { + ctx: AutumnContext; + fullCustomer: FullCustomer; + featureDeductions: FeatureDeduction[]; + overageBehavior: "cap" | "reject"; + body: TrackParams; +}; + +const queueSyncItem = ({ + ctx, + body, + updates, +}: { + ctx: AutumnContext; + body: TrackParams; + updates: Record; +}): void => { + const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); + if (modifiedCusEntIds.length === 0) return; + + globalSyncBatchingManagerV2.addSyncItem({ + customerId: body.customer_id, + orgId: ctx.org.id, + env: ctx.env, + cusEntIds: modifiedCusEntIds, + region: currentRegion, + }); +}; + +const queueEvent = ({ + ctx, + body, + fullCustomer, +}: { + ctx: AutumnContext; + body: TrackParams; + fullCustomer: FullCustomer; +}): void => { + if (body.skip_event || body.idempotency_key) return; + + const eventInfo: EventInfo = { + event_name: body.feature_id || body.event_name || "", + value: body.value ?? 1, + properties: body.properties, + timestamp: body.timestamp, + }; + + globalEventBatchingManager.addEvent( + constructEvent({ + ctx, + eventInfo, + internalCustomerId: fullCustomer.internal_id, + internalEntityId: fullCustomer.entity?.internal_id, + customerId: body.customer_id, + entityId: body.entity_id, + }), + ); +}; + +/** + * Executes deductions against cached customer data in Redis. + * Queues sync to Postgres and event insertion after successful deduction. + */ +export const runRedisDeductionV2 = async ({ + ctx, + fullCustomer, + featureDeductions, + overageBehavior, + body, +}: RunRedisDeductionParams): Promise => { + const { data: result, error } = await tryCatch( + deductFromRedisCusEnts({ + ctx, + fullCustomer, + entityId: fullCustomer.entity?.id, + deductions: featureDeductions, + overageBehaviour: overageBehavior || "cap", + }), + ); + + // Handle error (fallback to Postgres or rethrow) + if (error) { + return handleRedisDeductionError({ + ctx, + error, + body, + featureDeductions, + }); + } + + const { updates, fullCus } = result; + + // Queue sync and event + queueSyncItem({ + ctx, + body, + updates, + }); + + queueEvent({ ctx, body, fullCustomer }); + + const { balance, balances } = await deductionToTrackResponse({ + ctx, + fullCus: fullCus!, + featureDeductions, + updates, + }); + + return { + customer_id: body.customer_id, + entity_id: body.entity_id, + event_name: body.event_name, + value: body.value ?? 1, + balance, + balances, + }; +}; diff --git a/server/src/internal/balances/track/runTrackV2.ts b/server/src/internal/balances/track/runTrackV2.ts index f8cb28124..a9b7d715c 100644 --- a/server/src/internal/balances/track/runTrackV2.ts +++ b/server/src/internal/balances/track/runTrackV2.ts @@ -11,7 +11,7 @@ import { import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import { EventService } from "../../api/events/EventService.js"; import { getOrCreateCachedFullCustomer } from "../../customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js"; -import { runRedisDeductionV2 } from "./redisTrackUtils/runRedisDeductionV2.js"; +import { runRedisDeductionV2 } from "./runRedisDeduction/runRedisDeductionV2.js"; import { constructEvent, type EventInfo } from "./trackUtils/eventUtils.js"; import type { FeatureDeduction } from "./trackUtils/getFeatureDeductions.js"; @@ -39,10 +39,7 @@ export const runTrackV2 = async ({ // 1. Get full customer from cache or DB const fullCustomer = await getOrCreateCachedFullCustomer({ ctx, - customerId: body.customer_id, - customerData: body.customer_data, - entityId: body.entity_id, - entityData: body.entity_data, + params: body, source: "runTrackV2", }); @@ -75,7 +72,6 @@ export const runTrackV2 = async ({ } // Try Redis deduction - console.log("Deducting from Redis..."); const response = await runRedisDeductionV2({ ctx, fullCustomer, diff --git a/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts b/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts index 1cd6cb0c3..ae7aa0d05 100644 --- a/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts +++ b/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts @@ -1,15 +1,6 @@ -import { - type Feature, - FeatureNotFoundError, - RecaseError, -} from "@autumn/shared"; +import { FeatureNotFoundError, RecaseError } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; - -export type FeatureDeduction = { - feature: Feature; - deduction: number; - targetBalance?: number; -}; +import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; const DEFAULT_VALUE = 1; diff --git a/server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts b/server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts index 5da47a736..6168893a7 100644 --- a/server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts +++ b/server/src/internal/balances/track/trackUtils/handlePaidAllocatedCusEnt.ts @@ -2,12 +2,12 @@ import { cusProductsToCusPrices, type FullCusEntWithFullCusProduct, type FullCustomer, - type PgDeductionUpdate, } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { adjustAllowance } from "../../../../trigger/adjustAllowance"; import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService"; import { getTotalNegativeBalance } from "../../../customers/cusProducts/cusEnts/cusEntUtils"; +import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; export const handlePaidAllocatedCusEnt = async ({ ctx, @@ -18,7 +18,7 @@ export const handlePaidAllocatedCusEnt = async ({ ctx: AutumnContext; cusEnt: FullCusEntWithFullCusProduct; fullCus: FullCustomer; - updates: Record; + updates: Record; }) => { const { db, env, org } = ctx; diff --git a/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts b/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts index 07a4c16dc..733ca5d5e 100644 --- a/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts +++ b/server/src/internal/balances/track/trackUtils/rollbackDeduction.ts @@ -1,10 +1,7 @@ -import { - cusProductsToCusEnts, - type FullCustomer, - type PgDeductionUpdate, -} from "@autumn/shared"; +import { cusProductsToCusEnts, type FullCustomer } from "@autumn/shared"; import type { AutumnContext } from "../../../../honoUtils/HonoEnv"; import { CusEntService } from "../../../customers/cusProducts/cusEnts/CusEntitlementService"; +import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; export const rollbackDeduction = async ({ ctx, @@ -13,7 +10,7 @@ export const rollbackDeduction = async ({ }: { ctx: AutumnContext; oldFullCus: FullCustomer; - updates: Record; + updates: Record; }) => { const { db, logger } = ctx; diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts index 53bd5a156..6fa79f0d6 100644 --- a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -1,8 +1,4 @@ -import type { - Event, - PgDeductionUpdate, - SortCusEntParams, -} from "@autumn/shared"; +import type { Event, SortCusEntParams } from "@autumn/shared"; import { CusProductStatus, cusEntToCusPrice, @@ -16,7 +12,6 @@ import { notNullish, nullish, orgToInStatuses, - updateCusEntInFullCus, } from "@autumn/shared"; import { Decimal } from "decimal.js"; import { sql } from "drizzle-orm"; @@ -28,6 +23,8 @@ import { getUnlimitedAndUsageAllowed } from "../../../customers/cusProducts/cusE import { deleteCachedApiCustomer } from "../../../customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; import { getCreditCost } from "../../../features/creditSystemUtils.js"; import { isPaidContinuousUse } from "../../../features/featureUtils.js"; +import { applyDeductionUpdateToFullCustomer } from "../../utils/deduction/applyDeductionUpdateToFullCustomer.js"; +import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; import { constructEvent, type EventInfo } from "./eventUtils.js"; import type { FeatureDeduction } from "./getFeatureDeductions.js"; import { handlePaidAllocatedCusEnt } from "./handlePaidAllocatedCusEnt.js"; @@ -228,7 +225,7 @@ export const deductFromCusEnts = async ({ // Parse the JSONB result const resultJson = result[0]?.deduct_from_cus_ents as { - updates: Record; + updates: Record; remaining: number; }; @@ -302,7 +299,7 @@ export const deductFromCusEnts = async ({ updates, }); - updateCusEntInFullCus({ + applyDeductionUpdateToFullCustomer({ fullCus, cusEntId, update, diff --git a/server/src/internal/balances/updateBalance/runUpdateBalance.ts b/server/src/internal/balances/updateBalance/runUpdateBalance.ts index 8f9330b18..ed9ff6ac7 100644 --- a/server/src/internal/balances/updateBalance/runUpdateBalance.ts +++ b/server/src/internal/balances/updateBalance/runUpdateBalance.ts @@ -10,7 +10,7 @@ import { getCachedApiEntity } from "@/internal/entities/entityUtils/apiEntityCac import type { AutumnContext } from "../../../honoUtils/HonoEnv.js"; import type { BatchRequestFilters } from "../track/redisTrackUtils/executeBatchDeduction.js"; import { runDeductionTx } from "../track/trackUtils/runDeductionTx.js"; -import { syncItemV2 } from "../utils/sync/syncItemV2.js"; +import { syncItemV2 } from "../utils/sync/legacy/syncItemV2.js"; import { runRedisUpdateBalance } from "./runRedisUpdateBalance.js"; /** diff --git a/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts new file mode 100644 index 000000000..83d8a4b0d --- /dev/null +++ b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToFullCustomer.ts @@ -0,0 +1,50 @@ +import type { FullCustomer } from "@autumn/shared"; +import type { DeductionUpdate } from "../types/deductionTypes.js"; + +export const applyDeductionUpdateToFullCustomer = ({ + fullCus, + cusEntId, + update, +}: { + fullCus: FullCustomer; + cusEntId: string; + update: DeductionUpdate; +}) => { + for (let i = 0; i < fullCus.customer_products.length; i++) { + for ( + let j = 0; + j < fullCus.customer_products[i].customer_entitlements.length; + j++ + ) { + const ce = fullCus.customer_products[i].customer_entitlements[j]; + if (ce.id === cusEntId) { + let replaceables = ce.replaceables ?? []; + + if (update.newReplaceables) { + replaceables = [ + ...replaceables, + ...update.newReplaceables.map((r) => ({ + ...r, + delete_next_cycle: r.delete_next_cycle ?? true, + from_entity_id: r.from_entity_id ?? null, + })), + ]; + } + + if (update.deletedReplaceables) { + replaceables = replaceables.filter( + (r) => !update.deletedReplaceables?.map((r) => r.id).includes(r.id), + ); + } + + fullCus.customer_products[i].customer_entitlements[j] = { + ...ce, + balance: update.balance, + entities: update.entities, + adjustment: update.adjustment, + replaceables, + }; + } + } + } +}; diff --git a/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts new file mode 100644 index 000000000..b9787c172 --- /dev/null +++ b/server/src/internal/balances/utils/deduction/deductionToTrackResponse.ts @@ -0,0 +1,150 @@ +import type { ApiBalance, FullCustomer } from "@autumn/shared"; +import { + cusProductsToCusEnts, + findCustomerEntitlementById, + getRelevantFeatures, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; +import type { FeatureDeduction } from "../../track/trackUtils/getFeatureDeductions.js"; +import type { DeductionUpdate } from "../types/deductionUpdate.js"; + +type TrackBalanceResponse = { + balance: ApiBalance | null; + balances?: Record; +}; + +/** + * Convert updates keyed by cusEntId to actualDeductions keyed by featureId. + * Looks up each cusEntId in fullCus to determine its feature. + */ +export const computeActualDeductions = ({ + fullCus, + updates, +}: { + fullCus: FullCustomer; + updates: Record; +}): Record => { + const actualDeductions: Record = {}; + + const customerEntitlements = cusProductsToCusEnts({ + cusProducts: fullCus.customer_products, + }); + + for (const cusEntId of Object.keys(updates)) { + const update = updates[cusEntId]; + + const cusEnt = findCustomerEntitlementById({ + cusEnts: customerEntitlements, + id: cusEntId, + errorOnNotFound: true, + }); + + const featureId = cusEnt.entitlement.feature.id; + + // Accumulate deductions per feature + const currentDeduction = actualDeductions[featureId] || 0; + actualDeductions[featureId] = new Decimal(currentDeduction) + .plus(update.deducted) + .toNumber(); + } + + return actualDeductions; +}; + +/** + * Determines which feature's balance to return for a given featureDeduction. + * Prefers credit systems that were actually deducted from, else falls back to the main feature. + */ +const getFeatureToUseForBalance = ({ + featureDeduction, + features, + actualDeductions, +}: { + featureDeduction: FeatureDeduction; + features: AutumnContext["features"]; + actualDeductions: Record; +}): string => { + const relevantFeatures = getRelevantFeatures({ + features, + featureId: featureDeduction.feature.id, + }); + + // Find first feature that had an actual deduction + const featureWithDeduction = relevantFeatures.find( + (f) => (actualDeductions[f.id] ?? 0) > 0, + ); + + if (featureWithDeduction) { + return featureWithDeduction.id; + } + + // If no deduction occurred, prefer a credit system (if exists), else main feature + const creditSystem = relevantFeatures.find( + (f) => f.id !== featureDeduction.feature.id, + ); + return creditSystem?.id ?? featureDeduction.feature.id; +}; + +/** + * Builds the track response balances from deduction updates. + * Unifies the balance response logic from Redis and Postgres deduction paths. + */ +export const deductionToTrackResponse = async ({ + ctx, + fullCus, + featureDeductions, + updates, +}: { + ctx: AutumnContext; + fullCus: FullCustomer; + featureDeductions: FeatureDeduction[]; + updates: Record; +}): Promise => { + // 1. Compute actual deductions per feature from the raw updates + const actualDeductions = computeActualDeductions({ fullCus, updates }); + + // 2. Get API customer with balances + const { apiCustomer } = await getApiCustomerBase({ + ctx, + fullCus, + }); + + // 3. Build balances response + const finalBalances: Record = {}; + + // Add primary features (always - they were requested to be tracked) + for (const deduction of featureDeductions) { + const featureToUse = getFeatureToUseForBalance({ + featureDeduction: deduction, + features: ctx.features, + actualDeductions, + }); + + const balance = apiCustomer.balances[featureToUse]; + if (balance) { + finalBalances[featureToUse] = balance; + } + } + + // 5. Return appropriate response based on number of balances + if (Object.keys(finalBalances).length === 0) { + return { + balance: null, + balances: undefined, + }; + } + + if (Object.keys(finalBalances).length === 1) { + return { + balance: Object.values(finalBalances)[0], + balances: undefined, + }; + } + + return { + balance: null, + balances: finalBalances, + }; +}; diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts new file mode 100644 index 000000000..f25381b3a --- /dev/null +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -0,0 +1,128 @@ +import { + cusEntToStartingBalance, + cusProductsToCusEnts, + type FullCustomer, + getMaxOverage, + getRelevantFeatures, + isAllocatedCustomerEntitlement, + isFreeCustomerEntitlement, + notNullish, + orgToInStatuses, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import type { + CustomerEntitlementDeduction, + DeductionOptions, + FeatureDeduction, + PreparedFeatureDeduction, +} from "../types/deductionTypes.js"; + +/** + * Prepares all the inputs needed to execute a deduction for a single feature. + * Shared by both Redis (Lua) and Postgres (SQL) deduction paths. + */ +export const prepareFeatureDeduction = ({ + ctx, + fullCustomer, + deduction, + options = {}, +}: { + ctx: AutumnContext; + fullCustomer: FullCustomer; + deduction: FeatureDeduction; + options?: DeductionOptions; +}): PreparedFeatureDeduction => { + const { org } = ctx; + const { feature, deduction: toDeduct, targetBalance } = deduction; + + const { + overageBehaviour = "cap", + addToAdjustment = false, + sortParams, + } = options; + + // Get relevant features (just the feature itself if targetBalance is set) + const relevantFeatures = notNullish(targetBalance) + ? [feature] + : getRelevantFeatures({ + features: ctx.features, + featureId: feature.id, + }); + + // Get customer entitlements for these features + const cusEnts = cusProductsToCusEnts({ + cusProducts: fullCustomer.customer_products, + featureIds: relevantFeatures.map((f) => f.id), + reverseOrder: org.config?.reverse_deduction_order, + entity: fullCustomer.entity, + inStatuses: orgToInStatuses({ org }), + sortParams, + }); + + // Check if ANY relevant feature is unlimited + let unlimited = false; + const unlimitedFeatureIds: string[] = []; + + for (const rf of relevantFeatures) { + const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ + cusEnts, + internalFeatureId: rf.internal_id!, + }); + if (featureUnlimited) { + unlimited = true; + unlimitedFeatureIds.push(rf.id); + } + } + + // Build input for each customer entitlement + const customerEntitlementDeductions: CustomerEntitlementDeduction[] = + cusEnts.map((ce) => { + const creditCost = getCreditCost({ + featureId: feature.id, + creditSystem: ce.entitlement.feature, + }); + + const maxOverage = getMaxOverage({ cusEnt: ce }); + + const isFreeAllocated = + isFreeCustomerEntitlement(ce) && isAllocatedCustomerEntitlement(ce); + + const resetBalance = cusEntToStartingBalance({ cusEnt: ce }); + + return { + customer_entitlement_id: ce.id, + credit_cost: creditCost, + entity_feature_id: ce.entitlement.entity_feature_id ?? null, + usage_allowed: + ce.usage_allowed || + (isFreeAllocated && overageBehaviour !== "reject"), + min_balance: notNullish(maxOverage) ? -maxOverage : undefined, + max_balance: resetBalance, + add_to_adjustment: addToAdjustment, + }; + }); + + // Collect and sort rollovers by expires_at (oldest first) + const sortedRollovers = cusEnts + .flatMap((ce) => ce.rollovers || []) + .sort((a, b) => { + if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; + if (a.expires_at && !b.expires_at) return -1; + if (!a.expires_at && b.expires_at) return 1; + return 0; + }); + + return { + customerEntitlements: cusEnts, + customerEntitlementDeductions, + rolloverIds: sortedRollovers.map((r) => r.id), + // cusEnts, + // cusEntInput, + // rolloverIds, + // cusEntIds, + // unlimited, + // unlimitedFeatureIds, + }; +}; diff --git a/server/src/internal/balances/utils/redis/customerLock.ts b/server/src/internal/balances/utils/redis/customerLock.ts deleted file mode 100644 index 27fbe3fc2..000000000 --- a/server/src/internal/balances/utils/redis/customerLock.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { redis } from "@/external/redis/initRedis.js"; - -const LOCK_TTL_MS = 5000; // 5 second max lock hold -const LOCK_RETRY_DELAY_MS = 10; -const LOCK_MAX_RETRIES = 500; // 5 seconds total wait - -const acquireLock = async (lockKey: string): Promise => { - const result = await redis.set(lockKey, "1", "PX", LOCK_TTL_MS, "NX"); - return result === "OK"; -}; - -const releaseLock = async (lockKey: string): Promise => { - await redis.del(lockKey); -}; - -/** - * Execute a function while holding a lock for a specific customer. - * Ensures only one operation runs at a time per customer to prevent race conditions. - */ -export const withCustomerLock = async ({ - orgId, - env, - customerId, - fn, -}: { - orgId: string; - env: string; - customerId: string; - fn: () => Promise; -}): Promise => { - const lockKey = `lock:${orgId}:${env}:${customerId}`; - - // Acquire lock with retry - let acquired = false; - for (let i = 0; i < LOCK_MAX_RETRIES; i++) { - acquired = await acquireLock(lockKey); - if (acquired) break; - await new Promise((r) => setTimeout(r, LOCK_RETRY_DELAY_MS)); - } - - if (!acquired) { - throw new Error(`Failed to acquire customer lock for ${customerId}`); - } - - try { - return await fn(); - } finally { - await releaseLock(lockKey); - } -}; diff --git a/server/src/internal/balances/utils/redis/deductFromRedisCusEnts.ts b/server/src/internal/balances/utils/redis/deductFromRedisCusEnts.ts index bf5f76344..a650e5a55 100644 --- a/server/src/internal/balances/utils/redis/deductFromRedisCusEnts.ts +++ b/server/src/internal/balances/utils/redis/deductFromRedisCusEnts.ts @@ -1,31 +1,24 @@ -import type { PgDeductionUpdate, SortCusEntParams } from "@autumn/shared"; -import { - cusEntToCusPrice, - cusEntToStartingBalance, - cusProductsToCusEnts, - ErrCode, - FeatureUsageType, - type FullCustomer, - getMaxOverage, - getRelevantFeatures, - InternalError, - notNullish, - nullish, - orgToInStatuses, - RecaseError, - updateCusEntInFullCus, +import type { + FullCusEntWithFullCusProduct, + FullCustomer, + SortCusEntParams, } from "@autumn/shared"; -import { Decimal } from "decimal.js"; +import { DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT } from "@/_luaScriptsV2/luaScriptsV2.js"; import { redis } from "@/external/redis/initRedis.js"; -import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; -import { getUnlimitedAndUsageAllowed } from "../../../customers/cusProducts/cusEnts/cusEntUtils.js"; -import { buildFullCustomerCacheKey } from "../../../customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; -import { getCreditCost } from "../../../features/creditSystemUtils.js"; -import { isPaidContinuousUse } from "../../../features/featureUtils.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; +import { isPaidContinuousUse } from "@/internal/features/featureUtils.js"; import type { FeatureDeduction } from "../../track/trackUtils/getFeatureDeductions.js"; import { handlePaidAllocatedCusEnt } from "../../track/trackUtils/handlePaidAllocatedCusEnt.js"; import { rollbackDeduction } from "../../track/trackUtils/rollbackDeduction.js"; -import { DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT } from "./luaScriptsV2.js"; +import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js"; +import { prepareFeatureDeduction } from "../deduction/prepareFeatureDeduction.js"; +import type { DeductionUpdate } from "../types/deductionUpdate"; +import { + RedisDeductionError, + type RedisDeductionErrorCode, +} from "../types/redisDeductionError.js"; +import type { LuaDeductionResult } from "../types/redisDeductionResult.js"; export type RedisDeductionParams = { ctx: AutumnContext; @@ -35,42 +28,31 @@ export type RedisDeductionParams = { addToAdjustment?: boolean; skipAdditionalBalance?: boolean; alterGrantedBalance?: boolean; - fullCus: FullCustomer; + fullCustomer: FullCustomer; sortParams?: SortCusEntParams; }; -interface LuaDeductionResult { - updates: Record; - remaining: number; - error?: string; - feature_id?: string; - logs?: string[]; -} - export const deductFromRedisCusEnts = async ({ ctx, entityId, deductions, + fullCustomer, + sortParams, overageBehaviour = "cap", addToAdjustment = false, skipAdditionalBalance = true, - fullCus, - sortParams, }: RedisDeductionParams): Promise<{ oldFullCus: FullCustomer; fullCus: FullCustomer | undefined; - isPaidAllocated: boolean; - actualDeductions: Record; - remainingAmounts: Record; - modifiedCusEntIds: string[]; + updates: Record; }> => { const { org, env } = ctx; - const oldFullCus = structuredClone(fullCus); + const oldFullCus = structuredClone(fullCustomer); const isPaidAllocated = deductions.some((d) => isPaidContinuousUse({ feature: d.feature, - fullCus: fullCus!, + fullCus: fullCustomer!, }), ); @@ -79,12 +61,10 @@ export const deductFromRedisCusEnts = async ({ skipAdditionalBalance = true; } - const actualDeductions: Record = {}; - const remainingAmounts: Record = {}; - const modifiedCusEntIds: string[] = []; + let allUpdates: Record = {}; // Build cache key - const customerId = fullCus.id || fullCus.internal_id; + const customerId = fullCustomer.id || fullCustomer.internal_id; const cacheKey = buildFullCustomerCacheKey({ orgId: org.id, env, @@ -94,107 +74,31 @@ export const deductFromRedisCusEnts = async ({ for (const deduction of deductions) { const { feature, deduction: toDeduct, targetBalance } = deduction; - const relevantFeatures = notNullish(targetBalance) - ? [feature] - : getRelevantFeatures({ - features: ctx.features, - featureId: feature.id, - }); - - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCus.customer_products, - featureIds: relevantFeatures.map((f) => f.id), - reverseOrder: org.config?.reverse_deduction_order, - entity: fullCus.entity, - inStatuses: orgToInStatuses({ org }), - sortParams, - }); - - // Check if ANY relevant feature is unlimited - let unlimited = false; - for (const rf of relevantFeatures) { - const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ - cusEnts, - internalFeatureId: rf.internal_id!, + const { customerEntitlementDeductions, rolloverIds, customerEntitlements } = + prepareFeatureDeduction({ + ctx, + fullCustomer, + deduction, + options: { + overageBehaviour, + addToAdjustment, + sortParams, + skipAdditionalBalance, + }, }); - if (featureUnlimited) { - unlimited = true; - if (actualDeductions[rf.id] === undefined) { - actualDeductions[rf.id] = 0; - } - } - } - - if (cusEnts.length === 0 || unlimited) continue; - - const cusEntInput = cusEnts.map((ce) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: ce.entitlement.feature, - }); - - const maxOverage = getMaxOverage({ cusEnt: ce }); - - const cusPrice = cusEntToCusPrice({ cusEnt: ce }); - const isFreeAllocated = - ce.entitlement.feature.config?.usage_type === - FeatureUsageType.Continuous && nullish(cusPrice); - - const resetBalance = cusEntToStartingBalance({ cusEnt: ce }); - - return { - customer_entitlement_id: ce.id, - credit_cost: creditCost, - entity_feature_id: ce.entitlement.entity_feature_id, - usage_allowed: - ce.usage_allowed || - (isFreeAllocated && overageBehaviour !== "reject"), - min_balance: notNullish(maxOverage) ? -maxOverage : undefined, - add_to_adjustment: addToAdjustment, - max_balance: resetBalance, - }; - }); - - // Collect and sort rollovers by expires_at (oldest first) - const sortedRollovers = cusEnts - .flatMap((ce) => ce.rollovers || []) - .sort((a, b) => { - if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at; - if (a.expires_at && !b.expires_at) return -1; - if (!a.expires_at && b.expires_at) return 1; - return 0; - }); - - const rolloverIds = sortedRollovers.map((r) => r.id); - const cusEntIds = cusEntInput.map((ce) => ce.customer_entitlement_id); // Call Lua script to deduct from FullCustomer in Redis const luaParams = { - sorted_entitlements: cusEntInput, + sorted_entitlements: customerEntitlementDeductions, amount_to_deduct: toDeduct ?? null, target_balance: targetBalance ?? null, target_entity_id: entityId || null, rollover_ids: rolloverIds.length > 0 ? rolloverIds : null, - cus_ent_ids: cusEntIds.length > 0 ? cusEntIds : null, skip_additional_balance: skipAdditionalBalance, overage_behaviour: overageBehaviour ?? "cap", feature_id: feature.id, }; - // Log what's in Redis BEFORE the Lua script runs - const preRedisState = await redis.call( - "JSON.GET", - cacheKey, - "$.customer_products[0].customer_entitlements[0].entities", - ); - console.log( - `[deductFromRedisCusEnts] PRE-LUA Redis state for ${entityId}:`, - preRedisState, - ); - console.log( - `[deductFromRedisCusEnts] Lua params: amount_to_deduct=${luaParams.amount_to_deduct}, target_balance=${luaParams.target_balance}`, - ); - const result = (await redis.eval( DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, 1, // number of keys @@ -202,106 +106,37 @@ export const deductFromRedisCusEnts = async ({ JSON.stringify(luaParams), // ARGV[1] )) as string; - // Log what's in Redis AFTER the Lua script runs - const postRedisState = await redis.call( - "JSON.GET", - cacheKey, - "$.customer_products[0].customer_entitlements[0].entities", - ); - console.log( - `[deductFromRedisCusEnts] POST-LUA Redis state for ${entityId}:`, - postRedisState, - ); - const resultJson = JSON.parse(result) as LuaDeductionResult; - // Log Lua debug output - if (resultJson.logs && resultJson.logs.length > 0) { - console.log("\n========== LUA LOGS =========="); - for (const log of resultJson.logs) { - console.log(log); - } - console.log("==============================\n"); - } - - // Handle errors from Lua script - if (resultJson.error === "CUSTOMER_NOT_FOUND") { - throw new InternalError({ - message: `FullCustomer not found in cache: ${customerId}`, - code: "customer_not_in_cache", + if (resultJson.error) { + throw new RedisDeductionError({ + message: `Redis deduction failed: ${resultJson.error}`, + code: resultJson.error as RedisDeductionErrorCode, }); } - if (resultJson.error === "INSUFFICIENT_BALANCE") { - throw new RecaseError({ - message: `Insufficient balance for feature ${resultJson.feature_id}`, - code: ErrCode.InsufficientBalance, - statusCode: 402, - }); - } - - const { updates, remaining: featureRemaining } = resultJson; - - // Track remaining amount - remainingAmounts[feature.id] = featureRemaining; - - // Calculate total deducted from the updates - const totalDeducted = Object.values(updates).reduce( - (sum, update) => sum + update.deducted, - 0, - ); - - // Convert updates to actual deductions and collect modified cusEntIds - for (const [cusEntId, update] of Object.entries(updates)) { - modifiedCusEntIds.push(cusEntId); - - const cusEnt = cusEnts.find((ce) => ce.id === cusEntId); - const deductedFeature = cusEnt?.entitlement.feature; - if (!deductedFeature) continue; - - const currentDeduction = actualDeductions[deductedFeature.id] || 0; - actualDeductions[deductedFeature.id] = new Decimal(update.deducted) - .add(currentDeduction) - .toNumber(); - } - - // Log deduction details - if (targetBalance !== undefined) { - const entityInfo = entityId - ? `; Entity: ${entityId}` - : "Entity: customer-level"; - ctx.logger.info(`[Redis Sync]; Feature ${feature.id} | ${entityInfo}`, { - data: { - featureId: feature.id, - entityInfo, - totalDeducted, - updates: Object.keys(updates).length, - remaining: featureRemaining, - }, - }); - } else { - ctx.logger.info( - `[Redis Track]; Deducted ${totalDeducted} from feature ${feature.id}. Updated ${Object.keys(updates).length} entitlements. Remaining: ${featureRemaining}`, - ); - } + const { updates } = resultJson; + allUpdates = { ...allUpdates, ...updates }; // Handle paid allocated entitlements and update fullCus in memory try { for (const cusEntId of Object.keys(updates)) { const update = updates[cusEntId]; - const cusEnt = cusEnts.find((ce) => ce.id === cusEntId); + const cusEnt = customerEntitlements.find( + (ce: FullCusEntWithFullCusProduct) => ce.id === cusEntId, + ); if (!cusEnt) continue; await handlePaidAllocatedCusEnt({ ctx, cusEnt, - fullCus, + fullCus: fullCustomer, updates, }); - updateCusEntInFullCus({ - fullCus, + applyDeductionUpdateToFullCustomer({ + fullCus: fullCustomer, cusEntId, update, }); @@ -323,10 +158,81 @@ export const deductFromRedisCusEnts = async ({ return { oldFullCus, - fullCus, - actualDeductions, - remainingAmounts, - isPaidAllocated, - modifiedCusEntIds, + fullCus: fullCustomer, + updates: allUpdates, }; }; + +// // Log Lua debug output +// if (resultJson.logs && resultJson.logs.length > 0) { +// console.log("\n========== LUA LOGS =========="); +// for (const log of resultJson.logs) { +// console.log(log); +// } +// console.log("==============================\n"); +// } + +// // Log what's in Redis BEFORE the Lua script runs +// const preRedisState = await redis.call( +// "JSON.GET", +// cacheKey, +// "$.customer_products[0].customer_entitlements[0].entities", +// ); +// console.log( +// `[deductFromRedisCusEnts] PRE-LUA Redis state for ${entityId}:`, +// preRedisState, +// ); +// console.log( +// `[deductFromRedisCusEnts] Lua params: amount_to_deduct=${luaParams.amount_to_deduct}, target_balance=${luaParams.target_balance}`, +// ); + +// Log what's in Redis AFTER the Lua script runs +// const postRedisState = await redis.call( +// "JSON.GET", +// cacheKey, +// "$.customer_products[0].customer_entitlements[0].entities", +// ); +// console.log( +// `[deductFromRedisCusEnts] POST-LUA Redis state for ${entityId}:`, +// postRedisState, +// ); + +// // Calculate total deducted from the updates +// const totalDeducted = Object.values(updates).reduce( +// (sum, update) => sum + update.deducted, +// 0, +// ); + +// // Convert updates to actual deductions and collect modified cusEntIds +// for (const [cusEntId, update] of Object.entries(updates)) { +// modifiedCusEntIds.push(cusEntId); + +// const cusEnt = customerEntitlements.find((ce) => ce.id === cusEntId); +// const deductedFeature = cusEnt?.entitlement.feature; +// if (!deductedFeature) continue; + +// const currentDeduction = actualDeductions[deductedFeature.id] || 0; +// actualDeductions[deductedFeature.id] = new Decimal(update.deducted) +// .add(currentDeduction) +// .toNumber(); +// } + +// // Log deduction details +// if (targetBalance !== undefined) { +// const entityInfo = entityId +// ? `; Entity: ${entityId}` +// : "Entity: customer-level"; +// ctx.logger.info(`[Redis Sync]; Feature ${feature.id} | ${entityInfo}`, { +// data: { +// featureId: feature.id, +// entityInfo, +// totalDeducted, +// updates: Object.keys(updates).length, +// remaining: featureRemaining, +// }, +// }); +// } else { +// ctx.logger.info( +// `[Redis Track]; Deducted ${totalDeducted} from feature ${feature.id}. Updated ${Object.keys(updates).length} entitlements. Remaining: ${featureRemaining}`, +// ); +// } diff --git a/server/src/internal/balances/utils/sql/syncBalancesV2.sql b/server/src/internal/balances/utils/sql/syncBalancesV2.sql new file mode 100644 index 000000000..f4864d927 --- /dev/null +++ b/server/src/internal/balances/utils/sql/syncBalancesV2.sql @@ -0,0 +1,74 @@ +-- Sync balances from Redis cache to Postgres (V2 - simplified) +-- +-- Params (JSONB): +-- customer_entitlement_updates: array of objects with: +-- - customer_entitlement_id: string +-- - balance: number +-- - adjustment: number +-- - entities: jsonb (the full entities object) +-- +-- Returns JSONB with: +-- updates: object mapping customer_entitlement_id -> { balance, adjustment, entities } +-- +DROP FUNCTION IF EXISTS sync_balances_v2(jsonb); + +CREATE FUNCTION sync_balances_v2(params jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + customer_entitlement_updates jsonb := params->'customer_entitlement_updates'; + + ent_obj jsonb; + ent_id text; + ent_balance numeric; + ent_adjustment numeric; + ent_entities jsonb; + + updates_json jsonb := '{}'::jsonb; + cus_ent_ids text[]; +BEGIN + -- Extract all customer_entitlement_ids and lock rows upfront + SELECT ARRAY( + SELECT jsonb_array_elements_text( + jsonb_path_query_array(customer_entitlement_updates, '$[*].customer_entitlement_id') + ) + ) INTO cus_ent_ids; + + 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; + + -- Iterate and update each entitlement + FOR ent_obj IN SELECT * FROM jsonb_array_elements(customer_entitlement_updates) + LOOP + ent_id := ent_obj->>'customer_entitlement_id'; + ent_balance := (ent_obj->>'balance')::numeric; + ent_adjustment := (ent_obj->>'adjustment')::numeric; + ent_entities := ent_obj->'entities'; + + -- Update the customer_entitlement row directly + UPDATE customer_entitlements ce + SET + balance = COALESCE(ent_balance, ce.balance), + adjustment = COALESCE(ent_adjustment, ce.adjustment), + entities = COALESCE(ent_entities, ce.entities) + WHERE ce.id = ent_id; + + -- Track update + IF FOUND THEN + updates_json := jsonb_set( + updates_json, + ARRAY[ent_id], + jsonb_build_object( + 'balance', ent_balance, + 'adjustment', ent_adjustment, + 'entities', ent_entities + ) + ); + END IF; + END LOOP; + + RETURN jsonb_build_object('updates', updates_json); +END; +$$; diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts index f8d4de572..0ec22aa37 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts @@ -170,6 +170,7 @@ export class SyncBatchingManagerV2 { payload: { orgId: context.orgId, env: context.env, + customerId: context.customerId, item: { customerId: context.customerId, orgId: context.orgId, diff --git a/server/src/internal/balances/utils/sync/runSyncBalanceBatch.ts b/server/src/internal/balances/utils/sync/legacy/runSyncBalanceBatch.ts similarity index 100% rename from server/src/internal/balances/utils/sync/runSyncBalanceBatch.ts rename to server/src/internal/balances/utils/sync/legacy/runSyncBalanceBatch.ts diff --git a/server/src/internal/balances/utils/sync/syncItem.ts b/server/src/internal/balances/utils/sync/legacy/syncItem.ts similarity index 94% rename from server/src/internal/balances/utils/sync/syncItem.ts rename to server/src/internal/balances/utils/sync/legacy/syncItem.ts index ec1daf7fc..8c4c142de 100644 --- a/server/src/internal/balances/utils/sync/syncItem.ts +++ b/server/src/internal/balances/utils/sync/legacy/syncItem.ts @@ -18,16 +18,18 @@ import { } from "@autumn/shared"; import chalk from "chalk"; import { Decimal } from "decimal.js"; +import { CACHE_CUSTOMER_VERSIONS } from "@/_luaScripts/cacheConfig.js"; + import { getRegionalRedis } 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 { getCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js"; -import { CACHE_CUSTOMER_VERSIONS } from "../../../../_luaScripts/cacheConfig.js"; -import { handleThresholdReached } from "../../../../trigger/handleThresholdReached.js"; -import { getCachedApiEntity } from "../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js"; -import type { FeatureDeduction } from "../../track/trackUtils/getFeatureDeductions.js"; -import { deductFromCusEnts } from "../../track/trackUtils/runDeductionTx.js"; + +import { handleThresholdReached } from "@/trigger/handleThresholdReached.js"; +import { getCachedApiEntity } from "../../../../entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity"; +import { deductFromCusEnts } from "../../../track/trackUtils/runDeductionTx"; +import type { FeatureDeduction } from "../../types/deductionTypes"; export interface SyncItem { customerId: string; diff --git a/server/src/internal/balances/utils/sync/syncItemV2.ts b/server/src/internal/balances/utils/sync/legacy/syncItemV2.ts similarity index 98% rename from server/src/internal/balances/utils/sync/syncItemV2.ts rename to server/src/internal/balances/utils/sync/legacy/syncItemV2.ts index 54e218654..aad8c9468 100644 --- a/server/src/internal/balances/utils/sync/syncItemV2.ts +++ b/server/src/internal/balances/utils/sync/legacy/syncItemV2.ts @@ -19,7 +19,7 @@ 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 { getCachedApiEntity } from "@/internal/entities/entityUtils/apiEntityCacheUtils/getCachedApiEntity.js"; export interface SyncItemV2 { customerId: string; diff --git a/server/src/internal/balances/utils/sync/syncItemV3.ts b/server/src/internal/balances/utils/sync/syncItemV3.ts index b8ef95bfa..a5d45ec6c 100644 --- a/server/src/internal/balances/utils/sync/syncItemV3.ts +++ b/server/src/internal/balances/utils/sync/syncItemV3.ts @@ -1,4 +1,9 @@ -import type { FullCustomer, FullCustomerEntitlement } from "@autumn/shared"; +import { + cusProductsToCusEnts, + type EntityBalance, + type FullCustomer, + findCustomerEntitlementById, +} from "@autumn/shared"; import { sql } from "drizzle-orm"; import { getRegionalRedis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -13,87 +18,54 @@ export interface SyncItemV3 { cusEntIds: string[]; } -interface EntitlementSync { +interface SyncEntry { customer_entitlement_id: string; - target_balance?: number; - target_adjustment?: number; - entity_feature_id?: string; - target_entity_id?: string; + feature_id: string; + balance: number; + adjustment: number; + entities: Record | null; } -const buildCustomerLevelEntry = ({ - cusEnt, -}: { - cusEnt: FullCustomerEntitlement; -}): EntitlementSync => ({ - customer_entitlement_id: cusEnt.id, - target_balance: cusEnt.balance ?? 0, - target_adjustment: cusEnt.adjustment ?? 0, -}); - -const buildEntityLevelEntries = ({ - cusEnt, -}: { - cusEnt: FullCustomerEntitlement; -}): EntitlementSync[] => { - if (!cusEnt.entities) return []; - - return Object.entries(cusEnt.entities).map(([entityId, entityBalance]) => ({ - customer_entitlement_id: cusEnt.id, - target_balance: entityBalance.balance, - target_adjustment: entityBalance.adjustment, - entity_feature_id: cusEnt.entitlement?.entity_feature_id ?? undefined, - target_entity_id: entityId, - })); -}; - -const buildSyncEntriesFromFullCustomer = ({ +const buildSyncEntries = ({ fullCustomer, cusEntIds, }: { fullCustomer: FullCustomer; cusEntIds: string[]; -}): EntitlementSync[] => { - const entries: EntitlementSync[] = []; - const cusEntIdSet = new Set(cusEntIds); +}): SyncEntry[] => { + const cusEnts = cusProductsToCusEnts({ + cusProducts: fullCustomer.customer_products, + }); - for (const cusProduct of fullCustomer.customer_products) { - for (const cusEnt of cusProduct.customer_entitlements) { - if (!cusEntIdSet.has(cusEnt.id)) continue; + const entries: SyncEntry[] = []; - const hasEntityScope = !!cusEnt.entitlement?.entity_feature_id; + for (const cusEntId of cusEntIds) { + const cusEnt = findCustomerEntitlementById({ + cusEnts, + id: cusEntId, + errorOnNotFound: false, + }); - if (hasEntityScope) { - entries.push(...buildEntityLevelEntries({ cusEnt })); - } else { - entries.push(buildCustomerLevelEntry({ cusEnt })); - } - } + if (!cusEnt) continue; + + entries.push({ + customer_entitlement_id: cusEnt.id, + feature_id: cusEnt.entitlement.feature.id, + balance: cusEnt.balance ?? 0, + adjustment: cusEnt.adjustment ?? 0, + entities: cusEnt.entities ?? null, + }); } return entries; }; -const formatSyncResult = ({ - updates, -}: { - updates: - | Record - | undefined; -}): string => { - if (!updates || Object.keys(updates).length === 0) { - return "no changes"; - } - - return Object.entries(updates) - .map(([id, data]) => { - const shortId = id.replace("cus_ent_", ""); - const parts: string[] = []; - if (data.balance !== undefined) parts.push(`bal=${data.balance}`); - if (data.adjustment !== undefined) parts.push(`adj=${data.adjustment}`); - return `${shortId}: ${parts.join(", ")}`; - }) - .join(" | "); +const formatSyncEntry = ({ entry }: { entry: SyncEntry }): string => { + const hasEntities = entry.entities && Object.keys(entry.entities).length > 0; + const entitiesStr = hasEntities + ? `, entities=${Object.keys(entry.entities!).length}` + : ""; + return `${entry.feature_id} (${entry.customer_entitlement_id}): bal=${entry.balance}, adj=${entry.adjustment}${entitiesStr}`; }; /** @@ -119,52 +91,32 @@ export const syncItemV3 = async ({ }); if (!fullCustomer) { - logger.info(`[SYNC V3] Cache miss for ${customerId}, skipping sync`); + logger.info(`[SYNC V3] Cache miss for ${customerId}, skipping`); return; } - // Debug: log entities from cache - for (const cp of fullCustomer.customer_products) { - for (const ce of cp.customer_entitlements) { - if (cusEntIds.includes(ce.id)) { - logger.info( - `[SYNC V3 DEBUG] cusEnt ${ce.id.slice(-10)} entities: ${JSON.stringify(ce.entities)}`, - ); - } - } - } - - const entries = buildSyncEntriesFromFullCustomer({ fullCustomer, cusEntIds }); - - logger.info(`[SYNC V3 DEBUG] entries: ${JSON.stringify(entries)}`); + const entries = buildSyncEntries({ fullCustomer, cusEntIds }); if (entries.length === 0) { - logger.info(`[SYNC V3] No entries to sync for ${customerId}`); + logger.info(`[SYNC V3] No entries for ${customerId}`); return; } + for (const entry of entries) { + logger.info(`[SYNC V3] (${customerId}) ${formatSyncEntry({ entry })}`); + } + const result = await db.execute( - sql`SELECT * FROM sync_balances(${JSON.stringify({ entitlements: entries })}::jsonb)`, + sql`SELECT * FROM sync_balances_v2(${JSON.stringify({ customer_entitlement_updates: entries })}::jsonb)`, ); - const syncResult = result[0] as - | { - sync_balances?: { - updates?: Record< - string, - { balance?: number; adjustment?: number; entities?: unknown } - >; - }; - } + const syncResult = result[0]?.sync_balances_v2 as + | { updates?: Record } | undefined; - logger.info( - `[SYNC V3 DEBUG] SQL result: ${JSON.stringify(syncResult?.sync_balances)}`, - ); + const updateCount = syncResult?.updates + ? Object.keys(syncResult.updates).length + : 0; - const formatted = formatSyncResult({ - updates: syncResult?.sync_balances?.updates, - }); - - logger.info(`[SYNC V3] (${customerId}) ${formatted}`); + logger.info(`[SYNC V3] (${customerId}) Done: ${updateCount} updated`); }; diff --git a/server/src/internal/balances/utils/types/deductionTypes.ts b/server/src/internal/balances/utils/types/deductionTypes.ts new file mode 100644 index 000000000..9d1f5b479 --- /dev/null +++ b/server/src/internal/balances/utils/types/deductionTypes.ts @@ -0,0 +1,66 @@ +import type { + FullCusEntWithFullCusProduct, + FullCustomer, + SortCusEntParams, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { DeductionUpdate } from "./deductionUpdate"; +import type { FeatureDeduction } from "./featureDeduction.js"; + +/** Behavior options for deduction */ +export type DeductionOptions = { + overageBehaviour?: "cap" | "reject" | "allow"; + addToAdjustment?: boolean; + skipAdditionalBalance?: boolean; + alterGrantedBalance?: boolean; + sortParams?: SortCusEntParams; +}; + +/** Core params for deduction (shared by Redis & Postgres) */ +export type DeductionParams = { + ctx: AutumnContext; + fullCus: FullCustomer; + entityId?: string; + deductions: FeatureDeduction[]; + options?: DeductionOptions; +}; + +/** Result from deduction (same for Redis & Postgres) */ +export type DeductionResult = { + oldFullCus: FullCustomer; + fullCus: FullCustomer | undefined; + isPaidAllocated: boolean; + actualDeductions: Record; + remainingAmounts: Record; + modifiedCusEntIds: string[]; +}; + +/** Input for a single entitlement in the deduction script (Lua/SQL) */ +export type CustomerEntitlementDeduction = { + customer_entitlement_id: string; + credit_cost: number; + entity_feature_id: string | null; + usage_allowed: boolean; + min_balance: number | undefined; + add_to_adjustment: boolean; + max_balance: number; +}; + +/** Prepared input for executing a feature deduction */ +export type PreparedFeatureDeduction = { + customerEntitlements: FullCusEntWithFullCusProduct[]; + customerEntitlementDeductions: CustomerEntitlementDeduction[]; + rolloverIds: string[]; + // cusEnts: FullCusEntWithFullCusProduct[]; + // cusEntInput: CusEntDeductionInput[]; + // rolloverIds: string[]; + // cusEntIds: string[]; + // unlimited: boolean; + // unlimitedFeatureIds: string[]; +}; + +/** Result from Postgres deduction */ +export type PostgresDeductionResult = { + updates: Record; + remaining: number; +}; diff --git a/shared/api/balances/track/trackTypes/pgDeductionUpdate.ts b/server/src/internal/balances/utils/types/deductionUpdate.ts similarity index 62% rename from shared/api/balances/track/trackTypes/pgDeductionUpdate.ts rename to server/src/internal/balances/utils/types/deductionUpdate.ts index 36dd779e9..2e756f5b6 100644 --- a/shared/api/balances/track/trackTypes/pgDeductionUpdate.ts +++ b/server/src/internal/balances/utils/types/deductionUpdate.ts @@ -1,10 +1,10 @@ -import type { EntityBalance } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; import type { + EntityBalance, InsertReplaceable, Replaceable, -} from "@models/cusProductModels/cusEntModels/replaceableTable.js"; +} from "@autumn/shared"; -export interface PgDeductionUpdate { +export interface DeductionUpdate { balance: number; additional_balance: number; additional_granted_balance?: number; diff --git a/server/src/internal/balances/utils/types/featureDeduction.ts b/server/src/internal/balances/utils/types/featureDeduction.ts new file mode 100644 index 000000000..e081de7e4 --- /dev/null +++ b/server/src/internal/balances/utils/types/featureDeduction.ts @@ -0,0 +1,6 @@ +import type { Feature } from "@autumn/shared"; +export type FeatureDeduction = { + feature: Feature; + deduction: number; + targetBalance?: number; +}; diff --git a/server/src/internal/balances/utils/types/redisDeductionError.ts b/server/src/internal/balances/utils/types/redisDeductionError.ts new file mode 100644 index 000000000..fb3bbb8a3 --- /dev/null +++ b/server/src/internal/balances/utils/types/redisDeductionError.ts @@ -0,0 +1,38 @@ +/** Error codes returned by the Lua deduction script */ +export enum RedisDeductionErrorCode { + CustomerNotFound = "CUSTOMER_NOT_FOUND", + NoCustomerProducts = "NO_CUSTOMER_PRODUCTS", + InsufficientBalance = "INSUFFICIENT_BALANCE", + PaidAllocated = "PAID_ALLOCATED", +} + +/** Errors that should trigger a fallback to Postgres */ +export const FALLBACK_ERROR_CODES = [ + RedisDeductionErrorCode.CustomerNotFound, + RedisDeductionErrorCode.NoCustomerProducts, + RedisDeductionErrorCode.PaidAllocated, +] as const; + +/** Error thrown by Redis deduction operations */ +export class RedisDeductionError extends Error { + code: RedisDeductionErrorCode; + + constructor({ + message, + code, + }: { + message: string; + code: RedisDeductionErrorCode; + }) { + super(message); + this.name = "RedisDeductionError"; + this.code = code; + } + + /** Check if this error should trigger a Postgres fallback */ + shouldFallback(): boolean { + return FALLBACK_ERROR_CODES.includes( + this.code as (typeof FALLBACK_ERROR_CODES)[number], + ); + } +} diff --git a/server/src/internal/balances/utils/types/redisDeductionResult.ts b/server/src/internal/balances/utils/types/redisDeductionResult.ts new file mode 100644 index 000000000..3a6a3edfa --- /dev/null +++ b/server/src/internal/balances/utils/types/redisDeductionResult.ts @@ -0,0 +1,9 @@ +import type { DeductionUpdate } from "./deductionTypes.js"; + +export interface LuaDeductionResult { + updates: Record; + remaining: number; + error?: string; + feature_id?: string; + logs?: string[]; +} diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts index b117e80c5..d303735fd 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts @@ -1,10 +1,12 @@ import { type AppEnv, + type CheckParams, CusExpand, type CustomerData, type Entity, type EntityData, type FullCustomer, + type TrackParams, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { autoCreateEntity } from "@/internal/entities/handlers/handleCreateEntity/autoCreateEntity.js"; @@ -19,20 +21,20 @@ import { setCachedFullCustomer } from "./setCachedFullCustomer.js"; */ export const getOrCreateCachedFullCustomer = async ({ ctx, - customerId, - customerData, - entityId, - entityData, + params, source, }: { ctx: AutumnContext; - customerId: string | null; - customerData?: CustomerData; - entityId?: string; - entityData?: EntityData; + params: TrackParams | CheckParams; source?: string; }): Promise => { const { org, env, db, skipCache, logger } = ctx; + const { + customer_id: customerId, + customer_data: customerData, + entity_id: entityId, + entity_data: entityData, + } = params; let fullCustomer: FullCustomer | undefined; const fetchTimeMs = Date.now(); @@ -45,6 +47,7 @@ export const getOrCreateCachedFullCustomer = async ({ if (fullCustomer) { logger.debug(`[getOrCreateCachedFullCustomer] Cache hit: ${customerId}`); + return fullCustomer; } } diff --git a/server/src/queue/bullmq/initBullMqWorkers.ts b/server/src/queue/bullmq/initBullMqWorkers.ts index 7273cc20c..916230b35 100644 --- a/server/src/queue/bullmq/initBullMqWorkers.ts +++ b/server/src/queue/bullmq/initBullMqWorkers.ts @@ -4,7 +4,7 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { runInsertEventBatch } from "@/internal/balances/track/eventUtils/runInsertEventBatch.js"; -import { runSyncBalanceBatch } from "@/internal/balances/utils/sync/runSyncBalanceBatch.js"; +import { runSyncBalanceBatch } from "@/internal/balances/utils/sync/legacy/runSyncBalanceBatch.js"; import { runSaveFeatureDisplayTask } from "@/internal/features/featureUtils.js"; import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js"; diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index eeecc2a35..7962f30a5 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -11,8 +11,8 @@ import { type DrizzleCli, initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js"; import { runInsertEventBatch } from "@/internal/balances/track/eventUtils/runInsertEventBatch.js"; -import { runSyncBalanceBatch } from "@/internal/balances/utils/sync/runSyncBalanceBatch.js"; -import { syncItemV2 } from "@/internal/balances/utils/sync/syncItemV2.js"; +import { runSyncBalanceBatch } from "@/internal/balances/utils/sync/legacy/runSyncBalanceBatch.js"; +import { syncItemV2 } from "@/internal/balances/utils/sync/legacy/syncItemV2.js"; import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; import { runSaveFeatureDisplayTask } from "@/internal/features/featureUtils.js"; diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 1ce270b86..996b111ca 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -31,6 +31,7 @@ export interface Payloads { [JobName.SyncBalanceBatchV3]: { orgId: string; env: AppEnv; + customerId: string; item: { customerId: string; orgId: string; diff --git a/shared/api/models.ts b/shared/api/models.ts index a868cd574..1932fb137 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -67,7 +67,6 @@ export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; export * from "./balances/track/trackResponseV2.js"; -export * from "./balances/track/trackTypes/pgDeductionUpdate.js"; export * from "./balances/usageModels.js"; export * from "./billing/attach/prevVersions/attachBodyV0.js"; export * from "./billing/attach/prevVersions/attachResponseV1.js"; diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index 3de1880a0..5f864ebb6 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -1,9 +1,12 @@ import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import { FeatureType } from "../../models/featureModels/featureEnums"; +import { + FeatureType, + FeatureUsageType, +} from "../../models/featureModels/featureEnums"; import { AllowanceType } from "../../models/productModels/entModels/entModels"; import { cusEntToCusPrice } from "../productUtils/convertUtils"; -import { notNullish } from "../utils"; +import { notNullish, nullish } from "../utils"; export const isBooleanCusEnt = ({ cusEnt, @@ -39,3 +42,21 @@ export const cusEntsHavePrice = ({ return notNullish(cusPrice); }); }; + +export const isFreeCustomerEntitlement = ( + customerEntitlement: FullCusEntWithFullCusProduct, +) => { + const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement }); + return nullish(cusPrice); +}; + +export const isAllocatedCustomerEntitlement = ( + customerEntitlement: FullCusEntWithFullCusProduct, +) => { + const feature = customerEntitlement.entitlement.feature; + const isContinuous = + feature.config?.usage_type === FeatureUsageType.Continuous; + if (!isContinuous) return false; + + return true; +}; diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index 7b4e095bb..c040fbd0d 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -1,5 +1,4 @@ import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; @@ -24,54 +23,6 @@ export const isEntityCusEnt = ({ ); }; -export const updateCusEntInFullCus = ({ - fullCus, - cusEntId, - update, -}: { - fullCus: FullCustomer; - cusEntId: string; - update: PgDeductionUpdate; -}) => { - for (let i = 0; i < fullCus.customer_products.length; i++) { - for ( - let j = 0; - j < fullCus.customer_products[i].customer_entitlements.length; - j++ - ) { - const ce = fullCus.customer_products[i].customer_entitlements[j]; - if (ce.id === cusEntId) { - let replaceables = ce.replaceables ?? []; - - if (update.newReplaceables) { - replaceables = [ - ...replaceables, - ...update.newReplaceables.map((r) => ({ - ...r, - delete_next_cycle: r.delete_next_cycle ?? true, - from_entity_id: r.from_entity_id ?? null, - })), - ]; - } - - if (update.deletedReplaceables) { - replaceables = replaceables.filter( - (r) => !update.deletedReplaceables?.map((r) => r.id).includes(r.id), - ); - } - - fullCus.customer_products[i].customer_entitlements[j] = { - ...ce, - balance: update.balance, - entities: update.entities, - adjustment: update.adjustment, - replaceables, - }; - } - } - } -}; - // export const cusEntMatchesEntity = ({ // cusEnt, // entity, diff --git a/shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementById.ts b/shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementById.ts new file mode 100644 index 000000000..a2192e724 --- /dev/null +++ b/shared/utils/cusEntUtils/findCustomerEntitlement/findCustomerEntitlementById.ts @@ -0,0 +1,33 @@ +import { InternalError } from "@autumn/shared"; +import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels"; +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct"; + +export function findCustomerEntitlementById< + T extends FullCustomerEntitlement | FullCusEntWithFullCusProduct, +>(params: { cusEnts: T[]; id: string; errorOnNotFound: true }): T; + +export function findCustomerEntitlementById< + T extends FullCustomerEntitlement | FullCusEntWithFullCusProduct, +>(params: { cusEnts: T[]; id: string; errorOnNotFound?: false }): T | undefined; + +export function findCustomerEntitlementById< + T extends FullCustomerEntitlement | FullCusEntWithFullCusProduct, +>({ + cusEnts, + id, + errorOnNotFound = false, +}: { + cusEnts: T[]; + id: string; + errorOnNotFound?: boolean; +}): T | undefined { + const cusEnt = cusEnts.find((ce) => ce.id === id); + + if (!cusEnt && errorOnNotFound) { + throw new InternalError({ + message: `[findCustomerEntitlementById] Customer entitlement not found: ${id}`, + }); + } + + return cusEnt; +} diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 21f230eb6..d315eab1e 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -20,6 +20,7 @@ export * from "./cusEntUtils/convertCusEntUtils/cusEntToKey.js"; export * from "./cusEntUtils/convertCusEntUtils.js"; export * from "./cusEntUtils/cusEntUtils.js"; export * from "./cusEntUtils/filterCusEntUtils.js"; +export * from "./cusEntUtils/findCustomerEntitlement/findCustomerEntitlementById.js"; // Cus ent utils export * from "./cusEntUtils/getRolloverFields.js"; export * from "./cusEntUtils/getStartingBalance.js"; diff --git a/shared/utils/utils.ts b/shared/utils/utils.ts index 5f1ed202b..ca2930059 100644 --- a/shared/utils/utils.ts +++ b/shared/utils/utils.ts @@ -35,3 +35,28 @@ export const hashString = (str: string): string => { hasher.update(str); return hasher.digest("base64"); }; + +// Types for the result object with discriminated union +type Success = { + data: T; + error: null; +}; + +type Failure = { + data: null; + error: E; +}; + +type Result = Success | Failure; + +/** Wraps a promise and returns a discriminated union result */ +export async function tryCatch( + promise: Promise, +): Promise> { + try { + const data = await promise; + return { data, error: null }; + } catch (error) { + return { data: null, error: error as E }; + } +}