diff --git a/AGENTS.md b/AGENTS.md index 9c7fb9777..6a4799c1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,8 @@ in the test logs. Use your common sense - Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck ` +- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead. + - This codebase uses Bun as its preferred package manager and Node runtime. - **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` diff --git a/CLAUDE.md b/CLAUDE.md index 5b7405419..ac0b727f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ in the test logs. Use your common sense - Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck ` +- The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead. + - This codebase uses Bun as its preferred package manager and Node runtime. - **ALWAYS import from `zod/v4`**, not from `zod` directly. Example: `import { z } from "zod/v4";` diff --git a/NEW_TRACK.md b/NEW_TRACK.md deleted file mode 100644 index 97533489a..000000000 --- a/NEW_TRACK.md +++ /dev/null @@ -1,912 +0,0 @@ -# NEW_TRACK.md - Migration Plan: FullCustomer Cache for Track - -## Overview - -This document outlines the migration plan to move the Track endpoint from the dual cache system (`:customer:` + `:entity:` hash keys) to a single `:fullcustomer:` JSON key. - -### Goals -1. Single source of truth - one cache key per customer -2. Easier observability and maintenance -3. Atomic operations on the FullCustomer JSON document -4. Simplified sync process - -### Non-Goals (for this phase) -- Rollover deduction support -- Credit system deduction support -- Additional balance deduction -- Target balance / adjust granted balance functionality - ---- - -## Architecture Comparison - -### Current V1 Flow (Dual Cache) - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ V1 BATCHING FLOW │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ handleTrack.ts │ -│ └── Builds featureDeductions from feature_id or event_name │ -│ │ │ -│ ▼ │ -│ runTrackV2.ts │ -│ └── getOrCreateCachedFullCustomer (reads :fullcustomer: JSON) │ -│ └── runRedisDeductionV2 │ -│ │ │ -│ ▼ │ -│ runRedisDeductionV2.ts │ -│ └── globalBatchingManager.deduct({ │ -│ customerId, featureDeductions, orgId, env, entityId │ -│ }) │ -│ │ │ -│ ▼ │ -│ BatchingManager.ts │ -│ └── Batches requests by batchKey for 10ms window │ -│ batchKey = entityId │ -│ ? buildCachedApiEntityKey(...) // :customer:...:entity:... │ -│ : buildCachedApiCustomerKey(...) // :customer:... │ -│ │ │ -│ ▼ │ -│ executeBatchDeduction.ts │ -│ └── redis.batchDeduction( │ -│ JSON.stringify(requests), // Array of {featureDeductions, ...} │ -│ orgId, env, customerId, adjustGrantedBalance │ -│ ) │ -│ │ │ -│ ▼ │ -│ batchDeduction.lua (V1) │ -│ └── Operates on :customer: hash keys │ -│ Uses HGET to read balance hashes │ -│ Uses HINCRBYFLOAT for atomic increments │ -│ Handles entity balances via separate :entity: keys │ -│ Returns { │ -│ success, customerChanged, changedEntityIds, │ -│ balances, modifiedBreakdownIds │ -│ } │ -│ │ │ -│ ▼ │ -│ runRedisDeductionV2.ts (continued) │ -│ └── queueSyncAndEvent() - queues Postgres sync │ -│ └── Returns TrackResponseV2 with balances │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - -SYNC FLOW (V1): -┌─────────────────────────────────────────────────────────────────────────┐ -│ SyncBatchingManager.ts │ -│ └── Batches sync pairs by customer (1000ms window) │ -│ └── Queues to SQS with (customerId, featureId, entityId, breakdownIds) │ -│ │ │ -│ ▼ │ -│ syncItem.ts │ -│ └── getCachedApiCustomer/getCachedApiEntity (reads :customer: keys) │ -│ └── apiToBackendBalance() - converts ApiBalance to target balance │ -│ └── deductFromCusEnts({ targetBalance }) - syncs to Postgres │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -**Problems with V1:** -1. Two separate caches (`:fullcustomer:` and `:customer:`) can drift -2. Complex key structure with many hash keys per customer -3. Entity balances stored separately, harder to observe -4. `modifiedBreakdownIds` maps to the old `ApiBalance.breakdown` structure - ---- - -### Target V2 Flow (Single FullCustomer Cache) - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ V2 BATCHING FLOW │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ handleTrack.ts (unchanged) │ -│ └── Builds featureDeductions from feature_id or event_name │ -│ │ │ -│ ▼ │ -│ runTrackV2.ts (minor changes) │ -│ └── getOrCreateCachedFullCustomer (reads :fullcustomer: JSON) │ -│ └── runRedisDeductionV2 │ -│ │ │ -│ ▼ │ -│ runRedisDeductionV2.ts (MODIFIED) │ -│ └── globalBatchingManager.deduct({ │ -│ customerId, featureDeductions, orgId, env, entityId, │ -│ fullCustomer // NEW: pass FullCustomer for building sorted ents │ -│ }) │ -│ │ │ -│ ▼ │ -│ BatchingManager.ts (MODIFIED) │ -│ └── Batches requests by customerId ONLY (not per-entity) │ -│ batchKey = buildFullCustomerCacheKey(orgId, env, customerId) │ -│ All entity deductions for same customer go in same batch │ -│ │ │ -│ ▼ │ -│ executeFullCustomerDeduction.ts (NEW) │ -│ └── buildSortedEntitlements() from FullCustomer + featureDeductions │ -│ └── redis.fullCustomerDeduction( │ -│ cacheKey, // KEYS[1] - :fullcustomer: key │ -│ JSON.stringify(requests) │ -│ ) │ -│ │ │ -│ ▼ │ -│ deductFromCustomerEntitlements.lua (MODIFIED - V2) │ -│ └── Operates on :fullcustomer: JSON key │ -│ Uses JSON.GET to read FullCustomer document │ -│ Uses JSON.NUMINCRBY for atomic balance increments │ -│ Entity balances at customer_entitlements[i].entities[entityId] │ -│ Returns { │ -│ success, updates, changedCusEntIds │ -│ } │ -│ │ │ -│ ▼ │ -│ runRedisDeductionV2.ts (continued) │ -│ └── queueSyncAndEvent() with changedCusEntIds (not breakdownIds) │ -│ └── Build response balances from Lua updates │ -│ └── Returns TrackResponseV2 with balances │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - -SYNC FLOW (V2): -┌─────────────────────────────────────────────────────────────────────────┐ -│ SyncBatchingManager.ts (MODIFIED) │ -│ └── Batches sync pairs by customer (1000ms window) │ -│ └── Queues to SQS with (customerId, featureId, entityId, │ -│ changedCusEntIds) // NEW field │ -│ │ │ -│ ▼ │ -│ syncItem.ts (MODIFIED) │ -│ └── getCachedFullCustomer (reads :fullcustomer: JSON) │ -│ └── fullCustomerToApiBalance() - converts to ApiBalance format │ -│ └── apiToBackendBalance() - converts to target balance │ -│ └── deductFromCusEnts({ targetBalance }) - syncs to Postgres │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Redis Key Comparison - -### V1 Keys (Hash-based) -``` -{orgId}:env:customer:{version}:customerId # Base customer hash -{orgId}:env:customer:{version}:customerId:balances:{featureId} # Balance hash -{orgId}:env:customer:{version}:customerId:balances:{featureId}:breakdown:{index} -{orgId}:env:customer:{version}:customerId:balances:{featureId}:rollover:{index} -{orgId}:env:customer:{version}:customerId:entity:{entityId} # Entity base hash -{orgId}:env:customer:{version}:customerId:entity:{entityId}:balances:{featureId} -``` - -### V2 Key (JSON-based) -``` -{orgId}:env:fullcustomer:{version}:customerId # Single JSON document -``` - -### V2 JSON Structure -```json -{ - "id": "cus_123", - "internal_id": "...", - "customer_products": [ - { - "id": "cp_456", - "status": "active", - "customer_entitlements": [ - { - "id": "ce_789", - "balance": 100, - "adjustment": 0, - "additional_balance": 0, - "entities": { - "entity_A": { "balance": 50, "adjustment": 0 }, - "entity_B": { "balance": 30, "adjustment": 0 } - }, - "entitlement": { - "feature": { "id": "api_calls", ... }, - "entity_feature_id": "ef_123", // null if not entity-scoped - ... - } - } - ] - } - ], - "entities": [ - { "id": "entity_A", ... }, - { "id": "entity_B", ... } - ] -} -``` - ---- - -## File Changes - -### New Files - -| File | Description | -|------|-------------| -| `server/src/internal/balances/track/redisTrackUtils/executeFullCustomerDeduction.ts` | TypeScript wrapper to call V2 Lua script | -| `server/src/_luaScriptsV2/luaScriptsV2.ts` | Loader for V2 Lua scripts with dependency injection | - -### Modified Files - -| File | Changes | -|------|---------| -| `server/src/internal/balances/track/redisTrackUtils/BatchingManager.ts` | Batch by customer only, call V2 execution function | -| `server/src/internal/balances/track/redisTrackUtils/runRedisDeductionV2.ts` | Pass fullCustomer to batcher, handle new result format | -| `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua` | Accept request array, use JSON.NUMINCRBY, return changedCusEntIds | -| `server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromMainBalance.lua` | Return delta for NUMINCRBY instead of new value | -| `server/src/external/redis/initRedis.ts` | Register `fullCustomerDeduction` command | -| `server/src/internal/balances/utils/sync/SyncBatchingManager.ts` | Use changedCusEntIds instead of breakdownIds | -| `server/src/internal/balances/utils/sync/syncItem.ts` | Read from :fullcustomer: cache | - ---- - -## Detailed Implementation - -### Step 1: Create `executeFullCustomerDeduction.ts` - -**Location:** `server/src/internal/balances/track/redisTrackUtils/executeFullCustomerDeduction.ts` - -**Purpose:** TypeScript wrapper that: -1. Builds the `:fullcustomer:` cache key -2. Calls the V2 Lua script -3. Parses and returns results - -**Interfaces:** - -```typescript -// What we pass to each request in the batch -interface FullCustomerDeductionRequest { - featureDeductions: { featureId: string; amount: number }[]; - overageBehavior: "cap" | "reject"; - entityId?: string; - sortedEntitlements: LuaSortedEntitlement[]; -} - -// Pre-computed entitlement metadata for Lua -interface LuaSortedEntitlement { - customer_entitlement_id: string; - credit_cost: number; - entity_feature_id: string | null; - usage_allowed: boolean; - min_balance: number | undefined; // Floor (for overage) - max_balance: number | undefined; // Ceiling (for refunds) -} - -// What Lua returns -interface FullCustomerDeductionResult { - success: boolean; - error?: "CUSTOMER_NOT_FOUND" | "INSUFFICIENT_BALANCE" | "PAID_ALLOCATED"; - updates: Record; - changedCusEntIds: string[]; -} - -interface CusEntUpdate { - balance: number; - adjustment: number; - entities: Record | null; - deducted: number; -} -``` - -**Key Function:** - -```typescript -export const executeFullCustomerDeduction = async ({ - redis, - requests, - orgId, - env, - customerId, -}: { - redis: Redis; - requests: FullCustomerDeductionRequest[]; - orgId: string; - env: string; - customerId: string; -}): Promise => { - const cacheKey = buildFullCustomerCacheKey({ orgId, env, customerId }); - - try { - const result = await redis.fullCustomerDeduction( - cacheKey, // KEYS[1] - JSON.stringify(requests), // ARGV[1] - ); - - return JSON.parse(result); - } catch (error) { - logger.error(`Error executing fullCustomerDeduction: ${error}`); - return { - success: false, - error: "UNKNOWN_ERROR", - updates: {}, - changedCusEntIds: [], - }; - } -}; -``` - ---- - -### Step 2: Create `buildSortedEntitlements` Helper - -**Location:** Same file as above, or separate `buildSortedEntitlements.ts` - -**Purpose:** Convert `FullCustomer` + `featureDeductions` into `LuaSortedEntitlement[]` - -```typescript -export const buildSortedEntitlements = ({ - ctx, - fullCustomer, - featureDeductions, - entityId, -}: { - ctx: AutumnContext; - fullCustomer: FullCustomer; - featureDeductions: FeatureDeduction[]; - entityId?: string; -}): LuaSortedEntitlement[] => { - const { org } = ctx; - - // 1. Get all relevant feature IDs (primary features only for basic track) - const featureIds = featureDeductions.map(fd => fd.feature.id); - - // 2. Set entity on fullCustomer if entityId provided - if (entityId) { - fullCustomer.entity = fullCustomer.entities?.find(e => e.id === entityId); - } - - // 3. Get sorted cusEnts using existing utility - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCustomer.customer_products, - featureIds, - reverseOrder: org.config?.reverse_deduction_order, - entity: fullCustomer.entity, - inStatuses: orgToInStatuses({ org }), - }); - - // 4. Convert to Lua format - return cusEnts.map(ce => { - const cusPrice = cusEntToCusPrice({ cusEnt: ce }); - const maxOverage = getMaxOverage({ cusEnt: ce }); - const resetBalance = getStartingBalance({ - entitlement: ce.entitlement, - options: getEntOptions(ce.customer_product.options, ce.entitlement), - relatedPrice: cusPrice?.price, - productQuantity: ce.customer_product.quantity, - }); - - return { - customer_entitlement_id: ce.id, - credit_cost: 1, // Basic track - no credit system support yet - entity_feature_id: ce.entitlement.entity_feature_id ?? null, - usage_allowed: ce.usage_allowed ?? false, - min_balance: maxOverage != null ? -maxOverage : undefined, - max_balance: resetBalance, - }; - }); -}; -``` - ---- - -### Step 3: Modify `BatchingManager.ts` - -**Current behavior:** Batches by `customerId + entityId` -**New behavior:** Batches by `customerId` only - -**Why:** The V2 Lua script handles multiple `entityId` values in one call. All deductions for a customer should go in the same Lua execution for atomicity. - -**Changes:** - -```typescript -// File: BatchingManager.ts - -// ADD to deduct() params: -interface DeductParams { - customerId: string; - featureDeductions: FeatureDeduction[]; - orgId: string; - env: string; - entityId?: string; - overageBehavior?: "cap" | "reject"; - fullCustomer: FullCustomer; // NEW -} - -// CHANGE batch key generation: -async deduct(params: DeductParams): Promise { - const { customerId, orgId, env, fullCustomer } = params; - - // OLD: - // const batchKey = entityId - // ? buildCachedApiEntityKey({ entityId, customerId, orgId, env }) - // : buildCachedApiCustomerKey({ customerId, orgId, env }); - - // NEW: Batch by customer only - const batchKey = buildFullCustomerCacheKey({ orgId, env, customerId }); - - // ... rest of batching logic -} - -// CHANGE executeBatch() to call V2: -private async executeBatch(batchKey: string): Promise { - // ... get batch ... - - // OLD: - // const result = await executeBatchDeduction({ - // redis, - // requests: requests.map(r => ({ - // featureDeductions: r.featureDeductions, - // overageBehavior: r.overageBehavior, - // entityId: batch.entityId, - // })), - // orgId: batch.orgId, - // env: batch.env, - // customerId: batch.customerId, - // }); - - // NEW: Build sorted entitlements and call V2 - const result = await executeFullCustomerDeduction({ - redis, - requests: requests.map(r => ({ - featureDeductions: r.featureDeductions.map(fd => ({ - featureId: fd.featureId, - amount: fd.amount, - })), - overageBehavior: r.overageBehavior, - entityId: r.entityId, - sortedEntitlements: buildSortedEntitlements({ - ctx: r.ctx, - fullCustomer: r.fullCustomer, - featureDeductions: r.featureDeductions, - entityId: r.entityId, - }), - })), - orgId: batch.orgId, - env: batch.env, - customerId: batch.customerId, - }); - - // ... resolve promises ... -} -``` - -**Note:** Each request in the batch may have a different `entityId`. The Lua script processes them sequentially within the atomic execution. - ---- - -### Step 4: Modify V2 Lua Script - -**File:** `_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua` - -**Current state:** -- Uses `JSON.GET` to read FullCustomer -- Has `find_entitlement()` to locate cusEnt by ID -- Two-pass deduction (floor, then overage) -- `deductFromMainBalance` helper -- Uses `JSON.SET` (not atomic) -- Single request, not array -- Doesn't return `changedCusEntIds` array - -**Changes needed:** - -#### 4a. Accept array of requests - -```lua --- KEYS[1] = FullCustomer cache key --- ARGV[1] = JSON array of requests - -local cache_key = KEYS[1] -local requests = cjson.decode(ARGV[1]) - --- Get FullCustomer once (shared across all requests) -local full_customer_json = redis.call('JSON.GET', cache_key, '.') -if not full_customer_json then - return cjson.encode({ - success = false, - error = 'CUSTOMER_NOT_FOUND', - updates = {}, - changedCusEntIds = {} - }) -end - -local full_customer = cjson.decode(full_customer_json) -local all_updates = {} -local changed_cus_ent_ids = {} - --- Process each request -for _, request in ipairs(requests) do - local result = process_single_request(request, full_customer) - - if not result.success then - return cjson.encode(result) -- Early exit on failure - end - - -- Merge updates - for cus_ent_id, update in pairs(result.updates) do - all_updates[cus_ent_id] = update - table.insert(changed_cus_ent_ids, cus_ent_id) - end -end - -return cjson.encode({ - success = true, - updates = all_updates, - changedCusEntIds = changed_cus_ent_ids -}) -``` - -#### 4b. Use `JSON.NUMINCRBY` for atomic increments - -```lua --- OLD (in deductFromMainBalance or main script): -local new_balance = current_balance - deducted_amount -table.insert(pending_sets, { base_path .. '.balance', new_balance }) --- Later: redis.call('JSON.SET', cache_key, path, value) - --- NEW: Use JSON.NUMINCRBY for atomic increment --- Instead of tracking pending_sets, apply immediately: -if deducted_amount ~= 0 then - redis.call('JSON.NUMINCRBY', cache_key, base_path .. '.balance', -deducted_amount) - - if has_entity_scope and target_entity_id then - redis.call('JSON.NUMINCRBY', cache_key, - base_path .. '.entities.' .. target_entity_id .. '.balance', - -deducted_amount) - end - - if alter_granted_balance then - redis.call('JSON.NUMINCRBY', cache_key, base_path .. '.adjustment', -deducted_amount) - end -end -``` - -**Why JSON.NUMINCRBY:** -- Atomic: Multiple concurrent Lua executions won't lose updates -- Simpler: No need to read-compute-write -- Matches V1's `HINCRBYFLOAT` semantics - -#### 4c. Handle unlimited entitlements - -```lua --- In process_single_request, before deduction: --- Check if entitlement is unlimited -local entitlement = cus_ent.entitlement -if entitlement and entitlement.allowance_type == 'unlimited' then - -- Skip deduction, mark as "changed" so balance gets returned - table.insert(changed_cus_ent_ids, ent_id) - -- Continue to next entitlement -end -``` - -#### 4d. Return structure - -```lua -return cjson.encode({ - success = true, - error = cjson.null, -- or "INSUFFICIENT_BALANCE", "CUSTOMER_NOT_FOUND", "PAID_ALLOCATED" - updates = { - ["ce_123"] = { - balance = 90, - adjustment = 0, - entities = { ["entity_A"] = { balance = 40, adjustment = 0 } }, - deducted = 10 - } - }, - changedCusEntIds = { "ce_123", "ce_456" } -}) -``` - ---- - -### Step 5: Register V2 Lua Script - -**File:** `server/src/external/redis/initRedis.ts` - -**Add:** - -```typescript -import { getFullCustomerDeductionScript } from "../../_luaScriptsV2/luaScriptsV2.js"; - -// In configureRedisInstance(): -redisInstance.defineCommand("fullCustomerDeduction", { - numberOfKeys: 1, // KEYS[1] = :fullcustomer: cache key - lua: getFullCustomerDeductionScript(), -}); -``` - -**File:** `server/src/_luaScriptsV2/luaScriptsV2.ts` (NEW) - -```typescript -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Load helper -const DEDUCT_FROM_MAIN_BALANCE = readFileSync( - join(__dirname, "deductFromCustomerEntitlements/deductFromMainBalance.lua"), - "utf-8", -); - -// Load main script -const DEDUCT_FROM_CUSTOMER_ENTITLEMENTS = readFileSync( - join(__dirname, "deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua"), - "utf-8", -); - -export function getFullCustomerDeductionScript(): string { - return `${DEDUCT_FROM_MAIN_BALANCE}\n${DEDUCT_FROM_CUSTOMER_ENTITLEMENTS}`; -} -``` - -**Type definition in initRedis.ts:** - -```typescript -declare module "ioredis" { - interface RedisCommander { - fullCustomerDeduction( - cacheKey: string, // KEYS[1] - requestsJson: string, // ARGV[1] - ): Promise; - } -} -``` - ---- - -### Step 6: Modify `runRedisDeductionV2.ts` - -**Changes:** - -1. Pass `fullCustomer` to batching manager -2. Handle new result format (`changedCusEntIds` instead of `modifiedBreakdownIds`) -3. Build response balances from `updates` - -```typescript -// In runRedisDeductionV2(): - -// CHANGE: Pass fullCustomer to deduct() -const result = await globalBatchingManager.deduct({ - customerId, - featureDeductions: mappedDeductions, - orgId: org.id, - env, - entityId, - overageBehavior, - fullCustomer, // NEW - ctx, // NEW: needed for buildSortedEntitlements -}); - -// CHANGE: queueSyncAndEvent to use changedCusEntIds -const queueSyncAndEvent = ({ - ctx, - trackParams, - featureDeductions, - eventInfo, - result, - apiCustomer, -}: ...) => { - for (const deduction of featureDeductions) { - if (result.changedCusEntIds && result.changedCusEntIds.length > 0) { - globalSyncBatchingManager.addSyncPair({ - customerId: customer_id, - featureId: deduction.feature.id, - orgId: org.id, - env, - entityId: trackParams.entity_id, - region: currentRegion, - changedCusEntIds: result.changedCusEntIds, // NEW (was breakdownIds) - }); - } - } - // ... event batching unchanged -}; -``` - ---- - -### Step 7: Modify `SyncBatchingManager.ts` - -**Changes:** - -```typescript -interface SyncPairContext { - customerId: string; - featureId: string; - orgId: string; - env: AppEnv; - entityId?: string; - region: string; - timestamp: number; - changedCusEntIds: string[]; // CHANGED from breakdownIds -} - -// In addSyncPair(): -addSyncPair({ - customerId, - featureId, - orgId, - env, - entityId, - region, - changedCusEntIds, // CHANGED -}: Omit): void { - // ... existing batching logic - - const existingPair = customerBatch.pairs.get(pairKey); - customerBatch.pairs.set(pairKey, { - // ... - changedCusEntIds: existingPair - ? [...new Set([...existingPair.changedCusEntIds, ...changedCusEntIds])] - : changedCusEntIds, - }); -} -``` - ---- - -### Step 8: Modify `syncItem.ts` - -**Changes:** - -1. Read from `:fullcustomer:` cache instead of `:customer:` cache -2. Convert FullCustomer balance format to ApiBalance for existing logic - -```typescript -// In syncItem(): - -// OLD: -// if (entityId) { -// const { apiEntity } = await getCachedApiEntity({...}); -// redisEntity = apiEntity; -// } else { -// const { apiCustomer } = await getCachedApiCustomer({...}); -// redisEntity = apiCustomer; -// } - -// NEW: Read from :fullcustomer: cache -const fullCustomer = await getCachedFullCustomer({ - orgId: item.orgId, - env: item.env, - customerId: item.customerId, -}); - -if (!fullCustomer) { - ctx.logger.warn(`[syncItem] FullCustomer not found in cache: ${item.customerId}`); - return; -} - -// Convert to ApiBalance format for existing apiToBackendBalance logic -for (const relevantFeature of relevantFeatures) { - const apiBalance = fullCustomerToApiBalance({ - fullCustomer, - featureId: relevantFeature.id, - entityId: item.entityId, - }); - - if (!apiBalance) continue; - - // ... rest of existing sync logic with apiToBackendBalance -} -``` - -**New helper function:** - -```typescript -const fullCustomerToApiBalance = ({ - fullCustomer, - featureId, - entityId, -}: { - fullCustomer: FullCustomer; - featureId: string; - entityId?: string; -}): ApiBalance | null => { - // Find cusEnts for this feature - const cusEnts = cusProductsToCusEnts({ - cusProducts: fullCustomer.customer_products, - featureId, - inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - }); - - if (cusEnts.length === 0) return null; - - // Sum balances across all cusEnts for this feature - let currentBalance = 0; - let purchasedBalance = 0; - let grantedBalance = 0; - - for (const cusEnt of cusEnts) { - if (entityId && cusEnt.entitlement.entity_feature_id) { - // Entity-scoped: get from entities map - const entityBalance = cusEnt.entities?.[entityId]; - if (entityBalance) { - currentBalance += entityBalance.balance || 0; - } - } else if (!cusEnt.entitlement.entity_feature_id) { - // Customer-scoped: get from cusEnt directly - currentBalance += cusEnt.balance || 0; - } - - // TODO: Calculate purchased_balance, granted_balance from cusEnt - } - - return { - feature_id: featureId, - current_balance: currentBalance, - purchased_balance: purchasedBalance, - granted_balance: grantedBalance, - usage: grantedBalance - currentBalance + purchasedBalance, - // ... other fields - }; -}; -``` - ---- - -## Testing Plan - -### Unit Tests - -1. **`buildSortedEntitlements`:** - - Returns correct order (entity vs customer, intervals, usage_allowed) - - Handles missing entity - - Handles empty customer_products - -2. **`executeFullCustomerDeduction`:** - - Returns success on valid deduction - - Returns `CUSTOMER_NOT_FOUND` when cache empty - - Returns `INSUFFICIENT_BALANCE` when reject + not enough balance - -### Integration Tests - -1. **Basic track (no entity):** - - Single feature deduction - - Multiple feature deductions in one request - - Overage behavior: cap vs reject - -2. **Entity-scoped track:** - - Deduct from specific entity - - Multiple entities, same customer, concurrent requests - - Entity doesn't exist -> creates entity balance - -3. **Batching:** - - 100 concurrent requests for same customer -> all batched - - Different customers -> separate batches - -4. **Sync:** - - Deduction -> sync -> Postgres matches Redis - - Entity deduction -> correct entity balance in Postgres - ---- - -## Open Questions - -1. **Batching scope:** Currently planning to batch by `customerId` only. Confirm this is acceptable vs batching by `customerId + entityId`. - -2. **Response balances:** Should `runRedisDeductionV2` return balances in the same `ApiBalance` format as V1? Or can we change the response structure? - -3. **Fallback behavior:** If V2 Lua returns `CUSTOMER_NOT_FOUND`, should we: - - A) Fall back to `executePostgresTracking` (current plan) - - B) Try to populate cache and retry - - C) Something else - -4. **Credit system support timing:** The current plan doesn't include credit systems. When do we need this? - ---- - -## Migration Strategy - -1. **Phase 1 (this doc):** Basic track working with `:fullcustomer:` cache -2. **Phase 2:** Add rollover support to Lua script -3. **Phase 3:** Add credit system support -4. **Phase 4:** Add target_balance / adjust_granted_balance -5. **Phase 5:** Remove V1 cache code (`:customer:` keys) diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 36d7e8a3c..7ec0593fd 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -6,26 +6,28 @@ # Source shared configuration source "$(dirname "$0")/config.sh" - # 'server/tests/balances/track/concurrency' \ - # 'server/tests/balances/track/basic' \ # Run tests using TypeScript runner with compact mode # Adjust --max to control concurren.cy (default: 6) -# BUN_PARALLEL_COMPACT \ -# '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/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 \ + 'server/tests/balances/track/basic' \ + 'server/tests/balances/track/concurrency' \ + '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/negative' \ + 'server/tests/balances/track/rollovers' \ + 'server/tests/balances/track/race-condition' \ + '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/contextUtils.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua index 64b5a6d43..bc532a292 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/contextUtils.lua @@ -6,7 +6,8 @@ --[[ init_context(params) - Initializes context object with current balances for all customer_entitlements. + Initializes context object with current balances for all customer_entitlements + and builds a rollover index for fast lookups. Reads from Redis once upfront to avoid multiple reads during passes. params: @@ -16,6 +17,7 @@ Returns: context table with: customer_entitlements: { [cus_ent_id]: { base_path, balance, adjustment, entities } } + rollovers: { [rollover_id]: { base_path, balance, usage, entities } } pending_writes: {} (empty array to queue writes) logs: {} (debug logs) logger: { log(fmt, ...): function } (logger that appends to logs) @@ -25,6 +27,7 @@ local function init_context(params) local context = { customer_entitlements = {}, + rollovers = {}, pending_writes = {}, logs = logs, logger = { @@ -60,6 +63,29 @@ local function init_context(params) end context.customer_entitlements[ent_id] = ent_data + + -- Build rollover index for this customer_entitlement + local cus_ent_rollovers = cus_ent.rollovers + if cus_ent_rollovers and type(cus_ent_rollovers) == 'table' then + for r_idx, rollover in ipairs(cus_ent_rollovers) do + if rollover and rollover.id then + local r_idx_0 = r_idx - 1 + local rollover_path = base_path .. '.rollovers[' .. r_idx_0 .. ']' + + -- Read fresh rollover data from Redis + local rollover_data = read_rollover_data(params.cache_key, rollover_path) + + if rollover_data then + context.rollovers[rollover.id] = { + base_path = rollover_path, + balance = rollover_data.balance, + usage = rollover_data.usage, + entities = rollover_data.entities, + } + end + end + end + end end end @@ -124,6 +150,56 @@ local function queue_balance_update(params) end end +--[[ + queue_rollover_update(params) + + Queues a rollover balance/usage update to pending_writes. + Rollovers track both balance (decrements) and usage (increments). + + params: + context: table (context object) + path: string (JSON path to the rollover or entity, WITHOUT .balance/.usage suffix) + deduct_amount: number (positive amount to deduct from balance and add to usage) +]] +local function queue_rollover_update(params) + local context = params.context + local path = params.path + local deduct_amount = params.deduct_amount + + -- Queue balance decrement + table.insert(context.pending_writes, { path = path .. '.balance', delta = -deduct_amount }) + + -- Queue usage increment + table.insert(context.pending_writes, { path = path .. '.usage', delta = deduct_amount }) +end + +--[[ + update_in_memory_rollover(params) + + Updates balance and usage in-memory on a rollover object. + + params: + target: table (rollover_data or entities table) + entity_id: string or nil (if entity-scoped) + deduct_amount: number (positive amount to deduct from balance and add to usage) +]] +local function update_in_memory_rollover(params) + local target = params.target + local entity_id = params.entity_id + local deduct_amount = params.deduct_amount + + if entity_id then + if not target[entity_id] then + target[entity_id] = { balance = 0, usage = 0 } + end + target[entity_id].balance = (target[entity_id].balance or 0) - deduct_amount + target[entity_id].usage = (target[entity_id].usage or 0) + deduct_amount + else + target.balance = (target.balance or 0) - deduct_amount + target.usage = (target.usage or 0) + deduct_amount + end +end + --[[ apply_pending_writes(cache_key, context) diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua index dafeb5efd..e164c33f5 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromCustomerEntitlements.lua @@ -4,10 +4,16 @@ Uses JSON.NUMINCRBY for atomic incremental updates. Reads CURRENT balance from Redis before each calculation to avoid stale reads. + Deduction Order (mirrors SQL performDeduction.sql): + 1. Deduct from rollovers first (oldest first by expires_at) + 2. Pass 1: Deduct from main balance (floor at 0) + 3. Pass 2: Allow negative if usage_allowed + Helper functions are prepended via string interpolation from: - luaUtils.lua (safe_table, safe_number, find_entitlement, build_entity_path, sorted_keys, is_nil) - - readBalances.lua (read_current_balance, read_current_entity_balance, read_current_entities) - - deductFromRollovers.lua (deduct_from_rollovers - stub) + - readBalances.lua (read_current_balance, read_current_entity_balance, read_current_entities, read_rollover_data) + - contextUtils.lua (init_context, update_in_memory_customer_entitlement, queue_balance_update, apply_pending_writes) + - deductFromRollovers.lua (deduct_from_rollovers) - deductFromMainBalance.lua (calculate_change, deduct_from_main_balance) - getTotalBalance.lua (get_total_balance) @@ -30,6 +36,7 @@ Returns JSON: { updates: { [cus_ent_id]: { balance, additional_balance, adjustment, entities, deducted, additional_deducted } }, + rollover_updates: { [rollover_id]: { balance, usage, entities } }, remaining: number, error: string | null, feature_id: string | null @@ -186,13 +193,62 @@ local function process_pass(pass_config) logger.log("=== %s END === remaining=%s", pass_name, remaining_amount) end +-- ============================================================================ +-- HELPER: Process rollovers before main balance deduction +-- ============================================================================ +local function process_rollovers(config) + local context = config.context + local rollover_ids = config.rollover_ids + local remaining = config.remaining_amount + local target_entity_id = config.target_entity_id + local sorted_entitlements = config.sorted_entitlements + local logger = context.logger + + -- Early return if no rollovers or no positive amount + if is_nil(rollover_ids) or #rollover_ids == 0 or remaining <= 0 then + return 0 + end + + -- Determine has_entity_scope from first entitlement + local first_ent = sorted_entitlements[1] + local has_entity_scope = false + if first_ent then + has_entity_scope = first_ent.entity_feature_id ~= nil and first_ent.entity_feature_id ~= cjson.null + end + + local rollover_deducted = deduct_from_rollovers({ + context = context, + rollover_ids = rollover_ids, + amount = remaining, + target_entity_id = target_entity_id, + has_entity_scope = has_entity_scope, + }) + + logger.log("Rollover deduction: deducted=%s, remaining=%s", rollover_deducted, remaining - rollover_deducted) + + return rollover_deducted +end + -- ============================================================================ -- MAIN DEDUCTION/REFUND LOGIC -- Same two-pass structure for both deductions and refunds (matches SQL) +-- Step 1: Deduct from rollovers first (only for positive deductions) -- Pass 1: Process all entitlements (floor at 0 for deductions, ceiling at 0 for refunds) -- Pass 2: Process remaining (deductions: only usage_allowed can go negative; refunds: all can go above 0) -- ============================================================================ +-- Step 1: Deduct from rollovers BEFORE main balance deduction (only for track, not update balance) +if not alter_granted_balance then + local rollover_deducted = process_rollovers({ + context = context, + rollover_ids = rollover_ids, + remaining_amount = remaining_amount, + target_entity_id = target_entity_id, + sorted_entitlements = sorted_entitlements, + }) + remaining_amount = remaining_amount - rollover_deducted +end + process_pass({ pass_number = 1, skip_if_not_usage_allowed = false, @@ -241,10 +297,29 @@ for ent_id, update in pairs(updates) do end end +-- Build rollover_updates from context.rollovers (only include modified ones) +local rollover_updates = {} +if not is_nil(rollover_ids) then + for rollover_id, rollover_data in pairs(context.rollovers) do + -- Include all rollovers that were in the rollover_ids list (they may have been modified) + for _, rid in ipairs(rollover_ids) do + if rid == rollover_id then + rollover_updates[rollover_id] = { + balance = rollover_data.balance, + usage = rollover_data.usage, + entities = rollover_data.entities, + } + break + end + end + end +end + logger.log("=== LUA DEDUCTION END ===") return cjson.encode({ updates = updates, + rollover_updates = rollover_updates, remaining = remaining_amount, error = cjson.null, logs = context.logs diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua index adc478118..22f74f118 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/deductFromRollovers.lua @@ -1,27 +1,169 @@ -- ============================================================================ -- DEDUCT FROM ROLLOVERS --- TODO: Implement rollover deduction logic (mirrors SQL deductFromRollovers.sql) +-- Deducts from rollover balances before main entitlements (mirrors SQL deductFromRollovers.sql) -- ============================================================================ --[[ deduct_from_rollovers(params) Deducts from rollover balances before main entitlements. - Currently a stub - returns 0 (rollovers not yet implemented in V2). + Mirrors SQL logic in server/src/internal/balances/utils/sql/deductFromRollovers.sql + + Handles three scenarios: + 1. Entity-scoped with target_entity_id: Deduct from specific entity in rollover + 2. Entity-scoped without target_entity_id: Deduct from all entities in rollover + 3. Top-level balance: Deduct from rollover.balance params: - cache_key: string + context: table (context object with rollovers indexed) rollover_ids: string[] | nil - amount: number - credit_cost: number + amount: number (amount to deduct, in feature units, NOT credit-adjusted) target_entity_id: string | nil has_entity_scope: boolean Returns: - deducted: number (amount deducted from rollovers) + deducted: number (total amount deducted from rollovers, in feature units) ]] local function deduct_from_rollovers(params) - -- Stub: rollovers not yet implemented in V2 Lua - -- When implemented, should mirror server/src/internal/balances/utils/sql/deductFromRollovers.sql - return 0 + local context = params.context + local rollover_ids = params.rollover_ids + local amount = params.amount + local target_entity_id = params.target_entity_id + local has_entity_scope = params.has_entity_scope + local logger = context.logger + + -- Early return if no rollovers or no amount + if not rollover_ids or #rollover_ids == 0 then + return 0 + end + + if amount <= 0 then + return 0 + end + + local remaining_amount = amount + local total_deducted = 0 + + logger.log("=== ROLLOVER DEDUCTION START ===") + logger.log(" rollover_ids: %s", table.concat(rollover_ids, ", ")) + logger.log(" amount: %s, has_entity_scope: %s, target_entity_id: %s", + tostring(amount), tostring(has_entity_scope), tostring(target_entity_id or "nil")) + + -- Loop through rollover IDs in order (already sorted by expires_at) + for _, rollover_id in ipairs(rollover_ids) do + if remaining_amount <= 0 then break end + + local rollover_data = context.rollovers[rollover_id] + if not rollover_data then + logger.log(" Rollover %s not found in context", rollover_id) + else + local base_path = rollover_data.base_path + + -- ======================================================================== + -- CASE 1: Entity-scoped with specific target entity + -- ======================================================================== + if has_entity_scope and not is_nil(target_entity_id) then + local entities = rollover_data.entities or {} + local entity_obj = entities[target_entity_id] + local entity_balance = entity_obj and safe_number(entity_obj.balance) or 0 + + -- Calculate deduction (always cap at 0) + local deduct_amount = math.min(entity_balance, remaining_amount) + + logger.log(" Rollover %s entity %s: balance=%s, deduct=%s", + rollover_id, target_entity_id, entity_balance, deduct_amount) + + if deduct_amount > 0 then + local entity_path = base_path .. '["entities"]["' .. target_entity_id .. '"]' + + queue_rollover_update({ + context = context, + path = entity_path, + deduct_amount = deduct_amount, + }) + + update_in_memory_rollover({ + target = entities, + entity_id = target_entity_id, + deduct_amount = deduct_amount, + }) + + remaining_amount = remaining_amount - deduct_amount + total_deducted = total_deducted + deduct_amount + end + + -- ======================================================================== + -- CASE 2: Entity-scoped without target (all entities) + -- ======================================================================== + elseif has_entity_scope then + local entities = rollover_data.entities or {} + local entity_keys = sorted_keys(entities) + + for _, entity_key in ipairs(entity_keys) do + if remaining_amount <= 0 then break end + + local entity_obj = entities[entity_key] + local entity_balance = entity_obj and safe_number(entity_obj.balance) or 0 + + -- Calculate deduction for this entity (always cap at 0) + local deduct_amount = math.min(entity_balance, remaining_amount) + + logger.log(" Rollover %s entity %s: balance=%s, deduct=%s", + rollover_id, entity_key, entity_balance, deduct_amount) + + if deduct_amount > 0 then + local entity_path = base_path .. '["entities"]["' .. entity_key .. '"]' + + queue_rollover_update({ + context = context, + path = entity_path, + deduct_amount = deduct_amount, + }) + + update_in_memory_rollover({ + target = entities, + entity_id = entity_key, + deduct_amount = deduct_amount, + }) + + remaining_amount = remaining_amount - deduct_amount + total_deducted = total_deducted + deduct_amount + end + end + + -- ======================================================================== + -- CASE 3: Top-level balance (no entity scope) + -- ======================================================================== + else + local current_balance = safe_number(rollover_data.balance) + + -- Calculate deduction (always cap at 0) + local deduct_amount = math.min(current_balance, remaining_amount) + + logger.log(" Rollover %s top-level: balance=%s, deduct=%s", + rollover_id, current_balance, deduct_amount) + + if deduct_amount > 0 then + queue_rollover_update({ + context = context, + path = base_path, + deduct_amount = deduct_amount, + }) + + update_in_memory_rollover({ + target = rollover_data, + entity_id = nil, + deduct_amount = deduct_amount, + }) + + remaining_amount = remaining_amount - deduct_amount + total_deducted = total_deducted + deduct_amount + end + end + end + end + + logger.log("=== ROLLOVER DEDUCTION END === total_deducted=%s", total_deducted) + + return total_deducted end diff --git a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/readBalances.lua b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/readBalances.lua index f76ecb4eb..c732a6b8e 100644 --- a/server/src/_luaScriptsV2/deductFromCustomerEntitlements/readBalances.lua +++ b/server/src/_luaScriptsV2/deductFromCustomerEntitlements/readBalances.lua @@ -3,6 +3,33 @@ -- Functions to read current balance state from Redis -- ============================================================================ +-- ============================================================================ +-- HELPER: Read rollover data from Redis (fresh read) +-- Returns: { balance, usage, entities } or nil if not found +-- ============================================================================ +local function read_rollover_data(cache_key, rollover_path) + local result = redis.call('JSON.GET', cache_key, rollover_path) + if not result or result == cjson.null then + return nil + end + + local decoded = cjson.decode(result) + -- JSONPath returns an array of matches, extract the first element + if type(decoded) == 'table' and decoded[1] ~= nil then + decoded = decoded[1] + end + + if type(decoded) ~= 'table' then + return nil + end + + return { + balance = safe_number(decoded.balance), + usage = safe_number(decoded.usage), + entities = safe_table(decoded.entities), + } +end + -- ============================================================================ -- HELPER: Read current balance from Redis (fresh read, not from snapshot) -- ============================================================================ diff --git a/server/src/_luaScriptsV2/deleteFullCustomerCache/batchDeleteFullCustomerCache.lua b/server/src/_luaScriptsV2/deleteFullCustomerCache/batchDeleteFullCustomerCache.lua new file mode 100644 index 000000000..5dba9975d --- /dev/null +++ b/server/src/_luaScriptsV2/deleteFullCustomerCache/batchDeleteFullCustomerCache.lua @@ -0,0 +1,46 @@ +--[[ + Batch delete multiple FullCustomer caches from Redis. + + For each customer, atomically: + 1. Checks if test guard exists (skip that customer if so) + 2. Sets the stale-write guard key + 3. Deletes the cache key + + KEYS: none (all keys passed via ARGV to support variable number of customers) + + ARGV: + [1] guardTimestamp - timestamp for all guards + [2] guardTtl - TTL in seconds for guard keys + [3] customersJson - JSON array of {testGuardKey, guardKey, cacheKey} objects + + Returns: + JSON object: { deleted: number, skipped: number } +]] + +local guardTimestamp = ARGV[1] +local guardTtl = tonumber(ARGV[2]) +local customersJson = ARGV[3] + +local customers = cjson.decode(customersJson) +local deleted = 0 +local skipped = 0 + +for _, customer in ipairs(customers) do + local testGuardKey = customer.testGuardKey + local guardKey = customer.guardKey + local cacheKey = customer.cacheKey + + -- Check test guard first + if redis.call("EXISTS", testGuardKey) == 1 then + skipped = skipped + 1 + else + -- Set stale-write guard and delete cache + redis.call("SET", guardKey, guardTimestamp, "EX", guardTtl) + local wasDeleted = redis.call("DEL", cacheKey) + if wasDeleted > 0 then + deleted = deleted + 1 + end + end +end + +return cjson.encode({ deleted = deleted, skipped = skipped }) diff --git a/server/src/_luaScriptsV2/deleteFullCustomerCache/deleteFullCustomerCache.lua b/server/src/_luaScriptsV2/deleteFullCustomerCache/deleteFullCustomerCache.lua new file mode 100644 index 000000000..792e56ed8 --- /dev/null +++ b/server/src/_luaScriptsV2/deleteFullCustomerCache/deleteFullCustomerCache.lua @@ -0,0 +1,43 @@ +--[[ + Delete FullCustomer from Redis cache. + + Atomically: + 1. Checks if test guard exists (skip if so - used in race condition tests) + 2. Sets the stale-write guard key (to prevent in-flight requests from writing stale data) + 3. Deletes the cache key + + KEYS: + [1] testGuardKey - test guard key to check + [2] guardKey - stale-write guard key to set + [3] cacheKey - cache key to delete + + ARGV: + [1] guardTimestamp - timestamp for the guard + [2] guardTtl - TTL in seconds for the guard key + + Returns: + "SKIPPED" = test guard exists, deletion skipped + "DELETED" = cache key deleted successfully + "NOT_FOUND" = cache key didn't exist (but guard was set) +]] + +local testGuardKey = KEYS[1] +local guardKey = KEYS[2] +local cacheKey = KEYS[3] +local guardTimestamp = ARGV[1] +local guardTtl = tonumber(ARGV[2]) + +-- Check test guard first (used in race condition tests) +if redis.call("EXISTS", testGuardKey) == 1 then + return "SKIPPED" +end + +-- Set stale-write guard and delete cache atomically +redis.call("SET", guardKey, guardTimestamp, "EX", guardTtl) +local deleted = redis.call("DEL", cacheKey) + +if deleted > 0 then + return "DELETED" +else + return "NOT_FOUND" +end diff --git a/server/src/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index ec72ce17b..a510eb27a 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -5,8 +5,9 @@ import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Path to deductFromCustomerEntitlements folder (same directory as this file) +// Path to script folders const DEDUCT_DIR = join(__dirname, "deductFromCustomerEntitlements"); +const DELETE_CACHE_DIR = join(__dirname, "deleteFullCustomerCache"); // ============================================================================ // HELPER MODULES @@ -61,3 +62,25 @@ ${GET_TOTAL_BALANCE} ${DEDUCT_FROM_ROLLOVERS} ${DEDUCT_FROM_MAIN_BALANCE} ${mainScript}`; + +// ============================================================================ +// DELETE FULL CUSTOMER CACHE SCRIPTS +// ============================================================================ + +/** + * Lua script for deleting a single FullCustomer cache from Redis. + * Checks test guard, sets stale-write guard, and deletes cache atomically. + */ +export const DELETE_FULL_CUSTOMER_CACHE_SCRIPT = readFileSync( + join(DELETE_CACHE_DIR, "deleteFullCustomerCache.lua"), + "utf-8", +); + +/** + * Lua script for batch deleting multiple FullCustomer caches from Redis. + * For each customer: checks test guard, sets stale-write guard, deletes cache. + */ +export const BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT = readFileSync( + join(DELETE_CACHE_DIR, "batchDeleteFullCustomerCache.lua"), + "utf-8", +); diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index 21781eedf..ed3bdfced 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -13,7 +13,11 @@ import { SET_INVOICES_SCRIPT, SET_SUBSCRIPTIONS_SCRIPT, } from "../../_luaScripts/luaScripts.js"; -import { DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT } from "../../_luaScriptsV2/luaScriptsV2.js"; +import { + BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, + DELETE_FULL_CUSTOMER_CACHE_SCRIPT, +} from "../../_luaScriptsV2/luaScriptsV2.js"; if (!process.env.CACHE_URL) { throw new Error("CACHE_URL (redis) is not set"); @@ -101,6 +105,16 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, }); + redisInstance.defineCommand("deleteFullCustomerCache", { + numberOfKeys: 3, + lua: DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + }); + + redisInstance.defineCommand("batchDeleteFullCustomerCache", { + numberOfKeys: 0, + lua: BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + }); + // biome-ignore lint/correctness/noUnusedFunctionParameters: Might uncomment this back in in the future redisInstance.on("error", (error) => { // logger.error(`redis (cache) error: ${error.message}`); @@ -246,6 +260,18 @@ declare module "ioredis" { cacheKey: string, paramsJson: string, ): Promise; + deleteFullCustomerCache( + testGuardKey: string, + guardKey: string, + cacheKey: string, + guardTimestamp: string, + guardTtl: string, + ): Promise<"SKIPPED" | "DELETED" | "NOT_FOUND">; + batchDeleteFullCustomerCache( + guardTimestamp: string, + guardTtl: string, + customersJson: string, + ): Promise; } } diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 9f7cf1477..67f65480f 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -85,7 +85,7 @@ export const refreshCacheMiddleware = async ( matchRoute({ url: pathname, method, pattern }), ); - if (pathMatch) { + if (pathMatch && !skipCacheDeletion) { const customerId = c.req.param("customer_id"); if (customerId) { logger.info( diff --git a/server/src/internal/balances/track/utils/runRedisTrack.ts b/server/src/internal/balances/track/utils/runRedisTrack.ts index 1dabb54fc..12005c626 100644 --- a/server/src/internal/balances/track/utils/runRedisTrack.ts +++ b/server/src/internal/balances/track/utils/runRedisTrack.ts @@ -14,19 +14,24 @@ import { deductionUpdatesToModifiedIds } from "../../utils/sync/deductionUpdates import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManagerV2.js"; import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js"; import type { FeatureDeduction } from "../../utils/types/featureDeduction.js"; +import type { RolloverUpdate } from "../../utils/types/redisDeductionResult.js"; import { handleRedisTrackError } from "./handleRedisTrackError.js"; const queueSyncItem = ({ ctx, body, updates, + rolloverUpdates, }: { ctx: AutumnContext; body: TrackParams; updates: Record; + rolloverUpdates: Record; }): void => { const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); - if (modifiedCusEntIds.length === 0) return; + const rolloverIds = Object.keys(rolloverUpdates); + + if (modifiedCusEntIds.length === 0 && rolloverIds.length === 0) return; ctx.logger.info(`[QUEUE SYNC] (${body.customer_id})`); globalSyncBatchingManagerV2.addSyncItem({ @@ -34,6 +39,7 @@ const queueSyncItem = ({ orgId: ctx.org.id, env: ctx.env, cusEntIds: modifiedCusEntIds, + rolloverIds, region: currentRegion, }); }; @@ -102,13 +108,14 @@ export const runRedisTrack = async ({ }); } - const { updates, fullCus } = result; + const { updates, fullCus, rolloverUpdates } = result; // Queue sync and event queueSyncItem({ ctx, body, updates, + rolloverUpdates, }); queueEvent({ ctx, body, fullCustomer }); diff --git a/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts b/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts index 5ac974f1d..778cda372 100644 --- a/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts +++ b/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts @@ -73,11 +73,12 @@ export const runRedisUpdateBalanceV2 = async ({ const { updates } = result; - // Sync to Postgres + // Sync to Postgres (update balance doesn't touch rollovers) const modifiedCusEntIds = deductionUpdatesToModifiedIds({ updates }); + if (modifiedCusEntIds.length > 0) { await syncItemV3({ - item: { + payload: { customerId, orgId: org.id, env, diff --git a/server/src/internal/balances/utils/deduction/applyRolloverUpdatesToFullCustomer.ts b/server/src/internal/balances/utils/deduction/applyRolloverUpdatesToFullCustomer.ts new file mode 100644 index 000000000..0f6954b98 --- /dev/null +++ b/server/src/internal/balances/utils/deduction/applyRolloverUpdatesToFullCustomer.ts @@ -0,0 +1,33 @@ +import type { FullCustomer } from "@autumn/shared"; +import type { RolloverUpdate } from "../types/redisDeductionResult.js"; + +/** + * Apply rollover updates to the in-memory FullCustomer object. + * Updates balance, usage, and entities for each modified rollover. + */ +export const applyRolloverUpdatesToFullCustomer = ({ + fullCus, + rolloverUpdates, +}: { + fullCus: FullCustomer; + rolloverUpdates: Record; +}) => { + if (!rolloverUpdates || Object.keys(rolloverUpdates).length === 0) { + return; + } + + for (const cusProduct of fullCus.customer_products) { + for (const cusEnt of cusProduct.customer_entitlements) { + if (!cusEnt.rollovers) continue; + + for (const rollover of cusEnt.rollovers) { + const update = rolloverUpdates[rollover.id]; + if (!update) continue; + + rollover.balance = update.balance; + rollover.usage = update.usage; + rollover.entities = update.entities; + } + } + } +}; diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 0d739ce37..7392eb818 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -15,8 +15,12 @@ import { RedisDeductionError, RedisDeductionErrorCode, } from "../types/redisDeductionError.js"; -import type { LuaDeductionResult } from "../types/redisDeductionResult.js"; +import type { + LuaDeductionResult, + RolloverUpdate, +} from "../types/redisDeductionResult.js"; import { applyDeductionUpdateToFullCustomer } from "./applyDeductionUpdateToFullCustomer.js"; +import { applyRolloverUpdatesToFullCustomer } from "./applyRolloverUpdatesToFullCustomer.js"; import { logDeductionUpdates } from "./logDeductionUpdates.js"; import { prepareDeductionOptions } from "./prepareDeductionOptions.js"; import { prepareFeatureDeduction } from "./prepareFeatureDeduction.js"; @@ -37,6 +41,7 @@ export const executeRedisDeduction = async ({ oldFullCus: FullCustomer; fullCus: FullCustomer | undefined; updates: Record; + rolloverUpdates: Record; }> => { const { org, env } = ctx; const oldFullCus = structuredClone(fullCustomer); @@ -62,6 +67,7 @@ export const executeRedisDeduction = async ({ } let allUpdates: Record = {}; + let allRolloverUpdates: Record = {}; // Build cache key const customerId = fullCustomer.id || fullCustomer.internal_id; @@ -123,7 +129,7 @@ export const executeRedisDeduction = async ({ }); } - const { updates, logs } = resultJson; + const { updates, rollover_updates, logs } = resultJson; logDeductionUpdates({ ctx, fullCustomer, @@ -132,6 +138,7 @@ export const executeRedisDeduction = async ({ }); allUpdates = { ...allUpdates, ...updates }; + allRolloverUpdates = { ...allRolloverUpdates, ...rollover_updates }; if (logs && logs.length > 0) { ctx.logger.debug(`[executeRedisDeduction] Logs: ${logs.join("\n")}`); @@ -139,6 +146,13 @@ export const executeRedisDeduction = async ({ // Handle paid allocated entitlements and update fullCus in memory try { + // Apply rollover updates first + applyRolloverUpdatesToFullCustomer({ + fullCus: fullCustomer, + rolloverUpdates: rollover_updates, + }); + + // Apply customer entitlement updates for (const cusEntId of Object.keys(updates)) { const update = updates[cusEntId]; const cusEnt = customerEntitlements.find( @@ -179,5 +193,6 @@ export const executeRedisDeduction = async ({ oldFullCus, fullCus: fullCustomer, updates: allUpdates, + rolloverUpdates: allRolloverUpdates, }; }; diff --git a/server/src/internal/balances/utils/sql/syncBalancesV2.sql b/server/src/internal/balances/utils/sql/syncBalancesV2.sql index f4864d927..7af160e0d 100644 --- a/server/src/internal/balances/utils/sql/syncBalancesV2.sql +++ b/server/src/internal/balances/utils/sql/syncBalancesV2.sql @@ -6,9 +6,21 @@ -- - balance: number -- - adjustment: number -- - entities: jsonb (the full entities object) +-- - next_reset_at: bigint/number (unix timestamp, for conflict detection) +-- - entity_count: number (for conflict detection) +-- rollover_updates: array of objects with: +-- - rollover_id: string +-- - balance: number +-- - usage: number +-- - entities: jsonb (the full entities object) -- -- Returns JSONB with: -- updates: object mapping customer_entitlement_id -> { balance, adjustment, entities } +-- rollover_updates: object mapping rollover_id -> { balance, usage, entities } +-- +-- Raises exception if: +-- - next_reset_at in DB differs from input (indicates reset happened after cache was populated) +-- - entity count in DB differs from input (indicates entity was added/removed after cache was populated) -- DROP FUNCTION IF EXISTS sync_balances_v2(jsonb); @@ -18,57 +30,157 @@ LANGUAGE plpgsql AS $$ DECLARE customer_entitlement_updates jsonb := params->'customer_entitlement_updates'; + rollover_updates_param jsonb := params->'rollover_updates'; ent_obj jsonb; ent_id text; ent_balance numeric; ent_adjustment numeric; ent_entities jsonb; + ent_next_reset_at bigint; + ent_entity_count int; + + db_next_reset_at bigint; + db_entity_count int; + + rollover_obj jsonb; + rollover_id text; + rollover_balance numeric; + rollover_usage numeric; + rollover_entities jsonb; updates_json jsonb := '{}'::jsonb; + rollover_updates_json jsonb := '{}'::jsonb; cus_ent_ids text[]; + rollover_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; + -- ============================================================================ + -- STEP 1: Lock all rows upfront to prevent 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; + -- Extract all customer_entitlement_ids and lock rows + IF customer_entitlement_updates IS NOT NULL THEN + 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; 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'; + -- Extract all rollover_ids and lock rows + IF rollover_updates_param IS NOT NULL THEN + SELECT ARRAY( + SELECT jsonb_array_elements_text( + jsonb_path_query_array(rollover_updates_param, '$[*].rollover_id') + ) + ) INTO rollover_ids; - -- 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 - ) - ); + 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; - END LOOP; + END IF; - RETURN jsonb_build_object('updates', updates_json); + -- ============================================================================ + -- STEP 2: Update customer entitlements (with conflict detection) + -- ============================================================================ + IF customer_entitlement_updates IS NOT NULL THEN + 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'; + ent_next_reset_at := (ent_obj->>'next_reset_at')::bigint; + ent_entity_count := COALESCE((ent_obj->>'entity_count')::int, 0); + + -- Get current DB values for conflict detection + SELECT + ce.next_reset_at, + CASE + WHEN ce.entities IS NULL OR jsonb_typeof(ce.entities) != 'object' THEN 0 + ELSE (SELECT count(*) FROM jsonb_object_keys(ce.entities))::int + END + INTO db_next_reset_at, db_entity_count + FROM customer_entitlements ce + WHERE ce.id = ent_id; + + -- Guard 1: Check next_reset_at mismatch (indicates reset happened after cache was populated) + IF ent_next_reset_at IS NOT NULL AND db_next_reset_at IS NOT NULL + AND ent_next_reset_at != db_next_reset_at THEN + RAISE EXCEPTION 'RESET_AT_MISMATCH cus_ent_id:% cache_reset_at:% db_reset_at:%', + ent_id, ent_next_reset_at, db_next_reset_at; + END IF; + + -- Guard 2: Check entity count mismatch (indicates entity was added/removed after cache) + IF ent_entity_count != COALESCE(db_entity_count, 0) THEN + RAISE EXCEPTION 'ENTITY_COUNT_MISMATCH cus_ent_id:% cache_count:% db_count:%', + ent_id, ent_entity_count, COALESCE(db_entity_count, 0); + END IF; + + -- 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; + END IF; + + -- ============================================================================ + -- STEP 3: Update rollovers + -- ============================================================================ + IF rollover_updates_param IS NOT NULL THEN + FOR rollover_obj IN SELECT * FROM jsonb_array_elements(rollover_updates_param) + LOOP + rollover_id := rollover_obj->>'rollover_id'; + rollover_balance := (rollover_obj->>'balance')::numeric; + rollover_usage := (rollover_obj->>'usage')::numeric; + rollover_entities := rollover_obj->'entities'; + + -- Update the rollover row directly + UPDATE rollovers r + SET + balance = COALESCE(rollover_balance, r.balance), + usage = COALESCE(rollover_usage, r.usage), + entities = COALESCE(rollover_entities, r.entities) + WHERE r.id = rollover_id; + + -- Track update + IF FOUND THEN + rollover_updates_json := jsonb_set( + rollover_updates_json, + ARRAY[rollover_id], + jsonb_build_object( + 'balance', rollover_balance, + 'usage', rollover_usage, + 'entities', rollover_entities + ) + ); + END IF; + END LOOP; + END IF; + + RETURN jsonb_build_object( + 'updates', updates_json, + 'rollover_updates', rollover_updates_json + ); END; $$; diff --git a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts index 0ec22aa37..8b9e6a270 100644 --- a/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts +++ b/server/src/internal/balances/utils/sync/SyncBatchingManagerV2.ts @@ -11,6 +11,7 @@ interface CustomerBatchContext { region: string; timestamp: number; cusEntIds: Set; + rolloverIds: Set; } interface CustomerBatch { @@ -34,12 +35,14 @@ export class SyncBatchingManagerV2 { orgId, env, cusEntIds, + rolloverIds, region, }: { customerId: string; orgId: string; env: AppEnv; cusEntIds: string[]; + rolloverIds?: string[]; region?: string; }): void { const batchKey = this.buildBatchKey({ orgId, env, customerId }); @@ -52,20 +55,30 @@ export class SyncBatchingManagerV2 { } this.mergeCusEntIds({ batch, cusEntIds }); + this.mergeRolloverIds({ batch, rolloverIds: rolloverIds ?? [] }); - if (batch.context.cusEntIds.size >= this.MAX_BATCH_SIZE) { + const totalSize = + batch.context.cusEntIds.size + batch.context.rolloverIds.size; + if (totalSize >= this.MAX_BATCH_SIZE) { this.executeCustomerBatch({ batchKey }); } } - getStats(): { totalCustomers: number; totalPendingEntitlements: number } { + getStats(): { + totalCustomers: number; + totalPendingEntitlements: number; + totalPendingRollovers: number; + } { let totalEntitlements = 0; + let totalRollovers = 0; for (const batch of this.customerBatches.values()) { totalEntitlements += batch.context.cusEntIds.size; + totalRollovers += batch.context.rolloverIds.size; } return { totalCustomers: this.customerBatches.size, totalPendingEntitlements: totalEntitlements, + totalPendingRollovers: totalRollovers, }; } @@ -107,6 +120,7 @@ export class SyncBatchingManagerV2 { region: region || currentRegion, timestamp: Date.now(), cusEntIds: new Set(), + rolloverIds: new Set(), }, timer: null, }; @@ -124,6 +138,18 @@ export class SyncBatchingManagerV2 { } } + private mergeRolloverIds({ + batch, + rolloverIds, + }: { + batch: CustomerBatch; + rolloverIds: string[]; + }): void { + for (const id of rolloverIds) { + batch.context.rolloverIds.add(id); + } + } + private scheduleCustomerBatch({ batchKey }: { batchKey: string }): void { const batch = this.customerBatches.get(batchKey); if (!batch) return; @@ -145,7 +171,7 @@ export class SyncBatchingManagerV2 { this.customerBatches.delete(batchKey); const { context } = batch; - if (context.cusEntIds.size === 0) return; + if (context.cusEntIds.size === 0 && context.rolloverIds.size === 0) return; await this.queueSyncJob({ context }); } @@ -168,24 +194,20 @@ export class SyncBatchingManagerV2 { await addTaskToQueue({ jobName: JobName.SyncBalanceBatchV3, payload: { + customerId: context.customerId, orgId: context.orgId, env: context.env, - customerId: context.customerId, - item: { - customerId: context.customerId, - orgId: context.orgId, - env: context.env, - region: context.region, - timestamp: context.timestamp, - cusEntIds: Array.from(context.cusEntIds), - }, + region: context.region, + timestamp: context.timestamp, + cusEntIds: Array.from(context.cusEntIds), + rolloverIds: Array.from(context.rolloverIds), }, messageGroupId: context.customerId, messageDeduplicationId: dedupHash, }); logger.info( - `[SyncV3] Queued sync for ${context.customerId}, ${context.cusEntIds.size} entitlements`, + `[SyncV3] Queued sync for ${context.customerId}, ${context.cusEntIds.size} entitlements, ${context.rolloverIds.size} rollovers`, ); } catch (error) { logger.error( diff --git a/server/src/internal/balances/utils/sync/legacy/syncItem.ts b/server/src/internal/balances/utils/sync/legacy/syncItem.ts index d19fd4147..ada2a08ad 100644 --- a/server/src/internal/balances/utils/sync/legacy/syncItem.ts +++ b/server/src/internal/balances/utils/sync/legacy/syncItem.ts @@ -87,7 +87,10 @@ const applyCustomerEntitlementFiltersToBalance = ({ customerEntitlementFilters?: CustomerEntitlementFilters; }): ApiBalance | null => { // No filtering - return original balance - if (!customerEntitlementFilters?.cusEntIds || customerEntitlementFilters.cusEntIds.length === 0) { + if ( + !customerEntitlementFilters?.cusEntIds || + customerEntitlementFilters.cusEntIds.length === 0 + ) { return apiBalance; } @@ -143,7 +146,13 @@ export const syncItem = async ({ item: SyncItem; ctx: AutumnContext; }) => { - const { customerId, featureId, entityId, region, customerEntitlementFilters } = item; + const { + customerId, + featureId, + entityId, + region, + customerEntitlementFilters, + } = item; const { db, org, env } = ctx; // Get the correct regional Redis instance for this sync item diff --git a/server/src/internal/balances/utils/sync/syncItemV3.ts b/server/src/internal/balances/utils/sync/syncItemV3.ts index a5d45ec6c..6fa91264e 100644 --- a/server/src/internal/balances/utils/sync/syncItemV3.ts +++ b/server/src/internal/balances/utils/sync/syncItemV3.ts @@ -1,13 +1,70 @@ import { cusProductsToCusEnts, type EntityBalance, + type EntityRolloverBalance, type FullCustomer, findCustomerEntitlementById, + tryCatch, } from "@autumn/shared"; import { sql } from "drizzle-orm"; import { getRegionalRedis } from "@/external/redis/initRedis.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js"; +import type { Logger } from "../../../../external/logtail/logtailUtils"; + +const SYNC_CONFLICT_CODES = { + ResetAtMismatch: "RESET_AT_MISMATCH", + EntityCountMismatch: "ENTITY_COUNT_MISMATCH", +} as const; + +/** + * Handles sync errors from Postgres. Returns true if error was handled (conflict), false otherwise. + */ +const handleSyncPostgresError = async ({ + error, + customerId, + orgId, + env, + logger, +}: { + error: Error; + customerId: string; + orgId: string; + env: string; + logger: Logger; +}): Promise => { + const message = error.message || ""; + const isConflict = + message.includes(SYNC_CONFLICT_CODES.ResetAtMismatch) || + message.includes(SYNC_CONFLICT_CODES.EntityCountMismatch); + + if (!isConflict) { + throw error; + } + + // Extract conflict code and cus_ent_id from error message + const code = message.includes(SYNC_CONFLICT_CODES.ResetAtMismatch) + ? SYNC_CONFLICT_CODES.ResetAtMismatch + : SYNC_CONFLICT_CODES.EntityCountMismatch; + const cusEntMatch = message.match(/cus_ent_id:(\S+)/); + const cusEntId = cusEntMatch?.[1]; + + logger.warn( + `[SYNC V3] (${customerId}) Sync conflict detected: ${code}, cus_ent: ${cusEntId}. Clearing cache.`, + ); + + // Clear the stale cache + await deleteCachedFullCustomer({ + customerId, + orgId, + env, + source: `sync-conflict-${code}`, + logger, + }); + + return true; +}; export interface SyncItemV3 { customerId: string; @@ -16,6 +73,7 @@ export interface SyncItemV3 { region?: string; timestamp: number; cusEntIds: string[]; + rolloverIds?: string[]; } interface SyncEntry { @@ -24,6 +82,15 @@ interface SyncEntry { balance: number; adjustment: number; entities: Record | null; + next_reset_at: number | null; + entity_count: number; +} + +interface RolloverSyncEntry { + rollover_id: string; + balance: number; + usage: number; + entities: Record | null; } const buildSyncEntries = ({ @@ -48,18 +115,57 @@ const buildSyncEntries = ({ if (!cusEnt) continue; + const entityCount = cusEnt.entities + ? Object.keys(cusEnt.entities).length + : 0; + 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, + next_reset_at: cusEnt.next_reset_at ?? null, + entity_count: entityCount, }); } return entries; }; +const buildRolloverSyncEntries = ({ + fullCustomer, + rolloverIds, +}: { + fullCustomer: FullCustomer; + rolloverIds: string[]; +}): RolloverSyncEntry[] => { + if (!rolloverIds || rolloverIds.length === 0) { + return []; + } + + const entries: RolloverSyncEntry[] = []; + + for (const cusProduct of fullCustomer.customer_products) { + for (const cusEnt of cusProduct.customer_entitlements) { + if (!cusEnt.rollovers) continue; + + for (const rollover of cusEnt.rollovers) { + if (rolloverIds.includes(rollover.id)) { + entries.push({ + rollover_id: rollover.id, + balance: rollover.balance ?? 0, + usage: rollover.usage ?? 0, + entities: rollover.entities ?? null, + }); + } + } + } + } + + return entries; +}; + const formatSyncEntry = ({ entry }: { entry: SyncEntry }): string => { const hasEntities = entry.entities && Object.keys(entry.entities).length > 0; const entitiesStr = hasEntities @@ -68,24 +174,36 @@ const formatSyncEntry = ({ entry }: { entry: SyncEntry }): string => { return `${entry.feature_id} (${entry.customer_entitlement_id}): bal=${entry.balance}, adj=${entry.adjustment}${entitiesStr}`; }; +const formatRolloverSyncEntry = ({ + entry, +}: { + entry: RolloverSyncEntry; +}): string => { + const hasEntities = entry.entities && Object.keys(entry.entities).length > 0; + const entitiesStr = hasEntities + ? `, entities=${Object.keys(entry.entities!).length}` + : ""; + return `rollover ${entry.rollover_id}: bal=${entry.balance}, usage=${entry.usage}${entitiesStr}`; +}; + /** * Sync FullCustomer cache balances to Postgres */ export const syncItemV3 = async ({ - item, ctx, + payload, }: { - item: SyncItemV3; ctx: AutumnContext; + payload: SyncItemV3; }): Promise => { - const { customerId, region, cusEntIds } = item; + const { customerId, orgId, env, region, cusEntIds, rolloverIds } = payload; const { db, logger } = ctx; const redisInstance = region ? getRegionalRedis(region) : undefined; const fullCustomer = await getCachedFullCustomer({ - orgId: item.orgId, - env: item.env, + orgId, + env, customerId, redisInstance, }); @@ -96,8 +214,12 @@ export const syncItemV3 = async ({ } const entries = buildSyncEntries({ fullCustomer, cusEntIds }); + const rolloverEntries = buildRolloverSyncEntries({ + fullCustomer, + rolloverIds: rolloverIds ?? [], + }); - if (entries.length === 0) { + if (entries.length === 0 && rolloverEntries.length === 0) { logger.info(`[SYNC V3] No entries for ${customerId}`); return; } @@ -106,17 +228,47 @@ export const syncItemV3 = async ({ logger.info(`[SYNC V3] (${customerId}) ${formatSyncEntry({ entry })}`); } - const result = await db.execute( - sql`SELECT * FROM sync_balances_v2(${JSON.stringify({ customer_entitlement_updates: entries })}::jsonb)`, + for (const entry of rolloverEntries) { + logger.info( + `[SYNC V3] (${customerId}) ${formatRolloverSyncEntry({ entry })}`, + ); + } + + const { data: result, error } = await tryCatch( + db.execute( + sql`SELECT * FROM sync_balances_v2(${JSON.stringify({ + customer_entitlement_updates: entries, + rollover_updates: rolloverEntries, + })}::jsonb)`, + ), ); + if (error) { + await handleSyncPostgresError({ + error, + customerId, + orgId, + env, + logger, + }); + return; + } + const syncResult = result[0]?.sync_balances_v2 as - | { updates?: Record } + | { + updates?: Record; + rollover_updates?: Record; + } | undefined; const updateCount = syncResult?.updates ? Object.keys(syncResult.updates).length : 0; + const rolloverUpdateCount = syncResult?.rollover_updates + ? Object.keys(syncResult.rollover_updates).length + : 0; - logger.info(`[SYNC V3] (${customerId}) Done: ${updateCount} updated`); + logger.info( + `[SYNC V3] (${customerId}) Done: ${updateCount} cus_ents, ${rolloverUpdateCount} rollovers updated`, + ); }; diff --git a/server/src/internal/balances/utils/types/redisDeductionResult.ts b/server/src/internal/balances/utils/types/redisDeductionResult.ts index a7a4185ea..154397836 100644 --- a/server/src/internal/balances/utils/types/redisDeductionResult.ts +++ b/server/src/internal/balances/utils/types/redisDeductionResult.ts @@ -1,7 +1,15 @@ +import type { EntityRolloverBalance } from "@autumn/shared"; import type { DeductionUpdate } from "./deductionUpdate.js"; +export interface RolloverUpdate { + balance: number; + usage: number; + entities: Record; +} + export interface LuaDeductionResult { updates: Record; + rollover_updates: Record; remaining: number; error?: string; feature_id?: string; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index ec55ecb26..7213b3b38 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -15,10 +15,10 @@ import { cusEntsToMaxPurchase, cusEntsToPlanId, cusEntsToPrepaidQuantity, + cusEntsToPurchasedBalance, cusEntsToReset, cusEntsToRollovers, cusEntToKey, - cusEntToPurchasedBalance, dbToApiFeatureV1, expandIncludes, type Feature, @@ -223,11 +223,10 @@ export const getApiBalance = ({ .toNumber(); // 2. Purchased balance - const totalPurchasedBalance = sumValues( - cusEnts.map((cusEnt) => cusEntToPurchasedBalance({ cusEnt, entityId })), - ); - - console.log("totalPurchasedBalance", totalPurchasedBalance); + const totalPurchasedBalance = cusEntsToPurchasedBalance({ + cusEnts, + entityId, + }); // 3. Current balance let currentBalance = cusEntsToCurrentBalance({ diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts index 453fd209f..0850c2eb2 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/batchDeleteCachedFullCustomers.ts @@ -8,9 +8,17 @@ import { buildFullCustomerCacheKey, FULL_CUSTOMER_CACHE_GUARD_TTL_SECONDS, } from "./fullCustomerCacheConfig.js"; +import { buildTestFullCustomerCacheGuardKey } from "./testFullCustomerCacheGuard.js"; + +type CustomerToDelete = { + orgId: string; + env: string; + customerId: string; +}; /** - * Batch delete multiple FullCustomer caches in one Redis pipeline. + * Batch delete multiple FullCustomer caches. + * Groups by orgId to ensure all keys in each batch are on the same Redis Cluster node. * Sets guard keys to prevent stale writes from in-flight requests. */ export const batchDeleteCachedFullCustomers = async ({ @@ -18,11 +26,7 @@ export const batchDeleteCachedFullCustomers = async ({ source, logger, }: { - customers: Array<{ - orgId: string; - env: string; - customerId: string; - }>; + customers: CustomerToDelete[]; source?: string; logger?: Logger; }): Promise => { @@ -39,46 +43,72 @@ export const batchDeleteCachedFullCustomers = async ({ return 0; } + // Group customers by orgId to ensure all keys hash to the same Redis Cluster slot + const customersByOrg = new Map(); + for (const customer of customers) { + const existing = customersByOrg.get(customer.orgId) || []; + existing.push(customer); + customersByOrg.set(customer.orgId, existing); + } + try { const guardTimestamp = Date.now().toString(); + + // Use pipeline to batch all org deletions into one network round trip const pipeline = redis.pipeline(); + const orgIds: string[] = []; - // Queue SET (guard) and DEL (cache) for each customer - for (const { orgId, env, customerId } of customers) { - const cacheKey = buildFullCustomerCacheKey({ orgId, env, customerId }); - const guardKey = buildFullCustomerCacheGuardKey({ - orgId, - env, - customerId, - }); + for (const [orgId, orgCustomers] of customersByOrg) { + const customersData = orgCustomers.map(({ env, customerId }) => ({ + testGuardKey: buildTestFullCustomerCacheGuardKey({ + orgId, + env, + customerId, + }), + guardKey: buildFullCustomerCacheGuardKey({ orgId, env, customerId }), + cacheKey: buildFullCustomerCacheKey({ orgId, env, customerId }), + })); - pipeline.set( - guardKey, + pipeline.batchDeleteFullCustomerCache( guardTimestamp, - "EX", - FULL_CUSTOMER_CACHE_GUARD_TTL_SECONDS, + FULL_CUSTOMER_CACHE_GUARD_TTL_SECONDS.toString(), + JSON.stringify(customersData), ); - pipeline.del(cacheKey); + orgIds.push(orgId); } const results = await pipeline.exec(); - // Count deleted keys (every 2nd result is a DEL) + // Sum up results from all orgs let totalDeleted = 0; + let totalSkipped = 0; + if (results) { - for (let i = 1; i < results.length; i += 2) { - const [error, deletedCount] = results[i]; + for (const [error, resultJson] of results) { if (error) { - log.error(`[batchDeleteCachedFullCustomers] Pipeline error: ${error}`); + log.error( + `[batchDeleteCachedFullCustomers] Pipeline error: ${error}`, + ); throw error; } - totalDeleted += (deletedCount as number) ?? 0; + const result = JSON.parse(resultJson as string) as { + deleted: number; + skipped: number; + }; + totalDeleted += result.deleted; + totalSkipped += result.skipped; } } - log.info( - `[batchDeleteCachedFullCustomers] Deleted ${totalDeleted} keys for ${customers.length} customers, source: ${source}`, - ); + if (totalSkipped > 0) { + log.info( + `[batchDeleteCachedFullCustomers] Skipped ${totalSkipped} customers (test guard), deleted ${totalDeleted}, source: ${source}`, + ); + } else { + log.info( + `[batchDeleteCachedFullCustomers] Deleted ${totalDeleted} keys for ${customers.length} customers across ${customersByOrg.size} orgs, source: ${source}`, + ); + } return totalDeleted; } catch (error) { diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.ts index 0bec4209b..24b15241a 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.ts @@ -8,6 +8,7 @@ import { buildFullCustomerCacheKey, FULL_CUSTOMER_CACHE_GUARD_TTL_SECONDS, } from "./fullCustomerCacheConfig.js"; +import { buildTestFullCustomerCacheGuardKey } from "./testFullCustomerCacheGuard.js"; /** * Delete FullCustomer from Redis cache @@ -37,28 +38,38 @@ export const deleteCachedFullCustomer = async ({ if (!customerId) return; + const testGuardKey = buildTestFullCustomerCacheGuardKey({ + orgId, + env, + customerId, + }); const cacheKey = buildFullCustomerCacheKey({ orgId, env, customerId }); const guardKey = buildFullCustomerCacheGuardKey({ orgId, env, customerId }); try { - // Set guard key and delete cache key in one round trip const guardTimestamp = Date.now().toString(); - const results = await redis - .pipeline() - .set( - guardKey, - guardTimestamp, - "EX", - FULL_CUSTOMER_CACHE_GUARD_TTL_SECONDS, - ) - .del(cacheKey) - .exec(); - const deletedCount = results?.[1]?.[1] ?? 0; - - log.info( - `[deleteCachedFullCustomer] Deleted ${deletedCount} keys for ${customerId}, source: ${source}`, + const result = await redis.deleteFullCustomerCache( + testGuardKey, + guardKey, + cacheKey, + guardTimestamp, + FULL_CUSTOMER_CACHE_GUARD_TTL_SECONDS.toString(), ); + + if (result === "SKIPPED") { + log.info( + `[deleteCachedFullCustomer] Test guard exists, skipping deletion for ${customerId}`, + ); + } else if (result === "DELETED") { + log.info( + `[deleteCachedFullCustomer] Deleted cache for ${customerId}, source: ${source}`, + ); + } else { + log.debug( + `[deleteCachedFullCustomer] Cache key didn't exist for ${customerId}, source: ${source}`, + ); + } } catch (error) { log.error(`[deleteCachedFullCustomer] Error: ${error}`); throw error; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts index 0bc68e9ab..bf73ce48c 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts @@ -85,17 +85,19 @@ export const getOrSetCachedFullCustomer = async ({ // 3. Set cache (fire and forget) if (!skipCache) { - setCachedFullCustomer({ + await setCachedFullCustomer({ ctx, fullCustomer, customerId, fetchTimeMs, source, - }).catch((error) => { - logger.error( - `[getOrSetCachedFullCustomer] Failed to set cache: ${error}`, - ); }); + + // .catch((error) => { + // logger.error( + // `[getOrSetCachedFullCustomer] Failed to set cache: ${error}`, + // ); + // }); } return fullCustomer; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts new file mode 100644 index 000000000..c846430a2 --- /dev/null +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard.ts @@ -0,0 +1,67 @@ +import { logger } from "@/external/logtail/logtailUtils.js"; +import { redis } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +/** + * Builds the test cache delete guard key for fullCustomer cache + */ +export const buildTestFullCustomerCacheGuardKey = ({ + orgId, + env, + customerId, +}: { + orgId: string; + env: string; + customerId: string; +}) => `{${orgId}}:${env}:test_full_customer_cache_guard:${customerId}`; + +/** + * Sets a test cache delete guard to prevent fullCustomer cache deletion during testing. + * When this guard exists, deleteCachedFullCustomer will skip deletion. + */ +export const setTestFullCustomerCacheGuard = async ({ + ctx, + customerId, + ttlMs = 60000, // Default 60 seconds +}: { + ctx: AutumnContext; + customerId: string; + ttlMs?: number; +}): Promise => { + const key = buildTestFullCustomerCacheGuardKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + try { + await redis.set(key, "1", "PX", ttlMs); + return true; + } catch (error) { + logger.error(`Failed to set test fullCustomer cache guard: ${error}`); + return false; + } +}; + +/** + * Removes the test fullCustomer cache delete guard. + */ +export const removeTestFullCustomerCacheGuard = async ({ + ctx, + customerId, +}: { + ctx: AutumnContext; + customerId: string; +}): Promise => { + const key = buildTestFullCustomerCacheGuardKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + }); + try { + await redis.del(key); + return true; + } catch (error) { + logger.error(`Failed to remove test fullCustomer cache guard: ${error}`); + return false; + } +}; diff --git a/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts b/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts index 43ad87830..d9152ac89 100644 --- a/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts +++ b/server/src/queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.ts @@ -4,9 +4,9 @@ import { db } from "@/db/initDrizzle.js"; import { hatchet } from "@/external/hatchet/initHatchet.js"; import { getSentryTags } from "@/external/sentry/sentryUtils.js"; import { CusService } from "@/internal/customers/CusService.js"; -import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer.js"; -import { getCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/getCachedApiCustomer.js"; import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js"; import { JobName } from "../../JobName.js"; import { createWorkflowTask } from "../createWorkflowTask.js"; import { checkForMisingBalance } from "./checkForMisingBalance.js"; @@ -82,13 +82,24 @@ verifyCacheConsistencyWorkflow?.task({ handler: async ({ input, autumnContext }) => { const { customerId, source } = input; - // Get from cache - const { apiCustomer: cachedCustomer } = await getCachedApiCustomer({ - ctx: autumnContext, + // Get from cache (now using full customer cache) + const cachedFullCustomer = await getCachedFullCustomer({ + orgId: autumnContext.org.id, + env: autumnContext.env, customerId, - source: "verify", }); + if (!cachedFullCustomer) { + autumnContext.logger.info( + `[verifyCacheConsistency] No cached customer found for ${customerId}`, + ); + return { + consistent: true, + customerId, + source, + }; + } + // Get fresh from DB const fullCus = await CusService.getFull({ db, @@ -106,6 +117,13 @@ verifyCacheConsistencyWorkflow?.task({ withAutumnId: true, }); + // Convert cached full customer to API customer for comparison + const { apiCustomer: cachedCustomer } = await getApiCustomerBase({ + ctx: autumnContext, + fullCus: cachedFullCustomer, + withAutumnId: true, + }); + const { success: subscriptionsMatch, message: subscriptionsMatchMessage, @@ -119,10 +137,12 @@ verifyCacheConsistencyWorkflow?.task({ `[verifyCacheConsistency] subscriptions mismatch for customer ${customerId}`, ); - await deleteCachedApiCustomer({ + await deleteCachedFullCustomer({ customerId, orgId: autumnContext.org.id, env: autumnContext.env, + source: "verifyCacheConsistency", + logger: autumnContext.logger, }); Sentry.captureException(new Error(subscriptionsMatchMessage), { diff --git a/server/src/queue/initWorkers.ts b/server/src/queue/initWorkers.ts index 298893385..f97185078 100644 --- a/server/src/queue/initWorkers.ts +++ b/server/src/queue/initWorkers.ts @@ -168,7 +168,7 @@ const processMessage = async ({ await syncItemV3({ ctx, - item: job.data.item, + payload: job.data, }); return; } diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 996b111ca..6b74cb5dd 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -29,17 +29,13 @@ export interface Payloads { }; }; [JobName.SyncBalanceBatchV3]: { + customerId: string; orgId: string; env: AppEnv; - customerId: string; - item: { - customerId: string; - orgId: string; - env: string; - region?: string; - timestamp: number; - cusEntIds: string[]; - }; + region?: string; + timestamp: number; + cusEntIds: string[]; + rolloverIds?: string[]; }; [JobName.InsertEventBatch]: { events: EventInsert[]; diff --git a/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts b/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts index fae3cf882..98ecfb825 100644 --- a/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts +++ b/server/tests/balances/track/race-condition/simulate-verify-cache.test.ts @@ -1,181 +1,181 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type ApiCustomer, ApiVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { currentRegion } from "@/external/redis/initRedis.js"; -import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { CACHE_CUSTOMER_VERSIONS } from "../../../../src/_luaScripts/cacheConfig"; -import { syncItem } from "../../../../src/internal/balances/utils/sync/syncItem"; -import { deleteCachedApiCustomer } from "../../../../src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; +// import { beforeAll, describe, test } from "bun:test"; +// import { type ApiCustomer, ApiVersion } from "@autumn/shared"; +// import { TestFeature } from "@tests/setup/v2Features.js"; +// import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +// import chalk from "chalk"; +// import { AutumnInt } from "@/external/autumn/autumnCli.js"; +// import { currentRegion } from "@/external/redis/initRedis.js"; +// import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; +// import { CusService } from "@/internal/customers/CusService.js"; +// import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; +// import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +// import { +// constructProduct, +// constructRawProduct, +// } from "@/utils/scriptUtils/createTestProducts.js"; +// import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +// import { CACHE_CUSTOMER_VERSIONS } from "../../../../src/_luaScripts/cacheConfig"; +// import { syncItem } from "../../../../src/internal/balances/utils/sync/syncItem"; +// import { deleteCachedApiCustomer } from "../../../../src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; -const pro = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], -}); +// const pro = constructProduct({ +// type: "pro", +// items: [ +// constructFeatureItem({ +// featureId: TestFeature.Messages, +// includedUsage: 100, +// }), +// ], +// }); -const oneOffCredits = constructRawProduct({ - id: "one_off_messages", - isAddOn: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 250, - }), - ], -}); +// const oneOffCredits = constructRawProduct({ +// id: "one_off_messages", +// isAddOn: true, +// items: [ +// constructFeatureItem({ +// featureId: TestFeature.Messages, +// includedUsage: 250, +// }), +// ], +// }); -const testCase = "track-race-condition1"; +// const testCase = "track-race-condition1"; -describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out attached credits")}`, () => { - const customerId = testCase; - const autumnV2 = new AutumnInt({ - version: ApiVersion.V2_0, - }); - const autumnV2SkipCacheDeletion: AutumnInt = new AutumnInt({ - version: ApiVersion.V2_0, - skipCacheDeletion: true, - }); +// describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out attached credits")}`, () => { +// const customerId = testCase; +// const autumnV2 = new AutumnInt({ +// version: ApiVersion.V2_0, +// }); +// const autumnV2SkipCacheDeletion: AutumnInt = new AutumnInt({ +// version: ApiVersion.V2_0, +// skipCacheDeletion: true, +// }); - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", - }); +// beforeAll(async () => { +// await initCustomerV3({ +// ctx, +// customerId, +// withTestClock: true, +// attachPm: "success", +// }); - await initProductsV0({ - ctx, - products: [pro, oneOffCredits], - prefix: testCase, - }); +// await initProductsV0({ +// ctx, +// products: [pro, oneOffCredits], +// prefix: testCase, +// }); - await autumnV2.attach({ - customer_id: customerId, - product_id: pro.id, - }); - }); +// await autumnV2.attach({ +// customer_id: customerId, +// product_id: pro.id, +// }); +// }); - test("should manually reproduce race condition where sync wipes out attached credits", async () => { - console.log( - chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), - ); +// test("should manually reproduce race condition where sync wipes out attached credits", async () => { +// console.log( +// chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), +// ); - // STEP 1: Get full customer and set it in Redis cache - console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); +// // STEP 1: Get full customer and set it in Redis cache +// console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - withEntities: true, - withSubs: true, - }); +// const fullCus = await CusService.getFull({ +// db: ctx.db, +// idOrInternalId: customerId, +// orgId: ctx.org.id, +// env: ctx.env, +// withEntities: true, +// withSubs: true, +// }); - await setCachedApiCustomer({ - ctx, - fullCus, - customerId, - source: "test-setup", - fetchTimeMs: Date.now(), - }); - console.log(chalk.green("✓ Customer cached in Redis")); +// await setCachedApiCustomer({ +// ctx, +// fullCus, +// customerId, +// source: "test-setup", +// fetchTimeMs: Date.now(), +// }); +// console.log(chalk.green("✓ Customer cached in Redis")); - // STEP 2: Track 5 messages using globalBatchingManager.deduct() directly - // This deducts from Redis WITHOUT automatically queuing a sync - console.log( - chalk.yellow( - "\nStep 2: Tracking 5 messages directly via globalBatchingManager.deduct() (no auto sync)...", - ), - ); +// // STEP 2: Track 5 messages using globalBatchingManager.deduct() directly +// // This deducts from Redis WITHOUT automatically queuing a sync +// console.log( +// chalk.yellow( +// "\nStep 2: Tracking 5 messages directly via globalBatchingManager.deduct() (no auto sync)...", +// ), +// ); - await globalBatchingManager.deduct({ - customerId, - featureDeductions: [ - { - featureId: TestFeature.Messages, - amount: 5, - }, - ], - orgId: ctx.org.id, - env: ctx.env, - overageBehavior: "cap", - }); +// await globalBatchingManager.deduct({ +// customerId, +// featureDeductions: [ +// { +// featureId: TestFeature.Messages, +// amount: 5, +// }, +// ], +// orgId: ctx.org.id, +// env: ctx.env, +// overageBehavior: "cap", +// }); - console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); +// console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); - // Get the customer from Redis to see current balance - const customerAfterTrack = - await autumnV2.customers.get(customerId); +// // Get the customer from Redis to see current balance +// const customerAfterTrack = +// await autumnV2.customers.get(customerId); - console.log( - chalk.blue( - ` Current balance in Redis: ${customerAfterTrack.balances[TestFeature.Messages].current_balance}`, - ), - ); +// console.log( +// chalk.blue( +// ` Current balance in Redis: ${customerAfterTrack.balances[TestFeature.Messages].current_balance}`, +// ), +// ); - // STEP 3: Attach 250 credits (this updates DB directly) - console.log( - chalk.yellow("\nStep 3: Attaching 250 one-off credits (updates DB)..."), - ); - await autumnV2SkipCacheDeletion.attach({ - customer_id: customerId, - product_id: oneOffCredits.id, - }); +// // STEP 3: Attach 250 credits (this updates DB directly) +// console.log( +// chalk.yellow("\nStep 3: Attaching 250 one-off credits (updates DB)..."), +// ); +// await autumnV2SkipCacheDeletion.attach({ +// customer_id: customerId, +// product_id: oneOffCredits.id, +// }); - console.log(chalk.green("✓ Attached 250 credits to DB")); +// console.log(chalk.green("✓ Attached 250 credits to DB")); - // STEP 4: Manually call syncItemV2 to sync the OLD Redis balance to DB - // This simulates the race condition where sync runs AFTER attach - // The sync should detect that DB has newer data and NOT overwrite it - console.log( - chalk.yellow( - "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", - ), - ); +// // STEP 4: Manually call syncItemV2 to sync the OLD Redis balance to DB +// // This simulates the race condition where sync runs AFTER attach +// // The sync should detect that DB has newer data and NOT overwrite it +// console.log( +// chalk.yellow( +// "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", +// ), +// ); - await syncItem({ - item: { - customerId, - featureId: TestFeature.Messages, - orgId: ctx.org.id, - env: ctx.env, - timestamp: Date.now(), - region: currentRegion, - cacheVersion: CACHE_CUSTOMER_VERSIONS.LATEST, - }, - ctx, - }); - console.log(chalk.red("✓ Sync completed")); +// await syncItem({ +// item: { +// customerId, +// featureId: TestFeature.Messages, +// orgId: ctx.org.id, +// env: ctx.env, +// timestamp: Date.now(), +// region: currentRegion, +// cacheVersion: CACHE_CUSTOMER_VERSIONS.LATEST, +// }, +// ctx, +// }); +// console.log(chalk.red("✓ Sync completed")); - await deleteCachedApiCustomer({ - orgId: ctx.org.id, - env: ctx.env, - customerId: customerId, - source: "test-setup", - }); +// await deleteCachedApiCustomer({ +// orgId: ctx.org.id, +// env: ctx.env, +// customerId: customerId, +// source: "test-setup", +// }); - // 1. Check that credits weren't wiped out - const cachedCustomer = - await autumnV2.customers.get(customerId); +// // 1. Check that credits weren't wiped out +// const cachedCustomer = +// await autumnV2.customers.get(customerId); - console.log("Cached customer:", cachedCustomer); - }); -}); +// console.log("Cached customer:", cachedCustomer); +// }); +// }); diff --git a/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts b/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts index f1d3d4dbe..a664e2cd5 100644 --- a/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts +++ b/server/tests/balances/track/race-condition/simulate-verify-cache2.test.ts @@ -1,226 +1,226 @@ -import { beforeAll, describe, test } from "bun:test"; -import { type ApiCustomer, ApiVersion } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { currentRegion } from "@/external/redis/initRedis.js"; -import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; -import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { CACHE_CUSTOMER_VERSIONS } from "../../../../src/_luaScripts/cacheConfig"; -import { syncItem } from "../../../../src/internal/balances/utils/sync/syncItem"; -import { - removeTestCacheDeleteGuard, - setTestCacheDeleteGuard, -} from "../../../../src/internal/customers/cusUtils/apiCusCacheUtils/testCacheDeleteGuard"; -import { constructPrepaidItem } from "../../../../src/utils/scriptUtils/constructItem.js"; -import { timeout } from "../../../utils/genUtils"; +// import { beforeAll, describe, test } from "bun:test"; +// import { type ApiCustomer, ApiVersion } from "@autumn/shared"; +// import { TestFeature } from "@tests/setup/v2Features.js"; +// import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +// import chalk from "chalk"; +// import { AutumnInt } from "@/external/autumn/autumnCli.js"; +// import { currentRegion } from "@/external/redis/initRedis.js"; +// import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; +// import { CusService } from "@/internal/customers/CusService.js"; +// import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; +// import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +// import { +// constructProduct, +// constructRawProduct, +// } from "@/utils/scriptUtils/createTestProducts.js"; +// import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +// import { CACHE_CUSTOMER_VERSIONS } from "../../../../src/_luaScripts/cacheConfig"; +// import { syncItem } from "../../../../src/internal/balances/utils/sync/syncItem"; +// import { +// removeTestCacheDeleteGuard, +// setTestCacheDeleteGuard, +// } from "../../../../src/internal/customers/cusUtils/apiCusCacheUtils/testCacheDeleteGuard"; +// import { constructPrepaidItem } from "../../../../src/utils/scriptUtils/constructItem.js"; +// import { timeout } from "../../../utils/genUtils"; -const pro = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - }), - ], -}); +// const pro = constructProduct({ +// type: "pro", +// items: [ +// constructFeatureItem({ +// featureId: TestFeature.Messages, +// includedUsage: 100, +// }), +// ], +// }); -const oneOffCredits = constructRawProduct({ - id: "one_off_messages", - isAddOn: true, - items: [ - constructPrepaidItem({ - featureId: TestFeature.Messages, - includedUsage: 0, - billingUnits: 1, - price: 0.01, - isOneOff: true, - }), - ], -}); +// const oneOffCredits = constructRawProduct({ +// id: "one_off_messages", +// isAddOn: true, +// items: [ +// constructPrepaidItem({ +// featureId: TestFeature.Messages, +// includedUsage: 0, +// billingUnits: 1, +// price: 0.01, +// isOneOff: true, +// }), +// ], +// }); -const testCase = "simulate-verify-cache2"; +// const testCase = "simulate-verify-cache2"; -describe(`${chalk.yellowBright("simulate-verify-cache2: sync should not wipe out attached credits")}`, () => { - const customerId = testCase; - const autumnV2 = new AutumnInt({ - version: ApiVersion.V2_0, - }); - const autumnV2SkipCacheDeletion: AutumnInt = new AutumnInt({ - version: ApiVersion.V2_0, - skipCacheDeletion: true, - }); +// describe(`${chalk.yellowBright("simulate-verify-cache2: sync should not wipe out attached credits")}`, () => { +// const customerId = testCase; +// const autumnV2 = new AutumnInt({ +// version: ApiVersion.V2_0, +// }); +// const autumnV2SkipCacheDeletion: AutumnInt = new AutumnInt({ +// version: ApiVersion.V2_0, +// skipCacheDeletion: true, +// }); - beforeAll(async () => { - // Delete test cache delete guard - await removeTestCacheDeleteGuard({ - ctx, - customerId, - }); +// beforeAll(async () => { +// // Delete test cache delete guard +// await removeTestCacheDeleteGuard({ +// ctx, +// customerId, +// }); - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", - }); +// await initCustomerV3({ +// ctx, +// customerId, +// withTestClock: true, +// attachPm: "success", +// }); - await initProductsV0({ - ctx, - products: [pro, oneOffCredits], - prefix: testCase, - }); +// await initProductsV0({ +// ctx, +// products: [pro, oneOffCredits], +// prefix: testCase, +// }); - await autumnV2.attach({ - customer_id: customerId, - product_ids: [pro.id, oneOffCredits.id], - options: [ - { - feature_id: TestFeature.Messages, - quantity: 100, - }, - ], - }); +// await autumnV2.attach({ +// customer_id: customerId, +// product_ids: [pro.id, oneOffCredits.id], +// options: [ +// { +// feature_id: TestFeature.Messages, +// quantity: 100, +// }, +// ], +// }); - await timeout(3000); // let webhooks come in - }); +// await timeout(3000); // let webhooks come in +// }); - test("should manually reproduce race condition where sync wipes out attached credits", async () => { - console.log( - chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), - ); +// test("should manually reproduce race condition where sync wipes out attached credits", async () => { +// console.log( +// chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), +// ); - // STEP 1: Get full customer and set it in Redis cache - console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); +// // STEP 1: Get full customer and set it in Redis cache +// console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - withEntities: true, - withSubs: true, - }); +// const fullCus = await CusService.getFull({ +// db: ctx.db, +// idOrInternalId: customerId, +// orgId: ctx.org.id, +// env: ctx.env, +// withEntities: true, +// withSubs: true, +// }); - await setCachedApiCustomer({ - ctx, - fullCus, - customerId, - source: "test-setup", - fetchTimeMs: Date.now(), - }); - console.log(chalk.green("✓ Customer cached in Redis")); +// await setCachedApiCustomer({ +// ctx, +// fullCus, +// customerId, +// source: "test-setup", +// fetchTimeMs: Date.now(), +// }); +// console.log(chalk.green("✓ Customer cached in Redis")); - // STEP 2: Track 5 messages using globalBatchingManager.deduct() directly - // This deducts from Redis WITHOUT automatically queuing a sync - console.log( - chalk.yellow( - "\nStep 2: Tracking 5 messages directly via globalBatchingManager.deduct() (no auto sync)...", - ), - ); +// // STEP 2: Track 5 messages using globalBatchingManager.deduct() directly +// // This deducts from Redis WITHOUT automatically queuing a sync +// console.log( +// chalk.yellow( +// "\nStep 2: Tracking 5 messages directly via globalBatchingManager.deduct() (no auto sync)...", +// ), +// ); - await globalBatchingManager.deduct({ - customerId, - featureDeductions: [ - { - featureId: TestFeature.Messages, - amount: 5, - }, - ], - orgId: ctx.org.id, - env: ctx.env, - overageBehavior: "cap", - }); +// await globalBatchingManager.deduct({ +// customerId, +// featureDeductions: [ +// { +// featureId: TestFeature.Messages, +// amount: 5, +// }, +// ], +// orgId: ctx.org.id, +// env: ctx.env, +// overageBehavior: "cap", +// }); - console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); +// console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); - // Get the customer from Redis to see current balance - const customerAfterTrack = - await autumnV2.customers.get(customerId); +// // Get the customer from Redis to see current balance +// const customerAfterTrack = +// await autumnV2.customers.get(customerId); - const fullCustomerAfterTrack = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - withEntities: true, - withSubs: true, - }); +// const fullCustomerAfterTrack = await CusService.getFull({ +// db: ctx.db, +// idOrInternalId: customerId, +// orgId: ctx.org.id, +// env: ctx.env, +// withEntities: true, +// withSubs: true, +// }); - console.log( - chalk.blue( - ` Current balance in Redis: ${customerAfterTrack.balances[TestFeature.Messages].current_balance}`, - ), - ); +// console.log( +// chalk.blue( +// ` Current balance in Redis: ${customerAfterTrack.balances[TestFeature.Messages].current_balance}`, +// ), +// ); - // STEP 3: Attach 250 credits (this updates DB directly) - console.log( - chalk.yellow("\nStep 3: Attaching 250 one-off credits (updates DB)..."), - ); +// // STEP 3: Attach 250 credits (this updates DB directly) +// console.log( +// chalk.yellow("\nStep 3: Attaching 250 one-off credits (updates DB)..."), +// ); - await setTestCacheDeleteGuard({ - ctx, - customerId, - }); +// await setTestCacheDeleteGuard({ +// ctx, +// customerId, +// }); - await autumnV2SkipCacheDeletion.attach({ - customer_id: customerId, - product_id: oneOffCredits.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: 100, - }, - ], - }); +// await autumnV2SkipCacheDeletion.attach({ +// customer_id: customerId, +// product_id: oneOffCredits.id, +// options: [ +// { +// feature_id: TestFeature.Messages, +// quantity: 100, +// }, +// ], +// }); - // STEP 4: Manually call syncItemV2 to sync the OLD Redis balance to DB - // This simulates the race condition where sync runs AFTER attach - // The sync should detect that DB has newer data and NOT overwrite it - console.log( - chalk.yellow( - "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", - ), - ); +// // STEP 4: Manually call syncItemV2 to sync the OLD Redis balance to DB +// // This simulates the race condition where sync runs AFTER attach +// // The sync should detect that DB has newer data and NOT overwrite it +// console.log( +// chalk.yellow( +// "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", +// ), +// ); - await syncItem({ - item: { - customerId, - featureId: TestFeature.Messages, - orgId: ctx.org.id, - env: ctx.env, - timestamp: Date.now(), - region: currentRegion, - cacheVersion: CACHE_CUSTOMER_VERSIONS.LATEST, - fullCustomer: fullCustomerAfterTrack, - }, - ctx, - }); - console.log(chalk.red("✓ Sync completed")); +// await syncItem({ +// item: { +// customerId, +// featureId: TestFeature.Messages, +// orgId: ctx.org.id, +// env: ctx.env, +// timestamp: Date.now(), +// region: currentRegion, +// cacheVersion: CACHE_CUSTOMER_VERSIONS.LATEST, +// fullCustomer: fullCustomerAfterTrack, +// }, +// ctx, +// }); +// console.log(chalk.red("✓ Sync completed")); - // await removeTestCacheDeleteGuard({ - // ctx, - // customerId, - // }); +// // await removeTestCacheDeleteGuard({ +// // ctx, +// // customerId, +// // }); - // await deleteCachedApiCustomer({ - // orgId: ctx.org.id, - // env: ctx.env, - // customerId: customerId, - // source: "test-setup", - // }); +// // await deleteCachedApiCustomer({ +// // orgId: ctx.org.id, +// // env: ctx.env, +// // customerId: customerId, +// // source: "test-setup", +// // }); - // 1. Check that credits weren't wiped out - const cachedCustomer = - await autumnV2.customers.get(customerId); - }); -}); +// // 1. Check that credits weren't wiped out +// const cachedCustomer = +// await autumnV2.customers.get(customerId); +// }); +// }); diff --git a/server/tests/balances/track/race-condition/track-race-condition1.test.ts b/server/tests/balances/track/race-condition/track-race-condition1.test.ts index 7ffb3e354..de0442a52 100644 --- a/server/tests/balances/track/race-condition/track-race-condition1.test.ts +++ b/server/tests/balances/track/race-condition/track-race-condition1.test.ts @@ -5,10 +5,9 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { currentRegion } from "@/external/redis/initRedis.js"; -import { globalBatchingManager } from "@/internal/balances/track/redisTrackUtils/BatchingManager.js"; -import { syncItemV2 } from "@/internal/balances/utils/sync/syncItemV2.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; +import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; +import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; +import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; import { constructProduct, @@ -16,7 +15,7 @@ import { } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { deleteCachedApiCustomer } from "../../../../src/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; +import { deleteCachedFullCustomer } from "../../../../src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; const pro = constructProduct({ type: "pro", @@ -79,53 +78,43 @@ describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out // STEP 1: Get full customer and set it in Redis cache console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - withEntities: true, - withSubs: true, - }); - - await setCachedApiCustomer({ + const fullCustomer = await getOrSetCachedFullCustomer({ ctx, - fullCus, customerId, source: "test-setup", - fetchTimeMs: Date.now(), }); console.log(chalk.green("✓ Customer cached in Redis")); - // STEP 2: Track 5 messages using globalBatchingManager.deduct() directly + // STEP 2: Track 5 messages using executeRedisDeduction() directly // This deducts from Redis WITHOUT automatically queuing a sync console.log( chalk.yellow( - "\nStep 2: Tracking 5 messages directly via globalBatchingManager.deduct() (no auto sync)...", + "\nStep 2: Tracking 5 messages directly via executeRedisDeduction() (no auto sync)...", ), ); - const deductionResult = await globalBatchingManager.deduct({ - customerId, - featureDeductions: [ + const messagesFeature = ctx.features.find( + (f) => f.id === TestFeature.Messages, + )!; + + const deductionResult = await executeRedisDeduction({ + ctx, + deductions: [ { - featureId: TestFeature.Messages, - amount: 5, + feature: messagesFeature, + deduction: 5, }, ], - orgId: ctx.org.id, - env: ctx.env, - overageBehavior: "cap", + fullCustomer, + deductionOptions: { + overageBehaviour: "cap", + }, }); console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); - console.log( - " Redis balance after track:", - deductionResult.balances?.[TestFeature.Messages], - ); console.log( " Modified breakdown IDs:", - deductionResult.modifiedBreakdownIds, + Object.keys(deductionResult.updates), ); // Get the customer from Redis to see current balance @@ -148,7 +137,7 @@ describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out }); console.log(chalk.green("✓ Attached 250 credits to DB")); - // STEP 4: Manually call syncItemV2 to sync the OLD Redis balance to DB + // STEP 4: Manually call syncItemV3 to sync the OLD Redis balance to DB // This simulates the race condition where sync runs AFTER attach // The sync should detect that DB has newer data and NOT overwrite it console.log( @@ -157,24 +146,23 @@ describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out ), ); - // Get the breakdownIds from the deduction result - const breakdownIds = deductionResult.modifiedBreakdownIds || []; + // Get the cusEntIds from the deduction result + const cusEntIds = Object.keys(deductionResult.updates); - await syncItemV2({ - item: { + await syncItemV3({ + ctx, + payload: { customerId, - featureId: TestFeature.Messages, orgId: ctx.org.id, env: ctx.env, timestamp: Date.now(), region: currentRegion, - breakdownIds, + cusEntIds, }, - ctx, }); console.log(chalk.red("✓ Sync completed")); - await deleteCachedApiCustomer({ + await deleteCachedFullCustomer({ orgId: ctx.org.id, env: ctx.env, customerId: customerId, diff --git a/server/tests/balances/track/race-condition/track-race-condition2.test.ts b/server/tests/balances/track/race-condition/track-race-condition2.test.ts index 07b10b943..4879886b8 100644 --- a/server/tests/balances/track/race-condition/track-race-condition2.test.ts +++ b/server/tests/balances/track/race-condition/track-race-condition2.test.ts @@ -5,17 +5,23 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { currentRegion } from "@/external/redis/initRedis.js"; -import { runRedisDeduction } from "@/internal/balances/track/redisTrackUtils/runRedisDeduction.js"; -import { syncItem } from "@/internal/balances/utils/sync/syncItem.js"; -import { getTrackFeatureDeductions } from "@/internal/balances/track/trackUtils/getFeatureDeductions.js"; -import { CusService } from "@/internal/customers/CusService.js"; -import { setCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/setCachedApiCustomer.js"; +import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; +import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; +import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { + constructProduct, + constructRawProduct, +} from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { resetAndGetCusEnt } from "../../../advanced/rollovers/rolloverTestUtils.js"; -import { timeout } from "../../../utils/genUtils.js"; +import { deleteCachedFullCustomer } from "../../../../src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { + removeTestFullCustomerCacheGuard, + setTestFullCustomerCacheGuard, +} from "../../../../src/internal/customers/cusUtils/fullCustomerCacheUtils/testFullCustomerCacheGuard"; +import { constructPrepaidItem } from "../../../../src/utils/scriptUtils/constructItem.js"; +import { timeout } from "../../../utils/genUtils"; const pro = constructProduct({ type: "pro", @@ -27,9 +33,23 @@ const pro = constructProduct({ ], }); +const oneOffCredits = constructRawProduct({ + id: "one_off_messages", + isAddOn: true, + items: [ + constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 0, + billingUnits: 1, + price: 0.01, + isOneOff: true, + }), + ], +}); + const testCase = "track-race-condition2"; -describe(`${chalk.yellowBright("track race condition 2: track runs when credits are refreshing")}`, () => { +describe(`${chalk.yellowBright("track-race-condition2: sync should not wipe out top up credits")}`, () => { const customerId = testCase; const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0, @@ -40,6 +60,9 @@ describe(`${chalk.yellowBright("track race condition 2: track runs when credits }); beforeAll(async () => { + // Clean up any stale test guards from previous runs + await removeTestFullCustomerCacheGuard({ ctx, customerId }); + await initCustomerV3({ ctx, customerId, @@ -49,25 +72,25 @@ describe(`${chalk.yellowBright("track race condition 2: track runs when credits await initProductsV0({ ctx, - products: [pro], + products: [pro, oneOffCredits], prefix: testCase, }); await autumnV2.attach({ customer_id: customerId, - product_id: pro.id, + product_ids: [pro.id, oneOffCredits.id], + options: [ + { + feature_id: TestFeature.Messages, + quantity: 100, + }, + ], }); - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 80, - }); - - await timeout(2500); + await timeout(3000); // let webhooks come in }); - test("should manually reproduce race condition where sync wipes out refreshed credits", async () => { + test("should manually reproduce race condition where sync wipes out attached credits", async () => { console.log( chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), ); @@ -75,50 +98,44 @@ describe(`${chalk.yellowBright("track race condition 2: track runs when credits // STEP 1: Get full customer and set it in Redis cache console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); - const fullCus = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - withEntities: true, - withSubs: true, - }); - - await setCachedApiCustomer({ + const fullCustomer = await getOrSetCachedFullCustomer({ ctx, - fullCus, customerId, source: "test-setup", - fetchTimeMs: Date.now(), }); console.log(chalk.green("✓ Customer cached in Redis")); - // STEP 2: Track 5 messages using runRedisDeduction directly - // This will deduct from Redis and should queue a sync item + // STEP 2: Track 5 messages using executeRedisDeduction() directly + // This deducts from Redis WITHOUT automatically queuing a sync console.log( chalk.yellow( - "\nStep 2: Tracking 5 messages directly via runRedisDeduction...", + "\nStep 2: Tracking 5 messages directly via executeRedisDeduction() (no auto sync)...", ), ); - const featureDeductions = getTrackFeatureDeductions({ - ctx, - featureId: TestFeature.Messages, - value: 5, - }); - const deductionResult = await runRedisDeduction({ + const messagesFeature = ctx.features.find( + (f) => f.id === TestFeature.Messages, + )!; + + const deductionResult = await executeRedisDeduction({ ctx, - query: {}, - trackParams: { - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 5, + deductions: [ + { + feature: messagesFeature, + deduction: 5, + }, + ], + fullCustomer, + deductionOptions: { + overageBehaviour: "cap", }, - featureDeductions, - overageBehavior: "cap", }); - console.log(chalk.green("✓ Tracked 5 messages in Redis")); + console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); + console.log( + " Modified breakdown IDs:", + Object.keys(deductionResult.updates), + ); // Get the customer from Redis to see current balance const customerAfterTrack = @@ -130,41 +147,70 @@ describe(`${chalk.yellowBright("track race condition 2: track runs when credits ), ); - // STEP 3: Reset credits (simulates the cron reset operation) - await resetAndGetCusEnt({ - db: ctx.db, - customer: fullCus, - productGroup: pro.group!, - featureId: TestFeature.Messages, - skipCacheDeletion: true, - }); - - // STEP 4: Manually call syncItem to sync the OLD Redis balance to DB - // This should wipe out the 250 credits we just attached + // STEP 3: Lock the fullCustomer cache and attach 100 more credits + // The lock prevents cache invalidation during attach, simulating the race condition console.log( chalk.yellow( - "\nStep 4: Manually syncing OLD Redis balance to DB (this should wipe out the credits)...", + "\nStep 3: Locking cache and attaching 100 one-off credits (updates DB)...", ), ); - await syncItem({ - item: { + + await setTestFullCustomerCacheGuard({ ctx, customerId }); + + await autumnV2SkipCacheDeletion.attach({ + customer_id: customerId, + product_id: oneOffCredits.id, + options: [ + { + feature_id: TestFeature.Messages, + quantity: 100, + }, + ], + }); + console.log(chalk.green("✓ Attached 100 credits to DB (cache locked)")); + + // STEP 4: Manually call syncItemV3 to sync the OLD Redis balance to DB + // This simulates the race condition where sync runs AFTER attach + // The sync should detect that DB has newer data and NOT overwrite it + console.log( + chalk.yellow( + "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", + ), + ); + + // Get the cusEntIds from the deduction result + const cusEntIds = Object.keys(deductionResult.updates); + + await syncItemV3({ + ctx, + payload: { customerId, - featureId: TestFeature.Messages, orgId: ctx.org.id, env: ctx.env, timestamp: Date.now(), region: currentRegion, + cusEntIds, }, - ctx, }); - console.log(chalk.red("✓ Sync completed ")); + console.log(chalk.red("✓ Sync completed")); - // 1. Check that credits weren't wiped out + // Remove the test guard so cache can be invalidated normally + await removeTestFullCustomerCacheGuard({ ctx, customerId }); + + await deleteCachedFullCustomer({ + orgId: ctx.org.id, + env: ctx.env, + customerId: customerId, + source: "test-setup", + }); + + // Check that credits weren't wiped out const cachedCustomer = await autumnV2.customers.get(customerId); + // Expected: 100 (pro) + 100 (initial one-off) - 5 (tracked) + 100 (attached one-off) = 295 expect(cachedCustomer.balances[TestFeature.Messages].current_balance).toBe( - 100, + 295, ); const customerAfterSync = await autumnV2.customers.get( @@ -175,7 +221,6 @@ describe(`${chalk.yellowBright("track race condition 2: track runs when credits ); expect( customerAfterSync.balances[TestFeature.Messages].current_balance, - ).toBe(100); + ).toBe(295); }); }); - diff --git a/server/tests/balances/track/race-condition/track-race-condition3.test.ts b/server/tests/balances/track/race-condition/track-race-condition3.test.ts new file mode 100644 index 000000000..b30e821b9 --- /dev/null +++ b/server/tests/balances/track/race-condition/track-race-condition3.test.ts @@ -0,0 +1,185 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { type ApiCustomer, ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { currentRegion } from "@/external/redis/initRedis.js"; +import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; +import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; +import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { deleteCachedFullCustomer } from "../../../../src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { resetAndGetCusEnt } from "../../../advanced/rollovers/rolloverTestUtils.js"; +import { timeout } from "../../../utils/genUtils.js"; + +const pro = constructProduct({ + type: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +const testCase = "track-race-condition3"; + +describe(`${chalk.yellowBright("track-race-condition3: track runs when credits are refreshing")}`, () => { + const customerId = testCase; + const autumnV2 = new AutumnInt({ + version: ApiVersion.V2_0, + }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + }); + + await autumnV2.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 80, + }); + + await timeout(2500); + }); + + test("should manually reproduce race condition where sync wipes out refreshed credits", async () => { + console.log( + chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), + ); + + // STEP 1: Get full customer and set it in Redis cache + console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); + + const fullCustomer = await getOrSetCachedFullCustomer({ + ctx, + customerId, + source: "test-setup", + }); + console.log(chalk.green("✓ Customer cached in Redis")); + + // STEP 2: Track 5 messages using executeRedisDeduction() directly + // This deducts from Redis WITHOUT automatically queuing a sync + console.log( + chalk.yellow( + "\nStep 2: Tracking 5 messages directly via executeRedisDeduction() (no auto sync)...", + ), + ); + + const messagesFeature = ctx.features.find( + (f) => f.id === TestFeature.Messages, + )!; + + const deductionResult = await executeRedisDeduction({ + ctx, + deductions: [ + { + feature: messagesFeature, + deduction: 5, + }, + ], + fullCustomer, + deductionOptions: { + overageBehaviour: "cap", + }, + }); + + console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); + console.log( + " Modified breakdown IDs:", + Object.keys(deductionResult.updates), + ); + + // Get the customer from Redis to see current balance + const customerAfterTrack = + await autumnV2.customers.get(customerId); + + console.log( + chalk.blue( + ` Current balance in Redis: ${customerAfterTrack.balances[TestFeature.Messages].current_balance}`, + ), + ); + + // STEP 3: Reset credits (simulates the cron reset operation) + console.log( + chalk.yellow("\nStep 3: Resetting credits (simulates cron reset)..."), + ); + await resetAndGetCusEnt({ + db: ctx.db, + customer: fullCustomer, + productGroup: pro.group!, + featureId: TestFeature.Messages, + skipCacheDeletion: true, + }); + console.log(chalk.green("✓ Credits reset in DB")); + + // STEP 4: Manually call syncItemV3 to sync the OLD Redis balance to DB + // This simulates the race condition where sync runs AFTER reset + // The sync should detect that DB has newer data and NOT overwrite it + console.log( + chalk.yellow( + "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", + ), + ); + + // Get the cusEntIds from the deduction result + const cusEntIds = Object.keys(deductionResult.updates); + + await syncItemV3({ + ctx, + payload: { + customerId, + orgId: ctx.org.id, + env: ctx.env, + timestamp: Date.now(), + region: currentRegion, + cusEntIds, + }, + }); + console.log(chalk.red("✓ Sync completed")); + + await deleteCachedFullCustomer({ + orgId: ctx.org.id, + env: ctx.env, + customerId: customerId, + source: "test-setup", + }); + + // Check that credits weren't wiped out (should be reset to 100) + const cachedCustomer = + await autumnV2.customers.get(customerId); + + expect(cachedCustomer.balances[TestFeature.Messages].current_balance).toBe( + 100, + ); + + const customerAfterSync = await autumnV2.customers.get( + customerId, + { + skip_cache: "true", + }, + ); + expect( + customerAfterSync.balances[TestFeature.Messages].current_balance, + ).toBe(100); + }); +}); diff --git a/server/tests/balances/track/race-condition/track-race-condition4.test.ts b/server/tests/balances/track/race-condition/track-race-condition4.test.ts new file mode 100644 index 000000000..d7ecf5470 --- /dev/null +++ b/server/tests/balances/track/race-condition/track-race-condition4.test.ts @@ -0,0 +1,190 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { type ApiCustomer, ApiVersion } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { currentRegion } from "@/external/redis/initRedis.js"; +import { executeRedisDeduction } from "@/internal/balances/utils/deduction/executeRedisDeduction.js"; +import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; +import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { deleteCachedFullCustomer } from "../../../../src/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js"; +import { timeout } from "../../../utils/genUtils.js"; + +const pro = constructProduct({ + type: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }), + ], +}); + +const testCase = "track-race-condition4"; + +describe(`${chalk.yellowBright("track-race-condition4: sync should not wipe out newly created entity credits")}`, () => { + const customerId = testCase; + const autumnV2 = new AutumnInt({ + version: ApiVersion.V2_0, + }); + + const autumnV2WithoutCacheDeletion = new AutumnInt({ + version: ApiVersion.V2_0, + skipCacheDeletion: true, + }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); + + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + }); + + await autumnV2.entities.create(customerId, [ + { + id: "user-1", + name: "User 1", + feature_id: TestFeature.Users, + }, + ]); + + await autumnV2.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await timeout(2500); + }); + + test("should manually reproduce race condition where sync wipes out newly created entity credits", async () => { + console.log( + chalk.cyan("\n=== Manually orchestrating race condition steps ===\n"), + ); + + // STEP 1: Get full customer and set it in Redis cache + console.log(chalk.yellow("Step 1: Setting up customer in Redis cache...")); + + const fullCustomer = await getOrSetCachedFullCustomer({ + ctx, + customerId, + source: "test-setup", + }); + console.log(chalk.green("✓ Customer cached in Redis")); + + // STEP 2: Track 5 messages using executeRedisDeduction() directly + // This deducts from Redis WITHOUT automatically queuing a sync + console.log( + chalk.yellow( + "\nStep 2: Tracking 5 messages directly via executeRedisDeduction() (no auto sync)...", + ), + ); + + const messagesFeature = ctx.features.find( + (f) => f.id === TestFeature.Messages, + )!; + + const deductionResult = await executeRedisDeduction({ + ctx, + deductions: [ + { + feature: messagesFeature, + deduction: 5, + }, + ], + fullCustomer, + deductionOptions: { + overageBehaviour: "cap", + }, + }); + + console.log(chalk.green("✓ Tracked 5 messages in Redis (no sync queued)")); + console.log( + " Modified breakdown IDs:", + Object.keys(deductionResult.updates), + ); + + // Get the customer from Redis to see current balance + const customerAfterTrack = + await autumnV2.customers.get(customerId); + + console.log( + chalk.blue( + ` Current balance in Redis: ${customerAfterTrack.balances[TestFeature.Messages].current_balance}`, + ), + ); + + // STEP 3: Create a new entity + console.log(chalk.yellow("\nStep 3: Creating a new entity...")); + await autumnV2WithoutCacheDeletion.entities.create(customerId, [ + { + id: "user-2", + name: "User 2", + feature_id: TestFeature.Users, + }, + ]); + console.log(chalk.green("✓ New entity created")); + + // STEP 4: Manually call syncItemV3 to sync the OLD Redis balance to DB + // This simulates the race condition where sync runs AFTER reset + // The sync should detect that DB has newer data and NOT overwrite it + console.log( + chalk.yellow( + "\nStep 4: Manually syncing OLD Redis balance to DB (testing race condition)...", + ), + ); + + // Get the cusEntIds from the deduction result + const cusEntIds = Object.keys(deductionResult.updates); + + await syncItemV3({ + ctx, + payload: { + customerId, + orgId: ctx.org.id, + env: ctx.env, + timestamp: Date.now(), + region: currentRegion, + cusEntIds, + }, + }); + console.log(chalk.red("✓ Sync completed")); + + await deleteCachedFullCustomer({ + orgId: ctx.org.id, + env: ctx.env, + customerId: customerId, + source: "test-setup", + }); + + // Check that credits weren't wiped out (should be reset to 100) + const cachedCustomer = + await autumnV2.customers.get(customerId); + + expect(cachedCustomer.balances[TestFeature.Messages].current_balance).toBe( + 200, + ); + + const customerAfterSync = await autumnV2.customers.get( + customerId, + { + skip_cache: "true", + }, + ); + expect( + customerAfterSync.balances[TestFeature.Messages].current_balance, + ).toBe(200); + }); +}); diff --git a/server/tests/balances/track/rollovers/rolloverTestUtils.ts b/server/tests/balances/track/rollovers/rolloverTestUtils.ts new file mode 100644 index 000000000..eac987541 --- /dev/null +++ b/server/tests/balances/track/rollovers/rolloverTestUtils.ts @@ -0,0 +1,70 @@ +import type { Customer } from "@autumn/shared"; +import { + clearCusEntsFromCache, + resetCustomerEntitlement, +} from "@/cron/cronUtils.js"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js"; + +export const resetAndGetCusEnt = async ({ + db, + customer, + productGroup, + featureId, + skipCacheDeletion = false, +}: { + db: DrizzleCli; + customer: Customer; + productGroup: string; + featureId: string; + skipCacheDeletion?: boolean; +}) => { + // Run reset cusEnt on ... + let mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup, + }); + + let cusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId, + }); + + const resetCusEnt = { + ...cusEnt!, + customer, + }; + + const updatedCusEnt = await resetCustomerEntitlement({ + db, + cusEnt: resetCusEnt, + updatedCusEnts: [], + }); + + if (!skipCacheDeletion) { + await clearCusEntsFromCache({ cusEnts: [resetCusEnt] }); + } + + if (updatedCusEnt) { + await CusEntService.upsert({ + db, + data: [updatedCusEnt], + }); + } + + mainCusProduct = await getMainCusProduct({ + db, + internalCustomerId: customer.internal_id, + productGroup, + }); + + cusEnt = cusProductToCusEnt({ + cusProduct: mainCusProduct!, + featureId, + }); + + return cusEnt; +}; diff --git a/server/tests/balances/track/rollovers/track-rollover1.test.ts b/server/tests/balances/track/rollovers/track-rollover1.test.ts new file mode 100644 index 000000000..b05a7c178 --- /dev/null +++ b/server/tests/balances/track/rollovers/track-rollover1.test.ts @@ -0,0 +1,226 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type Customer, + LegacyVersion, + type LimitedItem, + ProductItemInterval, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +const rolloverConfig = { + max: 500, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; +const messagesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + interval: ProductItemInterval.Month, + rolloverConfig, +}) as LimitedItem; + +export const free = constructProduct({ + items: [messagesItem], + type: "free", + isDefault: false, +}); + +const testCase = "track-rollover1"; +// , per entity and regular + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + let customer: Customer; + + beforeAll(async () => { + await initProductsV0({ + ctx, + products: [free], + prefix: testCase, + customerId, + }); + + const res = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + customer = res.customer; + }); + + test("should attach free product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + }); + + const messageUsage = 250; + let curBalance = messagesItem.included_usage; + + test("should create track messages, reset, and have correct rollover", async () => { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messageUsage, + }); + + await timeout(3000); + + await resetAndGetCusEnt({ + db: ctx.db, + customer, + productGroup: free.group!, + featureId: TestFeature.Messages, + }); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + + const expectedRollover = Math.min( + messagesItem.included_usage - messageUsage, + rolloverConfig.max, + ); + + const expectedBalance = messagesItem.included_usage + expectedRollover; + + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe(expectedBalance); + // @ts-expect-error + expect(msgesFeature?.rollovers[0].balance).toBe(expectedRollover); + curBalance = expectedBalance; + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + expect(nonCachedMsgesFeature?.balance).toBe(expectedBalance); + // @ts-expect-error + expect(nonCachedMsgesFeature?.rollovers[0].balance).toBe(expectedRollover); + }); + + // let usage2 = 50; + test("should reset again and have correct rollover", async () => { + await resetAndGetCusEnt({ + db: ctx.db, + customer, + productGroup: free.group!, + featureId: TestFeature.Messages, + }); + + const expectedRollover = Math.min(curBalance, rolloverConfig.max); + const expectedBalance = messagesItem.included_usage + expectedRollover; + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe(expectedBalance); + + // @ts-expect-error (oldest rollover should be 100 (150 - 50)) + expect(msgesFeature?.rollovers[0].balance).toBe(100); + // @ts-expect-error (newest rollover should be 400 (msges.included_usage)) + expect(msgesFeature?.rollovers[1].balance).toBe(400); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + expect(nonCachedMsgesFeature?.balance).toBe(expectedBalance); + // @ts-expect-error + expect(nonCachedMsgesFeature?.rollovers[0].balance).toBe(100); + // @ts-expect-error + expect(nonCachedMsgesFeature?.rollovers[1].balance).toBe(400); + }); + + test("should track messages and deduct from rollovers first", async () => { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 150, + }); + + await timeout(3000); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + + // @ts-expect-error + const rollover1 = msgesFeature?.rollovers[0]; + // @ts-expect-error + const rollover2 = msgesFeature?.rollovers[1]; + + expect(rollover1.balance).toBe(0); + expect(rollover2.balance).toBe(350); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + // @ts-expect-error + const nonCachedRollover1 = nonCachedMsgesFeature?.rollovers[0]; + // @ts-expect-error + const nonCachedRollover2 = nonCachedMsgesFeature?.rollovers[1]; + expect(nonCachedRollover1.balance).toBe(0); + expect(nonCachedRollover2.balance).toBe(350); + }); + + test("should track and deduct from rollover + original balance", async () => { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 400, + }); + + await timeout(3000); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + + const rollovers = msgesFeature.rollovers; + // @ts-expect-error (rollovers is an array of rollovers) + expect(rollovers![0].balance).toBe(0); + // @ts-expect-error (rollovers is an array of rollovers) + expect(rollovers![1].balance).toBe(0); + expect(msgesFeature.balance).toBe(messagesItem.included_usage - 50); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + const nonCachedRollovers = nonCachedMsgesFeature.rollovers; + // @ts-expect-error + expect(nonCachedRollovers![0].balance).toBe(0); + // @ts-expect-error + expect(nonCachedRollovers![1].balance).toBe(0); + expect(nonCachedMsgesFeature.balance).toBe( + messagesItem.included_usage - 50, + ); + }); +}); diff --git a/server/tests/balances/track/rollovers/track-rollover2.test.ts b/server/tests/balances/track/rollovers/track-rollover2.test.ts new file mode 100644 index 000000000..5b0c3e880 --- /dev/null +++ b/server/tests/balances/track/rollovers/track-rollover2.test.ts @@ -0,0 +1,264 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type Customer, + LegacyVersion, + type LimitedItem, + ProductItemInterval, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +const rolloverConfig = { + max: 500, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const msgesItem = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + interval: ProductItemInterval.Month, + rolloverConfig, + entityFeatureId: TestFeature.Users, +}) as LimitedItem; + +export const free = constructProduct({ + items: [msgesItem], + type: "free", + isDefault: false, +}); + +const testCase = "track-rollover2"; +// , per entity and regular + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item (per entity)`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + let customer: Customer; + + beforeAll(async () => { + await initProductsV0({ + ctx, + products: [free], + prefix: testCase, + customerId, + }); + + const res = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + customer = res.customer; + }); + + const entities: any[] = [ + { + id: "1", + name: "Entity 1", + feature_id: TestFeature.Users, + }, + { + id: "2", + name: "Entity 2", + feature_id: TestFeature.Users, + }, + ]; + + test("should attach pro product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + + await autumn.entities.create(customerId, entities); + }); + + const entity1Id = entities[0].id; + const entity2Id = entities[1].id; + const newEntity1Balance = 300; + const newEntity2Balance = 200; + const includedUsage = msgesItem.included_usage; + const usages = [ + { + entityId: entity1Id, + usage: includedUsage - newEntity1Balance, + rollover: newEntity1Balance, + }, + { + entityId: entity2Id, + usage: includedUsage - newEntity2Balance, + rollover: newEntity2Balance, + }, + ]; + + test("should create track messages, reset, and have correct rollover", async () => { + for (const usage of usages) { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: usage.usage, + entity_id: usage.entityId, + }); + } + + await timeout(3000); + + // Run reset cusEnt on ... + await resetAndGetCusEnt({ + db: ctx.db, + customer, + productGroup: free.group!, + featureId: TestFeature.Messages, + }); + + for (const usage of usages) { + const entity = await autumn.entities.get(customerId, usage.entityId); + const msgesFeature = entity.features[TestFeature.Messages]; + const expectedRollover = Math.min(usage.rollover, rolloverConfig.max); + + expect(msgesFeature.rollovers.length).toBe(1); + expect(msgesFeature.balance).toBe(includedUsage + expectedRollover); + expect(msgesFeature.rollovers[0].balance).toBe(expectedRollover); + } + + // Verify non-cached entity balances + await timeout(2000); + for (const usage of usages) { + const expectedRollover = Math.min(usage.rollover, rolloverConfig.max); + const nonCachedEntity = await autumn.entities.get( + customerId, + usage.entityId, + { + skip_cache: "true", + }, + ); + const nonCachedMsgesFeature = + nonCachedEntity.features[TestFeature.Messages]; + expect(nonCachedMsgesFeature.balance).toBe( + includedUsage + expectedRollover, + ); + expect(nonCachedMsgesFeature.rollovers[0].balance).toBe(expectedRollover); + } + }); + + test("should reset again and have correct rollovers", async () => { + await resetAndGetCusEnt({ + db: ctx.db, + customer, + productGroup: free.group!, + featureId: TestFeature.Messages, + }); + + const entity1 = await autumn.entities.get(customerId, entity1Id); + const entity1Msges = entity1.features[TestFeature.Messages]; + // 400, 300 -> 400, 100 (max is 500) + const rollovers = entity1Msges.rollovers; + expect(rollovers[0].balance).toBe(100); + expect(rollovers[1].balance).toBe(400); + + const entity2 = await autumn.entities.get(customerId, entity2Id); + const entity2Msges = entity2.features[TestFeature.Messages]; + // 400, 200 -> 400, 0 (max is 500) + const rollovers2 = entity2Msges.rollovers; + expect(rollovers2[0].balance).toBe(100); + expect(rollovers2[1].balance).toBe(400); + + // Verify non-cached entity balances + await timeout(2000); + const nonCachedEntity1 = await autumn.entities.get(customerId, entity1Id, { + skip_cache: "true", + }); + const nonCachedEntity1Msges = + nonCachedEntity1.features[TestFeature.Messages]; + const nonCachedRollovers1 = nonCachedEntity1Msges.rollovers; + expect(nonCachedRollovers1[0].balance).toBe(100); + expect(nonCachedRollovers1[1].balance).toBe(400); + + const nonCachedEntity2 = await autumn.entities.get(customerId, entity2Id, { + skip_cache: "true", + }); + const nonCachedEntity2Msges = + nonCachedEntity2.features[TestFeature.Messages]; + const nonCachedRollovers2 = nonCachedEntity2Msges.rollovers; + expect(nonCachedRollovers2[0].balance).toBe(100); + expect(nonCachedRollovers2[1].balance).toBe(400); + }); + + test("should track and deduct from oldest rollovers first", async () => { + for (const entity of entities) { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 150, + entity_id: entity.id, + }); + + await timeout(2000); + const entRes = await autumn.entities.get(customerId, entity.id); + const msgesFeature = entRes.features[TestFeature.Messages]; + const rollovers = msgesFeature.rollovers; + expect(rollovers[0].balance).toBe(0); + expect(rollovers[1].balance).toBe(350); + expect(msgesFeature.balance).toBe(includedUsage + 350); + } + + // Verify non-cached entity balances + await timeout(2000); + for (const entity of entities) { + const nonCachedEntity = await autumn.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedEntity.features[TestFeature.Messages]; + const nonCachedRollovers = nonCachedMsgesFeature.rollovers; + expect(nonCachedRollovers[0].balance).toBe(0); + expect(nonCachedRollovers[1].balance).toBe(350); + expect(nonCachedMsgesFeature.balance).toBe(includedUsage + 350); + } + }); + + test("should track past rollovers and deduct from original balance", async () => { + for (const entity of entities) { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 400, + entity_id: entity.id, + }); + await timeout(2000); + + const entRes = await autumn.entities.get(customerId, entity.id); + const msgesFeature = entRes.features[TestFeature.Messages]; + const rollovers = msgesFeature.rollovers; + expect(rollovers[0].balance).toBe(0); + expect(rollovers[1].balance).toBe(0); + expect(msgesFeature.balance).toBe(includedUsage - 50); + } + + // Verify non-cached entity balances + await timeout(2000); + for (const entity of entities) { + const nonCachedEntity = await autumn.entities.get(customerId, entity.id, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedEntity.features[TestFeature.Messages]; + const nonCachedRollovers = nonCachedMsgesFeature.rollovers; + expect(nonCachedRollovers[0].balance).toBe(0); + expect(nonCachedRollovers[1].balance).toBe(0); + expect(nonCachedMsgesFeature.balance).toBe(includedUsage - 50); + } + }); +}); diff --git a/server/tests/balances/track/rollovers/track-rollover3.test.ts b/server/tests/balances/track/rollovers/track-rollover3.test.ts new file mode 100644 index 000000000..5a6e6521c --- /dev/null +++ b/server/tests/balances/track/rollovers/track-rollover3.test.ts @@ -0,0 +1,112 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + LegacyVersion, + type LimitedItem, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import type Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const rolloverConfig = { + max: 500, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; +const messagesItem = constructArrearProratedItem({ + featureId: TestFeature.Messages, + includedUsage: 400, + rolloverConfig, +}) as LimitedItem; + +export const pro = constructProduct({ + items: [messagesItem], + type: "pro", + isDefault: false, +}); + +const testCase = "track-rollover3"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + let testClockId: string; + let stripeCli: Stripe; + + beforeAll(async () => { + stripeCli = ctx.stripeCli; + + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + customerId, + }); + + const res = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + testClockId = res.testClockId!; + }); + + test("should attach pro product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + }); + + const rollover = 250; + + test("should create track messages, reset, and have correct rollover", async () => { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: messagesItem.included_usage - rollover, + }); + + await timeout(3000); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 20, + }); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + + const expectedBalance = messagesItem.included_usage + rollover; + + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe(expectedBalance); + // @ts-expect-error + expect(msgesFeature?.rollovers[0].balance).toBe(rollover); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + expect(nonCachedMsgesFeature?.balance).toBe(expectedBalance); + // @ts-expect-error + expect(nonCachedMsgesFeature?.rollovers[0].balance).toBe(rollover); + }); +}); diff --git a/server/tests/balances/track/rollovers/track-rollover4.test.ts b/server/tests/balances/track/rollovers/track-rollover4.test.ts new file mode 100644 index 000000000..bce9f3646 --- /dev/null +++ b/server/tests/balances/track/rollovers/track-rollover4.test.ts @@ -0,0 +1,163 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type ApiCusFeatureV3, + type Customer, + LegacyVersion, + type LimitedItem, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addMonths } from "date-fns"; +import type Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const rolloverConfig = { + max: 400, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; +const messagesItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 300, + price: 10, + rolloverConfig, +}) as LimitedItem; + +export const pro = constructProduct({ + items: [messagesItem], + type: "pro", + isDefault: false, +}); + +const testCase = "track-rollover4"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for usage price feature`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + let testClockId: string; + let customer: Customer; + let stripeCli: Stripe; + + let curUnix = new Date().getTime(); + + beforeAll(async () => { + stripeCli = ctx.stripeCli; + + await initProductsV0({ + ctx, + products: [pro], + prefix: testCase, + customerId, + }); + + const res = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + const paidQuantity = 300; + const balance = paidQuantity + messagesItem.included_usage; + const options = [ + { + feature_id: TestFeature.Messages, + quantity: paidQuantity, + }, + ]; + + test("should attach pro product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + options, + }); + }); + + const rollover = 50; + test("should create track messages, reset, and have correct rollover", async () => { + await autumn.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: balance - rollover, + }); + + await timeout(3000); + + curUnix = await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(new Date(), 1).getTime(), + waitForSeconds: 20, + }); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages] as ApiCusFeatureV3; + + const rollovers = msgesFeature?.rollovers; + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe(balance + rollover); + expect(rollovers?.[0].balance).toBe(rollover); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = nonCachedCustomer.features[ + TestFeature.Messages + ] as ApiCusFeatureV3; + const nonCachedRollovers = nonCachedMsgesFeature?.rollovers; + expect(nonCachedMsgesFeature?.balance).toBe(balance + rollover); + expect(nonCachedRollovers?.[0].balance).toBe(rollover); + }); + + // let usage2 = 50; + test("should reset again and have correct rollover", async () => { + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addMonths(curUnix, 1).getTime(), + waitForSeconds: 20, + }); + + const newRollover = Math.min(balance + rollover, rolloverConfig.max); + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages] as ApiCusFeatureV3; + + const rollovers = msgesFeature?.rollovers; + + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe(balance + newRollover); + expect(rollovers?.[0].balance).toBe(0); + expect(rollovers?.[1].balance).toBe(400); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + + const nonCachedRollovers = + nonCachedMsgesFeature?.rollovers as ApiCusFeatureV3["rollovers"]; + expect(nonCachedMsgesFeature?.balance).toBe(balance + newRollover); + expect(nonCachedRollovers?.[0].balance).toBe(0); + expect(nonCachedRollovers?.[1].balance).toBe(400); + }); +}); diff --git a/server/tests/balances/track/rollovers/track-rollover5.test.ts b/server/tests/balances/track/rollovers/track-rollover5.test.ts new file mode 100644 index 000000000..224e80fab --- /dev/null +++ b/server/tests/balances/track/rollovers/track-rollover5.test.ts @@ -0,0 +1,138 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type Customer, + LegacyVersion, + type LimitedItem, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +const freeRollover = { + max: 1000, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; +const proRollover = { + max: 600, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const freeMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: freeRollover, +}) as LimitedItem; + +const proMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: proRollover, +}) as LimitedItem; + +const free = constructProduct({ + items: [freeMsges], + type: "free", + isDefault: false, +}); + +const pro = constructProduct({ + items: [proMsges], + type: "pro", +}); + +const testCase = "track-rollover5"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + let customer: Customer; + + beforeAll(async () => { + await initProductsV0({ + ctx, + products: [free, pro], + prefix: testCase, + customerId, + }); + + const res = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + customer = res.customer; + }); + + test("should attach free product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + }); + + test("should create rollovers", async () => { + await resetAndGetCusEnt({ + customer, + db: ctx.db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + await resetAndGetCusEnt({ + customer, + db: ctx.db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + + // Attach pro + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + const freeRolloverBalance = freeMsges.included_usage * 2; + const proRolloverBalance = Math.min(proRollover.max, freeRolloverBalance); + + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe( + proMsges.included_usage + proRolloverBalance, + ); + const rollovers = msgesFeature?.rollovers; + // @ts-expect-error (rollovers is an array of rollovers) + expect(rollovers?.[0].balance).toBe(100); + // @ts-expect-error (rollovers is an array of rollovers) + expect(rollovers[1].balance).toBe(500); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + expect(nonCachedMsgesFeature?.balance).toBe( + proMsges.included_usage + proRolloverBalance, + ); + + const nonCachedRollovers = nonCachedMsgesFeature?.rollovers; + // @ts-expect-error (rollovers is an array of rollovers) + expect(nonCachedRollovers[0].balance).toBe(100); + // @ts-expect-error (rollovers is an array of rollovers) + expect(nonCachedRollovers[1].balance).toBe(500); + }); +}); diff --git a/server/tests/balances/track/rollovers/track-rollover6.test.ts b/server/tests/balances/track/rollovers/track-rollover6.test.ts new file mode 100644 index 000000000..196613264 --- /dev/null +++ b/server/tests/balances/track/rollovers/track-rollover6.test.ts @@ -0,0 +1,159 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { + type Customer, + LegacyVersion, + type LimitedItem, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { hoursToFinalizeInvoice } from "@tests/utils/constants.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { addHours, addMonths } from "date-fns"; +import type Stripe from "stripe"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { timeout } from "@/utils/genUtils.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; +import { resetAndGetCusEnt } from "./rolloverTestUtils.js"; + +const freeRollover = { + max: 600, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; +const proRollover = { + max: 1000, + length: 1, + duration: RolloverExpiryDurationType.Month, +}; + +const freeMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: freeRollover, +}) as LimitedItem; + +const proMsges = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 500, + rolloverConfig: proRollover, +}) as LimitedItem; + +const free = constructProduct({ + items: [freeMsges], + type: "free", + isDefault: false, +}); + +const pro = constructProduct({ + items: [proMsges], + type: "pro", +}); + +const testCase = "track-rollover6"; + +describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for upgrade`)}`, () => { + const customerId = testCase; + const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 }); + let testClockId: string; + let customer: Customer; + let stripeCli: Stripe; + const curUnix = Date.now(); + + beforeAll(async () => { + stripeCli = ctx.stripeCli; + + await initProductsV0({ + ctx, + products: [free, pro], + prefix: testCase, + customerId, + }); + + const res = await initCustomerV3({ + ctx, + customerId, + customerData: {}, + attachPm: "success", + withTestClock: true, + }); + + testClockId = res.testClockId!; + customer = res.customer; + }); + + test("should attach free product", async () => { + await autumn.attach({ + customer_id: customerId, + product_id: pro.id, + }); + }); + + test("should create rollovers", async () => { + await resetAndGetCusEnt({ + customer, + db: ctx.db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + await resetAndGetCusEnt({ + customer, + db: ctx.db, + productGroup: testCase, + featureId: TestFeature.Messages, + }); + + // Attach pro + await autumn.attach({ + customer_id: customerId, + product_id: free.id, + }); + + await advanceTestClock({ + stripeCli, + testClockId, + advanceTo: addHours( + addMonths(curUnix, 1), + hoursToFinalizeInvoice, + ).getTime(), + waitForSeconds: 20, + }); + + const cus = await autumn.customers.get(customerId); + const msgesFeature = cus.features[TestFeature.Messages]; + const proRolloverBalance = proMsges.included_usage * 2; + const freeRolloverBalance = Math.min(freeRollover.max, proRolloverBalance); + + expect(msgesFeature).toBeDefined(); + expect(msgesFeature?.balance).toBe( + freeMsges.included_usage + freeRolloverBalance, + ); + + const rollovers = msgesFeature?.rollovers; + // @ts-expect-error (rollovers is an array of rollovers) + expect(rollovers?.[0].balance).toBe(100); + // @ts-expect-error (rollovers is an array of rollovers) + expect(rollovers[1].balance).toBe(500); + + // Verify non-cached customer balance + await timeout(2000); + const nonCachedCustomer = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + const nonCachedMsgesFeature = + nonCachedCustomer.features[TestFeature.Messages]; + expect(nonCachedMsgesFeature?.balance).toBe( + freeMsges.included_usage + freeRolloverBalance, + ); + + const nonCachedRollovers = nonCachedMsgesFeature?.rollovers; + // @ts-expect-error (rollovers is an array of rollovers) + expect(nonCachedRollovers?.[0].balance).toBe(100); + // @ts-expect-error (rollovers is an array of rollovers) + expect(nonCachedRollovers[1].balance).toBe(500); + }); +}); diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts deleted file mode 100644 index 9ef5711e9..000000000 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; -import { - cusEntToCusPrice, - entToOptions, -} from "../../productUtils/convertUtils.js"; -import { getBillingType } from "../../productUtils/priceUtils.js"; -import { nullish } from "../../utils.js"; -import { getCusEntBalance } from "../balanceUtils.js"; - -export const cusEntToPurchasedBalance = ({ - cusEnt, - entityId, -}: { - cusEnt: FullCusEntWithFullCusProduct; - entityId?: string; -}) => { - // return 0; - // 1. If prepaid - const cusPrice = cusEntToCusPrice({ cusEnt }); - if (nullish(cusPrice)) { - const { balance } = getCusEntBalance({ - cusEnt, - entityId, - }); - - return Math.max(0, -balance); - } - - const billingType = getBillingType(cusPrice.price.config); - const billingUnits = cusPrice.price.config.billing_units || 1; - - if (billingType === BillingType.UsageInAdvance) { - // Purchased balance is how much was prepaid - const cusProduct = cusEnt.customer_product; - const options = entToOptions({ - ent: cusEnt.entitlement, - options: cusProduct.options, - }); - - const quantity = options?.quantity || 0; - const quantityWithBillingUnits = new Decimal(quantity) - .mul(billingUnits) - .toNumber(); - - // Add negative cus ent balance too - const { balance } = getCusEntBalance({ - cusEnt, - entityId, - }); - - return quantityWithBillingUnits + Math.max(0, -balance); - } - - const { balance } = getCusEntBalance({ - cusEnt, - entityId, - }); - - return Math.max(0, -balance); -}; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts index e67f92cbf..4616f487b 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts @@ -1,7 +1,50 @@ import { Decimal } from "decimal.js"; +import type { FullCustomerEntitlement } from "../../../models/cusProductModels/cusEntModels/cusEntModels"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; -import { sumValues } from "../../utils"; -import { cusEntToBalance } from "../convertCusEntUtils"; +import { AllowanceType } from "../../../models/productModels/entModels/entModels"; +import { nullish, sumValues } from "../../utils"; +import { isEntityScopedCusEnt } from "../classifyCusEntUtils"; +import { getRolloverFields } from "../getRolloverFields"; + +export const cusEntToCurrentBalance = ({ + cusEnt, + entityId, + withRollovers = false, +}: { + cusEnt: FullCustomerEntitlement; + entityId?: string; + withRollovers?: boolean; +}): number => { + if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) return 0; + + const getCusEntMainBalance = () => { + if (isEntityScopedCusEnt({ cusEnt })) { + if (nullish(entityId)) { + const entities = Object.values(cusEnt.entities ?? {}); + return sumValues(entities.map((entity) => Math.max(0, entity.balance))); + } else { + const entityBalance = cusEnt.entities?.[entityId]?.balance; + + return Math.max(0, entityBalance ?? 0); + } + } + + return Math.max(0, cusEnt.balance ?? 0); + }; + + const mainBalance = getCusEntMainBalance(); + + const rollover = getRolloverFields({ + cusEnt, + entityId, + }); + + if (withRollovers && rollover) { + return new Decimal(mainBalance).add(rollover.balance).toNumber(); + } + + return mainBalance; +}; export const cusEntsToCurrentBalance = ({ cusEnts, @@ -12,25 +55,25 @@ export const cusEntsToCurrentBalance = ({ entityId?: string; withRollovers?: boolean; }) => { - const cusEntToCurrentBalance = ({ - cusEnt, - entityId, - withRollovers = false, - }: { - cusEnt: FullCusEntWithFullCusProduct; - entityId?: string; - withRollovers?: boolean; - }) => { - const balance = cusEntToBalance({ - cusEnt, - entityId, - withRollovers, - }); + // const cusEntToCurrentBalance = ({ + // cusEnt, + // entityId, + // withRollovers = false, + // }: { + // cusEnt: FullCusEntWithFullCusProduct; + // entityId?: string; + // withRollovers?: boolean; + // }) => { + // const balance = cusEntToBalance({ + // cusEnt, + // entityId, + // withRollovers, + // }); - const currentBalance = new Decimal(Math.max(0, balance)).toNumber(); + // const currentBalance = new Decimal(Math.max(0, balance)).toNumber(); - return currentBalance; - }; + // return currentBalance; + // }; return sumValues( cusEnts.map((cusEnt) => diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts index 1603b1602..df771052d 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.ts @@ -1,6 +1,7 @@ import { Decimal } from "decimal.js"; import { cusEntToCusPrice, + entToOptions, type FullCusEntWithFullCusProduct, isPrepaidPrice, sumValues, @@ -17,10 +18,10 @@ export const cusEntToPrepaidQuantity = ({ if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return 0; // 3. Get quantity - const options = cusEnt.customer_product.options.find( - (option) => - option.internal_feature_id === cusEnt.entitlement.internal_feature_id, - ); + const options = entToOptions({ + ent: cusEnt.entitlement, + options: cusEnt.customer_product.options, + }); if (!options) return 0; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts new file mode 100644 index 000000000..ebbe2b1bd --- /dev/null +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.ts @@ -0,0 +1,68 @@ +import { Decimal } from "decimal.js"; +import type { FullCustomerEntitlement } from "../../../models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; +import { cusEntToCusPrice } from "../../productUtils/convertUtils.js"; +import { getBillingType } from "../../productUtils/priceUtils.js"; +import { nullish, sumValues } from "../../utils.js"; +import { isEntityScopedCusEnt } from "../classifyCusEntUtils.js"; +import { cusEntToPrepaidQuantity } from "./cusEntsToPrepaidQuantity.js"; + +export const getCusEntMainOverage = ({ + cusEnt, + entityId, +}: { + cusEnt: FullCustomerEntitlement; + entityId?: string; +}) => { + if (isEntityScopedCusEnt({ cusEnt })) { + if (nullish(entityId)) { + const entities = Object.values(cusEnt.entities ?? {}); + return sumValues(entities.map((entity) => Math.max(0, -entity.balance))); + } else { + const entityBalance = cusEnt.entities?.[entityId]?.balance; + + return Math.max(0, -(entityBalance ?? 0)); + } + } + + return Math.max(0, -(cusEnt.balance ?? 0)); +}; + +export const cusEntToPurchasedBalance = ({ + cusEnt, + entityId, +}: { + cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; +}) => { + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (nullish(cusPrice)) { + return getCusEntMainOverage({ cusEnt, entityId }); + } + + const billingType = getBillingType(cusPrice.price.config); + + if (billingType === BillingType.UsageInAdvance) { + // Purchased balance is how much was prepaid + const prepaidQuantity = cusEntToPrepaidQuantity({ cusEnt }); + + const mainOverage = getCusEntMainOverage({ cusEnt, entityId }); + + return new Decimal(prepaidQuantity).add(mainOverage).toNumber(); + } + + return getCusEntMainOverage({ cusEnt, entityId }); +}; + +export const cusEntsToPurchasedBalance = ({ + cusEnts, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + entityId?: string; +}) => { + return sumValues( + cusEnts.map((cusEnt) => cusEntToPurchasedBalance({ cusEnt, entityId })), + ); +}; diff --git a/shared/utils/index.ts b/shared/utils/index.ts index d315eab1e..f2cd48587 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -5,9 +5,9 @@ export * from "./cursorUtils.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToCurrentBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToPrepaidQuantity.js"; +export * from "./cusEntUtils/balanceUtils/cusEntsToPurchasedBalance.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToReset.js"; export * from "./cusEntUtils/balanceUtils/cusEntsToRollovers.js"; -export * from "./cusEntUtils/balanceUtils/cusEntToPurchasedBalance.js"; // Cus ent utils export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.js"; export * from "./cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.js";