diff --git a/scripts/testGroups/all.sh b/scripts/testGroups/all.sh index e858d232d..7ec1b1da6 100755 --- a/scripts/testGroups/all.sh +++ b/scripts/testGroups/all.sh @@ -9,9 +9,9 @@ BUN_PARALLEL_V2 \ 'integration/billing/stripe-webhooks' \ 'integration/billing/autumn-webhooks' \ 'integration/cron' \ - # 'integration/crud/plans' \ - # 'integration/billing/update-subscription' \ - # 'integration/billing/attach' \ + 'integration/crud/plans' \ + 'integration/billing/update-subscription' \ + 'integration/billing/attach' \ # 'integration/billing/attach' \ diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 0e7f22370..fac2b6289 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -36,15 +36,6 @@ BUN_PARALLEL_V2 \ 'balances/check/send-event' \ 'balances/check/loose' \ 'balances/set-usage' \ + 'integration/balances/update' \ --max=6 - -BUN_PARALLEL_V2 \ - 'server/tests/balances/update/filters' \ - 'server/tests/balances/update/update-combined' \ - 'server/tests/balances/update/update-current-balance/basic' \ - 'server/tests/balances/update/update-current-balance/entity' \ - 'server/tests/balances/update/update-current-balance/allocated' \ - 'server/tests/balances/update/update-current-balance/breakdown' \ - 'server/tests/balances/update/update-granted-balance' \ - --max=6 diff --git a/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts b/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts new file mode 100644 index 000000000..73edcc277 --- /dev/null +++ b/server/src/honoMiddlewares/responseFilter/responseFilterConfig.ts @@ -0,0 +1,69 @@ +import { + type ApiBalanceBreakdownV1, + ApiBalanceBreakdownV1Schema, + type ApiBalanceV1, + ApiBalanceV1Schema, +} from "@autumn/shared"; +import type { z } from "zod/v4"; + +/** + * Extract the object literal type from a schema with an `object` field. + */ +type ExtractObjectType = T extends { object: infer O } ? O : never; + +/** + * A filter config entry that maps an object type to fields to omit. + */ +type FilterConfigEntry = { + objectType: ExtractObjectType; + omitFields: (keyof T)[]; +}; + +/** + * Creates a strongly-typed filter config entry. + * Ensures the object type and fields match the schema. + */ +function createFilterConfig({ + schema, + omitFields, +}: { + schema: z.ZodType; + omitFields: (keyof T)[]; +}): FilterConfigEntry { + // Parse just to extract the object type from the schema's shape + const shape = (schema as z.ZodObject).shape; + const objectField = shape.object as z.ZodLiteral; + const objectType = objectField.value as ExtractObjectType; + + return { + objectType, + omitFields, + }; +} + +/** + * Filter configurations for each object type. + * Use createFilterConfig for type safety. + */ +const filterConfigs = [ + createFilterConfig({ + schema: ApiBalanceBreakdownV1Schema, + omitFields: ["overage", "expires_at", "object"], + }), + createFilterConfig({ + schema: ApiBalanceV1Schema, + omitFields: ["object"], + }), +]; + +/** + * Runtime config mapping object type to fields to omit. + * Built from the typed filterConfigs array. + */ +export const responseFilterConfig: Record = + Object.fromEntries( + filterConfigs.map((config) => [ + config.objectType, + config.omitFields as string[], + ]), + ); diff --git a/server/src/honoMiddlewares/responseFilter/responseFilterMiddleware.ts b/server/src/honoMiddlewares/responseFilter/responseFilterMiddleware.ts new file mode 100644 index 000000000..fa59b47e1 --- /dev/null +++ b/server/src/honoMiddlewares/responseFilter/responseFilterMiddleware.ts @@ -0,0 +1,66 @@ +import type { Context, Next } from "hono"; +import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import { responseFilterConfig } from "./responseFilterConfig.js"; + +/** + * Recursively strips internal fields from response data based on object type. + * Uses the `object` field to identify which filter rules to apply. + */ +function stripInternalFields({ data }: { data: unknown }): unknown { + if (data === null || typeof data !== "object") return data; + + if (Array.isArray(data)) { + return data.map((item) => stripInternalFields({ data: item })); + } + + const obj = data as Record; + const objectType = obj.object; + + // Get fields to omit for this object type + const fieldsToOmit = + typeof objectType === "string" + ? (responseFilterConfig[objectType] ?? []) + : []; + + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (fieldsToOmit.includes(key)) continue; + result[key] = stripInternalFields({ data: value }); + } + + return result; +} + +/** + * Middleware that filters internal fields from JSON responses. + * + * Runs after the handler completes, parses the JSON response, + * recursively strips fields marked as internal based on object type, + * and replaces the response with the filtered version. + */ +export const responseFilterMiddleware = async ( + c: Context, + next: Next, +) => { + await next(); + + // Only process JSON responses + const contentType = c.res.headers.get("content-type"); + if (!contentType?.includes("application/json")) return; + + // Only process successful responses + if (c.res.status < 200 || c.res.status >= 300) return; + + try { + const cloned = c.res.clone(); + const body = await cloned.json(); + const filtered = stripInternalFields({ data: body }); + + c.res = new Response(JSON.stringify(filtered), { + status: c.res.status, + headers: c.res.headers, + }); + } catch { + // If parsing fails, leave response unchanged + } +}; diff --git a/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts b/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts index 778cda372..eed742ba6 100644 --- a/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts +++ b/server/src/internal/balances/updateBalance/runRedisUpdateBalanceV2.ts @@ -33,7 +33,7 @@ export const runRedisUpdateBalanceV2 = async ({ const deductionOptions: DeductionOptions = { overageBehaviour: "allow", // Allow bypasses granted_balance cap for balance updates customerEntitlementFilters, - alterGrantedBalance: true, + alterGrantedBalance: false, }; const { data: result, error } = await tryCatch( diff --git a/server/src/internal/balances/utils/sql/performDeduction.sql b/server/src/internal/balances/utils/sql/performDeduction.sql index 47682ae7a..f584334ac 100644 --- a/server/src/internal/balances/utils/sql/performDeduction.sql +++ b/server/src/internal/balances/utils/sql/performDeduction.sql @@ -84,7 +84,7 @@ BEGIN 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; ELSIF rollovers_arr IS NOT NULL AND jsonb_typeof(rollovers_arr) = 'array' AND jsonb_array_length(rollovers_arr) > 0 THEN - PERFORM 1 FROM rollovers r WHERE r.id IN (SELECT jsonb_array_elements_text(rollovers_arr)->>'id') FOR UPDATE; + PERFORM 1 FROM rollovers r WHERE r.id IN (SELECT jsonb_array_elements(rollovers_arr)->>'id') FOR UPDATE; END IF; -- ============================================================================ diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index ad7f77390..68c93e4d1 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -19,6 +19,8 @@ export const getBooleanApiBalance = ({ const id = cusEnts[0].id; return { + object: "balance", + feature: apiFeature, feature_id: feature.id, @@ -34,6 +36,7 @@ export const getBooleanApiBalance = ({ breakdown: [ { + object: "balance_breakdown", id, plan_id: planId, included_grant: 0, @@ -44,6 +47,7 @@ export const getBooleanApiBalance = ({ reset: null, expires_at: null, price: null, + overage: 0, } satisfies ApiBalanceBreakdownV1, ], rollovers: undefined, @@ -63,6 +67,7 @@ export const getUnlimitedApiBalance = ({ const entityId = undefined; // Unlimited features don't have entity context return { + object: "balance", feature: apiFeature, feature_id: feature.id, @@ -78,6 +83,7 @@ export const getUnlimitedApiBalance = ({ breakdown: [ { + object: "balance_breakdown", id, plan_id: planId, included_grant: 0, @@ -88,6 +94,7 @@ export const getUnlimitedApiBalance = ({ reset: null, expires_at: null, price: null, + overage: 0, } satisfies ApiBalanceBreakdownV1, ], rollovers: cusEntsToRollovers({ cusEnts, entityId }), diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index bc4d3335f..1fc037829 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -20,6 +20,7 @@ import { cusEntsToRollovers, cusEntsToRolloverUsage, cusEntsToUsage, + cusEntToInvoiceOverage, customerEntitlementToBalancePrice, dbToApiFeatureV1, expandIncludes, @@ -84,9 +85,16 @@ const getApiBalanceBreakdownItem = ({ // Price const price = customerEntitlementToBalancePrice({ customerEntitlement }); + const overage = cusEntToInvoiceOverage({ + cusEnt: customerEntitlement, + entityId, + }); + const expiresAt = customerEntitlement.expires_at; return { + object: "balance_breakdown", + id: customerEntitlement.id, plan_id: planId, @@ -99,6 +107,8 @@ const getApiBalanceBreakdownItem = ({ reset: reset, price: price, expires_at: expiresAt, + + overage: overage, }; }; @@ -182,6 +192,8 @@ export const getApiBalance = ({ return { data: { + object: "balance", + feature_id: feature.id, feature: apiFeature, diff --git a/server/src/routers/apiRouter.ts b/server/src/routers/apiRouter.ts index 7576d57bd..73839fab7 100644 --- a/server/src/routers/apiRouter.ts +++ b/server/src/routers/apiRouter.ts @@ -12,6 +12,7 @@ import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js"; import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js"; import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js"; import { refreshProductsCacheMiddleware } from "../honoMiddlewares/refreshProductsCacheMiddleware.js"; +import { responseFilterMiddleware } from "../honoMiddlewares/responseFilter/responseFilterMiddleware.js"; import { secretKeyMiddleware } from "../honoMiddlewares/secretKeyMiddleware.js"; import type { HonoEnv } from "../honoUtils/HonoEnv.js"; import { @@ -36,6 +37,7 @@ import { export const apiRouter = new Hono(); +apiRouter.use("*", responseFilterMiddleware); apiRouter.use("*", secretKeyMiddleware); apiRouter.use("*", orgConfigMiddleware); apiRouter.use("*", apiVersionMiddleware); diff --git a/server/tests/balances/track/negative/track-negative3.test.ts b/server/tests/balances/track/negative/track-negative3.test.ts index 4c140dfa4..2d5f5c264 100644 --- a/server/tests/balances/track/negative/track-negative3.test.ts +++ b/server/tests/balances/track/negative/track-negative3.test.ts @@ -105,6 +105,7 @@ describe(`${chalk.yellowBright("track-negative3: track negative on free allocate customer_id: customerId, feature_id: TestFeature.Users, current_balance: 10, + granted_balance: 10, }); const customer = await autumnV2.customers.get(customerId); diff --git a/server/tests/balances/update/balances-update1.test.ts b/server/tests/balances/update/balances-update1.test.ts deleted file mode 100644 index 5c0d4137f..000000000 --- a/server/tests/balances/update/balances-update1.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 1000, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "balances-update1"; - -describe(`${chalk.yellowBright("balances-update1: testing update balance after track (metered feature)")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should track usage and have correct v1 / v2 api cus feature", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: messagesFeature.included_usage + 140, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: messagesFeature.included_usage + 140, - current_balance: messagesFeature.included_usage + 140, - usage: 0, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/balances-update2.test.ts b/server/tests/balances/update/balances-update2.test.ts deleted file mode 100644 index 027a9230d..000000000 --- a/server/tests/balances/update/balances-update2.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type LimitedItem, - ResetInterval, -} 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const monthlyMsges = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const lifetimeMsges = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: null, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [lifetimeMsges, monthlyMsges], -}); - -const testCase = "balances-update2"; - -describe(`${chalk.yellowBright("balances-update2: testing update balance after track (metered feature)")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update balance and have correct v2 api balance for one off interval", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: lifetimeMsges.included_usage + 140, - interval: ResetInterval.OneOff, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: - monthlyMsges.included_usage + lifetimeMsges.included_usage + 140, - current_balance: - monthlyMsges.included_usage + lifetimeMsges.included_usage + 140, - usage: 0, - purchased_balance: 0, - }); - - const lifetimeBreakdown = balance.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: lifetimeMsges.included_usage + 140, - current_balance: lifetimeMsges.included_usage + 140, - purchased_balance: 0, - usage: 0, - }); - - const monthlyBreakdown = balance.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - expect(monthlyBreakdown).toMatchObject({ - granted_balance: monthlyMsges.included_usage, - current_balance: monthlyMsges.included_usage, - purchased_balance: 0, - usage: 0, - }); - }); - - test("should update balance and have correct v2 api balance for one off interval", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: monthlyMsges.included_usage + 120, - interval: ResetInterval.Month, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: - monthlyMsges.included_usage + lifetimeMsges.included_usage + 140 + 120, - current_balance: - monthlyMsges.included_usage + lifetimeMsges.included_usage + 140 + 120, - usage: 0, - purchased_balance: 0, - }); - - const lifetimeBreakdown = balance.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: lifetimeMsges.included_usage + 140, - current_balance: lifetimeMsges.included_usage + 140, - purchased_balance: 0, - usage: 0, - }); - - const monthlyBreakdown = balance.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - expect(monthlyBreakdown).toMatchObject({ - granted_balance: monthlyMsges.included_usage + 120, - current_balance: monthlyMsges.included_usage + 120, - purchased_balance: 0, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/balances-update3.test.ts b/server/tests/balances/update/balances-update3.test.ts deleted file mode 100644 index d646c73aa..000000000 --- a/server/tests/balances/update/balances-update3.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeAll, describe, test } from "bun:test"; -import { ApiVersion, type LimitedItem } 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { timeout } from "../../utils/genUtils"; - -const workflows = constructFeatureItem({ - featureId: TestFeature.Workflows, - includedUsage: 5, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [workflows], -}); - -const testCase = "balances-update3"; - -describe(`${chalk.yellowBright("balances-update3: testing update balance to increase granted balance and track negative")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update balance and have correct v2 api balance for one off interval", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Workflows, - current_balance: 10, - }); - - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Workflows, - value: 5, - }); - - await timeout(2000); - - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Workflows, - value: -5, - }); - - await timeout(2000); - }); -}); diff --git a/server/tests/balances/update/filters/update-filters1.test.ts b/server/tests/balances/update/filters/update-filters1.test.ts deleted file mode 100644 index 34593b2c5..000000000 --- a/server/tests/balances/update/filters/update-filters1.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, -} 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 { 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"; - -/** - * Test: update-filters1 - * - * Tests filtering balance updates by customer_entitlement_id. - * - * Scenario: - * - 3 products, each with monthly messages (100, 150, 200) - * - Attach all three to customer - * - Get breakdown IDs - * - Update each breakdown individually by customer_entitlement_id - */ - -const messagesItemA = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const messagesItemB = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 150, - interval: ProductItemInterval.Month, -}); - -const messagesItemC = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: ProductItemInterval.Month, -}); - -const productA = constructProduct({ - type: "free", - id: "prod-a", - isDefault: false, - items: [messagesItemA], -}); - -const productB = constructProduct({ - type: "free", - id: "prod-b", - isDefault: false, - isAddOn: true, - items: [messagesItemB], -}); - -const productC = constructProduct({ - type: "free", - id: "prod-c", - isDefault: false, - isAddOn: true, - items: [messagesItemC], -}); - -const testCase = "update-filters1"; - -describe(`${chalk.yellowBright("update-filters1: filter by customer_entitlement_id with 3 monthly products")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - let breakdownIds: { id: string; grantedBalance: number }[] = []; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [productA, productB, productC], - prefix: testCase, - }); - - await autumnV2.attach({ customer_id: customerId, product_id: productA.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productB.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productC.id }); - - // Get breakdown IDs - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - breakdownIds = - res.balance?.breakdown?.map((b) => ({ - id: b.id!, - grantedBalance: b.granted_balance!, - })) ?? []; - }); - - test("initial: customer has 450 with 3 breakdown items", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 450, - current_balance: 450, - usage: 0, - }); - - expect(breakdownIds).toHaveLength(3); - - const balances = breakdownIds - .map((b) => b.grantedBalance) - .sort((a, b) => a - b); - expect(balances).toEqual([100, 150, 200]); - - // All IDs should be unique - const uniqueIds = new Set(breakdownIds.map((b) => b.id)); - expect(uniqueIds.size).toBe(3); - }); - - test("update first breakdown (100 → 80) by customer_entitlement_id", async () => { - const targetBreakdown = breakdownIds.find((b) => b.grantedBalance === 100); - expect(targetBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 80, - customer_entitlement_id: targetBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 430 (80 + 150 + 200) - expect(res.balance).toMatchObject({ - granted_balance: 430, - current_balance: 430, - usage: 0, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.id === targetBreakdown!.id, - ); - expect(updatedBreakdown?.granted_balance).toBe(80); - expect(updatedBreakdown?.current_balance).toBe(80); - - // Verify other breakdowns unchanged - const otherBreakdowns = - res.balance?.breakdown?.filter((b) => b.id !== targetBreakdown!.id) ?? []; - const otherBalances = otherBreakdowns - .map((b) => b.granted_balance) - .sort((a, b) => (a ?? 0) - (b ?? 0)); - expect(otherBalances).toEqual([150, 200]); - - // Update local cache of breakdown info - breakdownIds = breakdownIds.map((b) => - b.id === targetBreakdown!.id ? { ...b, grantedBalance: 80 } : b, - ); - }); - - test("update second breakdown (150 → 200) by customer_entitlement_id", async () => { - const targetBreakdown = breakdownIds.find((b) => b.grantedBalance === 150); - expect(targetBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 200, - customer_entitlement_id: targetBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 480 (80 + 200 + 200) - expect(res.balance).toMatchObject({ - granted_balance: 480, - current_balance: 480, - usage: 0, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.id === targetBreakdown!.id, - ); - expect(updatedBreakdown?.granted_balance).toBe(200); - expect(updatedBreakdown?.current_balance).toBe(200); - - // Update local cache - breakdownIds = breakdownIds.map((b) => - b.id === targetBreakdown!.id ? { ...b, grantedBalance: 200 } : b, - ); - }); - - test("update third breakdown (200 → 50) by customer_entitlement_id", async () => { - // Find the original 200 breakdown (not the one we just updated to 200) - const originalBreakdownC = breakdownIds.find( - (b) => b.grantedBalance === 200 && b.id !== breakdownIds[1].id, - ); - expect(originalBreakdownC).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - customer_entitlement_id: originalBreakdownC!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 330 (80 + 200 + 50) - expect(res.balance).toMatchObject({ - granted_balance: 330, - current_balance: 330, - usage: 0, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.id === originalBreakdownC!.id, - ); - expect(updatedBreakdown?.granted_balance).toBe(50); - expect(updatedBreakdown?.current_balance).toBe(50); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 330, - current_balance: 330, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/filters/update-filters2.test.ts b/server/tests/balances/update/filters/update-filters2.test.ts deleted file mode 100644 index 0e53b2b03..000000000 --- a/server/tests/balances/update/filters/update-filters2.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, -} 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 { - constructArrearItem, - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -/** - * Test: update-filters2 - * - * Tests filtering balance updates by customer_entitlement_id with different item types. - * - * Scenario: - * - Product A: Free monthly (100 messages → granted_balance) - * - Product B: Prepaid monthly (quantity 100 with billingUnits 100 → purchased_balance 100) - * NOTE: Prepaid quantity is rounded to nearest billing unit! - * - Product C: Pay-per-use monthly (200 messages included → granted_balance) - * - All monthly intervals - * - Update each breakdown individually by customer_entitlement_id - * - * Expected totals: - * - granted_balance: 100 (free) + 0 (prepaid includedUsage) + 200 (arrear) = 300 - * - purchased_balance: 100 (prepaid quantity, rounded to billing units) - * - current_balance: 300 + 100 = 400 - */ - -const freeMessagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const prepaidMessagesItem = constructPrepaidItem({ - featureId: TestFeature.Messages, - includedUsage: 0, - price: 9, - billingUnits: 100, -}); - -const arrearMessagesItem = constructArrearItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - price: 0.1, - billingUnits: 1000, -}); - -const freeProd = constructProduct({ - type: "free", - id: "free-prod", - isDefault: false, - items: [freeMessagesItem], -}); - -const prepaidProd = constructProduct({ - type: "free", - id: "prepaid-prod", - isDefault: false, - isAddOn: true, - items: [prepaidMessagesItem], -}); - -const arrearProd = constructProduct({ - type: "free", - id: "arrear-prod", - isDefault: false, - isAddOn: true, - items: [arrearMessagesItem], -}); - -const testCase = "update-filters2"; - -describe(`${chalk.yellowBright("update-filters2: filter by cusEntId with free + prepaid + pay-per-use")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - let breakdowns: { - id: string; - grantedBalance: number; - currentBalance: number; - overageAllowed: boolean; - planId: string; - }[] = []; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [freeProd, prepaidProd, arrearProd], - prefix: testCase, - }); - - await autumnV2.attach({ customer_id: customerId, product_id: freeProd.id }); - await autumnV2.attach({ - customer_id: customerId, - product_id: prepaidProd.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: 100, - }, - ], - }); - await autumnV2.attach({ - customer_id: customerId, - product_id: arrearProd.id, - }); - - // Get breakdown IDs - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - breakdowns = - res.balance?.breakdown?.map((b) => ({ - id: b.id!, - planId: b.plan_id!, - grantedBalance: b.granted_balance!, - currentBalance: b.current_balance!, - overageAllowed: b.overage_allowed!, - })) ?? []; - }); - - test("initial: customer has 400 current_balance with 3 breakdown items of different types", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 400, - purchased_balance: 100, - usage: 0, - }); - - expect(breakdowns).toHaveLength(3); - - const balances = breakdowns - .map((b) => b.currentBalance) - .sort((a, b) => a - b); - expect(balances).toEqual([100, 100, 200]); - - // Verify we have different overage_allowed states - const overageStates = breakdowns.map((b) => b.overageAllowed); - // Only the arrear item should have overage_allowed=true - expect(overageStates.filter((o) => o === true).length).toBe(1); - }); - - test("update free breakdown (100 → 75) by customer_entitlement_id", async () => { - // Free item: 100 messages, no overage - const freeBreakdown = breakdowns.find( - (b) => b.grantedBalance === 100 && !b.overageAllowed, - ); - expect(freeBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 75, - customer_entitlement_id: freeBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 325 (75 + 50 + 200) - expect(res.balance).toMatchObject({ - granted_balance: 275, - current_balance: 375, - purchased_balance: 100, - usage: 0, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.id === freeBreakdown!.id, - ); - expect(updatedBreakdown?.granted_balance).toBe(75); - expect(updatedBreakdown?.current_balance).toBe(75); - - // Update local cache - breakdowns = breakdowns.map((b) => - b.id === freeBreakdown!.id - ? { ...b, grantedBalance: 75, currentBalance: 75 } - : b, - ); - }); - - test("update prepaid breakdown by customer_entitlement_id", async () => { - // Find the prepaid breakdown - it's the one that's not free and not arrear - // Can't rely on granted_balance since prepaid purchased goes to purchased_balance - const prepaidBreakdown = breakdowns.find( - (b) => b.planId === prepaidProd.id, - ); - expect(prepaidBreakdown).toBeDefined(); - - // Update prepaid breakdown's current_balance - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - customer_entitlement_id: prepaidBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.plan_id === prepaidProd.id, - ); - - expect(updatedBreakdown?.current_balance).toBe(150); - }); - - test("update arrear breakdown (→ 150) by customer_entitlement_id", async () => { - // Arrear item: has overage_allowed=true - const arrearBreakdown = breakdowns.find((b) => b.planId === arrearProd.id); - expect(arrearBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - customer_entitlement_id: arrearBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.plan_id === arrearProd.id, - ); - expect(updatedBreakdown?.current_balance).toBe(150); - expect(updatedBreakdown?.overage_allowed).toBe(true); - }); - - test("update arrear breakdown to negative (-50) by customer_entitlement_id", async () => { - // Arrear item allows negative balance - const arrearBreakdown = breakdowns.find((b) => b.planId === arrearProd.id); - expect(arrearBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: -50, - customer_entitlement_id: arrearBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Verify the specific breakdown went negative - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.plan_id === arrearProd.id, - ); - expect(updatedBreakdown).toMatchObject({ - granted_balance: -50, - current_balance: 0, - purchased_balance: 50, - }); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const customerFromCache = - await autumnV2.customers.get(customerId); - - // Verify cache and DB match - expect(customerFromDb.balances[TestFeature.Messages].current_balance).toBe( - customerFromCache.balances[TestFeature.Messages].current_balance, - ); - }); -}); diff --git a/server/tests/balances/update/filters/update-filters3.test.ts b/server/tests/balances/update/filters/update-filters3.test.ts deleted file mode 100644 index 089c817d7..000000000 --- a/server/tests/balances/update/filters/update-filters3.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - type ApiEntityV1, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, -} 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 { 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"; - -/** - * Test: update-filters3 - * - * Tests filtering balance updates by customer_entitlement_id for: - * 1. Entity products (products attached to entities) - * 2. Entity balances (per-entity feature items) - * - * Note: Entity balances share the same cusEntId but have different entity scopes. - * - * Scenario: - * - Entity product: 100 messages attached to each entity - * - Per-entity balance: 50 messages per entity (entityFeatureId = Users) - */ - -const entityProductMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const perEntityMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: ProductItemInterval.Month, - entityFeatureId: TestFeature.Users, -}); - -const entityProd = constructProduct({ - type: "free", - id: "entity-prod", - isDefault: false, - items: [entityProductMessages], -}); - -const perEntityProd = constructProduct({ - type: "free", - id: "per-entity-prod", - isDefault: false, - items: [perEntityMessages], -}); - -const testCase = "update-filters3"; - -describe(`${chalk.yellowBright("update-filters3: entity products and entity balances filter")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - ]; - - const entityProductBreakdownIds: { entityId: string; breakdownId: string }[] = - []; - let perEntityBreakdownId: string = ""; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [entityProd, perEntityProd], - prefix: testCase, - }); - - // Create entities - await autumnV2.entities.create(customerId, entities); - - // Attach entity product to each entity - for (const entity of entities) { - await autumnV2.attach({ - customer_id: customerId, - entity_id: entity.id, - product_id: entityProd.id, - }); - } - - // Attach per-entity product to customer - await autumnV2.attach({ - customer_id: customerId, - product_id: perEntityProd.id, - }); - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - - // Get breakdown IDs for entity products - for (const entity of entities) { - const res = await autumnV2.check({ - customer_id: customerId, - entity_id: entity.id, - feature_id: TestFeature.Messages, - }); - - // Entity product breakdown (100) - unique per entity - const entityProdBreakdown = res.balance?.breakdown?.find( - (b) => b.granted_balance === 100, - ); - if (entityProdBreakdown) { - entityProductBreakdownIds.push({ - entityId: entity.id, - breakdownId: entityProdBreakdown.id!, - }); - } - - // Per-entity breakdown (50) - shared cusEntId - const perEntityBreakdown = res.balance?.breakdown?.find( - (b) => b.granted_balance === 50, - ); - if (perEntityBreakdown && !perEntityBreakdownId) { - perEntityBreakdownId = perEntityBreakdown.id!; - } - } - }); - - test("initial: each entity has 150 messages (100 entity prod + 50 per-entity)", async () => { - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - } - - // Customer total: 2 * (100 + 50) = 300 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - }); - - test("entity products have unique cusEntIds per entity", async () => { - expect(entityProductBreakdownIds).toHaveLength(2); - - const uniqueIds = new Set( - entityProductBreakdownIds.map((e) => e.breakdownId), - ); - expect(uniqueIds.size).toBe(2); - }); - - test("per-entity balances share the same cusEntId", async () => { - expect(perEntityBreakdownId).toBeTruthy(); - - // Verify both entities see the same breakdown ID for per-entity balance - for (const entity of entities) { - const res = await autumnV2.check({ - customer_id: customerId, - entity_id: entity.id, - feature_id: TestFeature.Messages, - }); - - const perEntityBreakdown = res.balance?.breakdown?.find( - (b) => b.granted_balance === 50, - ); - expect(perEntityBreakdown?.id).toBe(perEntityBreakdownId); - } - }); - - test("update entity product breakdown for entity 1 (100 → 75)", async () => { - const entity1Breakdown = entityProductBreakdownIds.find( - (e) => e.entityId === entities[0].id, - ); - expect(entity1Breakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 75, - customer_entitlement_id: entity1Breakdown!.breakdownId, - }); - - // Entity 1: 75 + 50 = 125 - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 125, - current_balance: 125, - usage: 0, - }); - - // Entity 2 should be unchanged: 100 + 50 = 150 - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - - // Customer total: 125 + 150 = 275 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 275, - current_balance: 275, - usage: 0, - }); - }); - - test("update entity product breakdown for entity 2 (100 → 120)", async () => { - const entity2Breakdown = entityProductBreakdownIds.find( - (e) => e.entityId === entities[1].id, - ); - expect(entity2Breakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - current_balance: 120, - customer_entitlement_id: entity2Breakdown!.breakdownId, - }); - - // Entity 2: 120 + 50 = 170 - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 170, - current_balance: 170, - usage: 0, - }); - - // Customer total: 125 + 170 = 295 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 295, - current_balance: 295, - usage: 0, - }); - }); - - test("update per-entity balance for entity 1 (50 → 30) using shared cusEntId", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 30, - customer_entitlement_id: perEntityBreakdownId, - }); - - // Entity 1: 75 + 30 = 105 - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 105, - current_balance: 105, - usage: 0, - }); - - // Entity 2 should still have its per-entity balance of 50 - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 170, - current_balance: 170, - usage: 0, - }); - - // Customer total: 105 + 170 = 275 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 275, - current_balance: 275, - usage: 0, - }); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - for (const entity of entities) { - const entityFromDb = (await autumnV2.entities.get(customerId, entity.id, { - skip_cache: "true", - })) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 275, - current_balance: 275, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/filters/update-filters4.test.ts b/server/tests/balances/update/filters/update-filters4.test.ts deleted file mode 100644 index de2158c87..000000000 --- a/server/tests/balances/update/filters/update-filters4.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, - ResetInterval, -} 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 { 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"; - -/** - * Test: update-filters4 - * - * Tests filtering balance updates by interval. - * - * Scenario: - * - Product A: Monthly messages (100) - * - Product B: Lifetime messages (200) - * - Update only monthly breakdown using interval filter - * - Update only lifetime breakdown using interval filter - */ - -const monthlyMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const lifetimeMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, -}); - -const monthlyProd = constructProduct({ - type: "free", - id: "monthly-prod", - isDefault: false, - items: [monthlyMessages], -}); - -const lifetimeProd = constructProduct({ - type: "free", - id: "lifetime-prod", - isDefault: false, - isAddOn: true, - items: [lifetimeMessages], -}); - -const testCase = "update-filters4"; - -describe(`${chalk.yellowBright("update-filters4: filter by interval (monthly vs lifetime)")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [monthlyProd, lifetimeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: monthlyProd.id, - }); - await autumnV2.attach({ - customer_id: customerId, - product_id: lifetimeProd.id, - }); - }); - - test("initial: customer has 300 with monthly (100) and lifetime (200)", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance?.breakdown).toHaveLength(2); - - const monthlyBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "month", - ); - const lifetimeBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "one_off", - ); - - expect(monthlyBreakdown?.granted_balance).toBe(100); - expect(lifetimeBreakdown?.granted_balance).toBe(200); - }); - - test("update only monthly breakdown (100 → 75) using interval filter", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 75, - interval: ResetInterval.Month, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 275 (75 + 200) - expect(res.balance).toMatchObject({ - granted_balance: 275, - current_balance: 275, - usage: 0, - }); - - // Monthly breakdown should be updated - const monthlyBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "month", - ); - expect(monthlyBreakdown?.granted_balance).toBe(75); - expect(monthlyBreakdown?.current_balance).toBe(75); - - // Lifetime breakdown should be unchanged - const lifetimeBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "one_off", - ); - expect(lifetimeBreakdown?.granted_balance).toBe(200); - expect(lifetimeBreakdown?.current_balance).toBe(200); - }); - - test("update only lifetime breakdown (200 → 150) using interval filter", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - interval: ResetInterval.OneOff, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 225 (75 + 150) - expect(res.balance).toMatchObject({ - granted_balance: 225, - current_balance: 225, - usage: 0, - }); - - // Monthly breakdown should be unchanged - const monthlyBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "month", - ); - expect(monthlyBreakdown?.granted_balance).toBe(75); - expect(monthlyBreakdown?.current_balance).toBe(75); - - // Lifetime breakdown should be updated - const lifetimeBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "one_off", - ); - expect(lifetimeBreakdown?.granted_balance).toBe(150); - expect(lifetimeBreakdown?.current_balance).toBe(150); - }); - - test("increase monthly breakdown (75 → 125) using interval filter", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 125, - interval: ResetInterval.Month, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 275 (125 + 150) - expect(res.balance).toMatchObject({ - granted_balance: 275, - current_balance: 275, - usage: 0, - }); - - // Monthly breakdown should be updated - const monthlyBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "month", - ); - expect(monthlyBreakdown?.granted_balance).toBe(125); - expect(monthlyBreakdown?.current_balance).toBe(125); - }); - - test("increase lifetime breakdown (150 → 300) using interval filter", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 300, - interval: ResetInterval.OneOff, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 425 (125 + 300) - expect(res.balance).toMatchObject({ - granted_balance: 425, - current_balance: 425, - usage: 0, - }); - - // Lifetime breakdown should be updated - const lifetimeBreakdown = res.balance?.breakdown?.find( - (b) => b.reset?.interval === "one_off", - ); - expect(lifetimeBreakdown?.granted_balance).toBe(300); - expect(lifetimeBreakdown?.current_balance).toBe(300); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 425, - current_balance: 425, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/filters/update-filters5.test.ts b/server/tests/balances/update/filters/update-filters5.test.ts deleted file mode 100644 index 5b85752d0..000000000 --- a/server/tests/balances/update/filters/update-filters5.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, - ResetInterval, -} 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 { 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"; - -/** - * Test: update-filters5 - * - * Tests filtering balance updates by interval with multiple products per interval. - * - * Scenario: - * - Product A: Monthly messages (100) - * - Product B: Monthly messages (150) - * - Product C: Lifetime messages (200) - * - Product D: Lifetime messages (50) - * - * Update by interval should sequentially distribute across breakdowns of that interval. - */ - -const monthlyMessagesA = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const monthlyMessagesB = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 150, - interval: ProductItemInterval.Month, -}); - -const lifetimeMessagesC = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, -}); - -const lifetimeMessagesD = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: null, -}); - -const monthlyProdA = constructProduct({ - type: "free", - id: "monthly-prod-a", - isDefault: false, - items: [monthlyMessagesA], -}); - -const monthlyProdB = constructProduct({ - type: "free", - id: "monthly-prod-b", - isDefault: false, - isAddOn: true, - items: [monthlyMessagesB], -}); - -const lifetimeProdC = constructProduct({ - type: "free", - id: "lifetime-prod-c", - isDefault: false, - isAddOn: true, - items: [lifetimeMessagesC], -}); - -const lifetimeProdD = constructProduct({ - type: "free", - id: "lifetime-prod-d", - isDefault: false, - isAddOn: true, - items: [lifetimeMessagesD], -}); - -const testCase = "update-filters5"; - -describe(`${chalk.yellowBright("update-filters5: interval filter with multiple products, sequential deduction")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [monthlyProdA, monthlyProdB, lifetimeProdC, lifetimeProdD], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: monthlyProdA.id, - }); - await autumnV2.attach({ - customer_id: customerId, - product_id: monthlyProdB.id, - }); - await autumnV2.attach({ - customer_id: customerId, - product_id: lifetimeProdC.id, - }); - await autumnV2.attach({ - customer_id: customerId, - product_id: lifetimeProdD.id, - }); - }); - - test("initial: customer has 500 with 2 monthly (250) and 2 lifetime (250)", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 500, - current_balance: 500, - usage: 0, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance?.breakdown).toHaveLength(4); - - // Monthly breakdowns - const monthlyBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "month") ?? - []; - expect(monthlyBreakdowns).toHaveLength(2); - const monthlySum = monthlyBreakdowns.reduce( - (s, b) => s + (b.granted_balance ?? 0), - 0, - ); - expect(monthlySum).toBe(250); - - // Lifetime breakdowns - const lifetimeBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") ?? - []; - expect(lifetimeBreakdowns).toHaveLength(2); - const lifetimeSum = lifetimeBreakdowns.reduce( - (s, b) => s + (b.granted_balance ?? 0), - 0, - ); - expect(lifetimeSum).toBe(250); - }); - - test("decrease monthly balance from 250 to 150 (sequential deduction of 100)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - interval: ResetInterval.Month, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 400 (150 monthly + 250 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 400, - current_balance: 400, - usage: 0, - }); - - // Monthly breakdowns: one deducted, one unchanged - const monthlyBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "month") ?? - []; - expect(monthlyBreakdowns).toHaveLength(2); - - const monthlySum = monthlyBreakdowns.reduce( - (s, b) => s + (b.current_balance ?? 0), - 0, - ); - expect(monthlySum).toBe(150); - - // Lifetime should be unchanged - const lifetimeBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") ?? - []; - const lifetimeSum = lifetimeBreakdowns.reduce( - (s, b) => s + (b.current_balance ?? 0), - 0, - ); - expect(lifetimeSum).toBe(250); - }); - - test("decrease monthly balance from 150 to 50 (sequential deduction of 100, spans both)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - interval: ResetInterval.Month, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 300 (50 monthly + 250 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - // Monthly breakdowns should sum to 50 - const monthlyBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "month") ?? - []; - const monthlySum = monthlyBreakdowns.reduce( - (s, b) => s + (b.current_balance ?? 0), - 0, - ); - expect(monthlySum).toBe(50); - }); - - test("decrease lifetime balance from 250 to 100 (sequential deduction of 150)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 100, - interval: ResetInterval.OneOff, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 150 (50 monthly + 100 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - - // Lifetime breakdowns should sum to 100 - const lifetimeBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") ?? - []; - const lifetimeSum = lifetimeBreakdowns.reduce( - (s, b) => s + (b.current_balance ?? 0), - 0, - ); - expect(lifetimeSum).toBe(100); - }); - - test("increase monthly balance from 50 to 200 (sequential addition of 150)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 200, - interval: ResetInterval.Month, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 300 (200 monthly + 100 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - // Monthly breakdowns should sum to 200 - const monthlyBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "month") ?? - []; - const monthlySum = monthlyBreakdowns.reduce( - (s, b) => s + (b.current_balance ?? 0), - 0, - ); - expect(monthlySum).toBe(200); - }); - - test("increase lifetime balance from 100 to 350 (sequential addition of 250)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 350, - interval: ResetInterval.OneOff, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 550 (200 monthly + 350 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 550, - current_balance: 550, - usage: 0, - }); - - // Lifetime breakdowns should sum to 350 - const lifetimeBreakdowns = - res.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") ?? - []; - const lifetimeSum = lifetimeBreakdowns.reduce( - (s, b) => s + (b.current_balance ?? 0), - 0, - ); - expect(lifetimeSum).toBe(350); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 550, - current_balance: 550, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/legacy-balance-update1.test.ts b/server/tests/balances/update/legacy-balance-update1.test.ts deleted file mode 100644 index b4df05bf5..000000000 --- a/server/tests/balances/update/legacy-balance-update1.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const pro = constructProduct({ - type: "pro", - items: [ - constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 500, - }), - ], -}); - -const testCase = "legacy-balance-update1"; - -describe(`${chalk.yellowBright("legacy-balance-update1: allow updating balances for an entity")}`, () => { - const customerId = testCase; - const entityId = `${testCase}-user-1`; - const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: true, - attachPm: "success", - }); - - await autumnV1.entities.create(customerId, [ - { - id: entityId, - name: "User 1", - feature_id: TestFeature.Credits, - }, - ]); - - await initProductsV0({ - ctx, - products: [pro], - prefix: testCase, - }); - - await autumnV1.attach({ - customer_id: customerId, - entity_id: entityId, - product_id: pro.id, - }); - }); - - test("should allow updating balances for an entity", async () => { - await autumnV1.customers.setBalance({ - customerId: customerId, - entityId: entityId, - balances: [ - { - feature_id: TestFeature.Credits, - balance: 100, - }, - ], - }); - - const entity = await autumnV1.entities.get(customerId, entityId); - - expect(entity.features.credits.balance).toBe(100); - }); -}); diff --git a/server/tests/balances/update/update-combined/update-combined1.test.ts b/server/tests/balances/update/update-combined/update-combined1.test.ts deleted file mode 100644 index 5d47d9efb..000000000 --- a/server/tests/balances/update/update-combined/update-combined1.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { 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"; - -/** - * Test: Update current_balance + granted_balance together - * - * Scenario: - * 1. Start with 100 messages - * 2. Track 30 → current_balance: 70, usage: 30 - * 3. Update current_balance: 50, granted_balance: 100 - * → Should set granted to 100, current to 50, usage recalculated to 50 - */ - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-combined1"; - -describe(`${chalk.yellowBright("update-combined1: current_balance + granted_balance together")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("track 30 usage first", async () => { - const trackRes = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 30, - }); - - expect(trackRes.balance).toMatchObject({ - granted_balance: 100, - current_balance: 70, - usage: 30, - }); - }); - - test("update current_balance: 50 and granted_balance: 100", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - granted_balance: 100, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance explicitly set to 100, current_balance to 50 - // usage = granted_balance - current_balance = 100 - 50 = 50 - expect(balance).toMatchObject({ - granted_balance: 100, - current_balance: 50, - usage: 50, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 50, - usage: 50, - }); - }); - - test("update current_balance: 80 and granted_balance: 150", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 80, - granted_balance: 150, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // usage = 150 - 80 = 70 - expect(balance).toMatchObject({ - granted_balance: 150, - current_balance: 80, - usage: 70, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 80, - usage: 70, - }); - }); - - test("update to reset usage: current_balance: 100, granted_balance: 100", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 100, - granted_balance: 100, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // usage = 100 - 100 = 0 - expect(balance).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-combined/update-combined2.test.ts b/server/tests/balances/update/update-combined/update-combined2.test.ts deleted file mode 100644 index fe6b7d26c..000000000 --- a/server/tests/balances/update/update-combined/update-combined2.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - type LimitedItem, -} 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 { 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"; - -/** - * Test: Update current_balance + next_reset_at together - * - * Scenario: - * 1. Start with 100 messages (monthly) - * 2. Update current_balance and next_reset_at simultaneously - * 3. Verify both values are updated correctly - */ - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-combined2"; - -describe(`${chalk.yellowBright("update-combined2: current_balance + next_reset_at together")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - let originalResetAt: number; - let cusEntId: string; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - - // Get original reset time and customer_entitlement_id - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - originalResetAt = res.balance?.reset?.resets_at ?? 0; - cusEntId = res.balance?.breakdown?.[0]?.id ?? ""; - }); - - test("initial state: has reset time", async () => { - expect(originalResetAt).toBeGreaterThan(Date.now()); - expect(cusEntId).toBeTruthy(); - }); - - test("update current_balance and next_reset_at together", async () => { - // Set next reset to 1 week from now - const newResetAt = Date.now() + 7 * 24 * 60 * 60 * 1000; - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - next_reset_at: newResetAt, - customer_entitlement_id: cusEntId, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - - // Verify reset time was updated - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Reset time should be close to what we set (within 1 second tolerance) - expect(res.balance?.reset?.resets_at).toBeCloseTo(newResetAt, -3); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - }); - }); - - test("update current_balance and push next_reset_at to 30 days", async () => { - // Set next reset to 30 days from now - const newResetAt = Date.now() + 30 * 24 * 60 * 60 * 1000; - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 200, - next_reset_at: newResetAt, - customer_entitlement_id: cusEntId, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 200, - current_balance: 200, - usage: 0, - }); - - // Verify reset time - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance?.reset?.resets_at).toBeCloseTo(newResetAt, -3); - }); -}); diff --git a/server/tests/balances/update/update-combined/update-combined3.test.ts b/server/tests/balances/update/update-combined/update-combined3.test.ts deleted file mode 100644 index a2bcdc013..000000000 --- a/server/tests/balances/update/update-combined/update-combined3.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - type LimitedItem, -} 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 { 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"; - -/** - * Test: Update current_balance + granted_balance + next_reset_at all together - * - * Scenario: - * 1. Start with 100 messages (monthly) - * 2. Track 30 usage - * 3. Update all three: current_balance, granted_balance, and next_reset_at - * 4. Verify all values are updated correctly - */ - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-combined3"; - -describe(`${chalk.yellowBright("update-combined3: current_balance + granted_balance + next_reset_at")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - let cusEntId: string; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - - // Get customer_entitlement_id - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - cusEntId = res.balance?.breakdown?.[0]?.id ?? ""; - }); - - test("track 30 usage first", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 30, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - expect(customerV2.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 70, - usage: 30, - }); - }); - - test("update all three values at once", async () => { - const newResetAt = Date.now() + 14 * 24 * 60 * 60 * 1000; // 14 days - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 80, - granted_balance: 150, - next_reset_at: newResetAt, - customer_entitlement_id: cusEntId, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance: 150, current_balance: 80, usage: 70 - expect(balance).toMatchObject({ - granted_balance: 150, - current_balance: 80, - usage: 70, - purchased_balance: 0, - }); - - // Verify reset time - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - expect(res.balance?.reset?.resets_at).toBeCloseTo(newResetAt, -3); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 80, - usage: 70, - }); - }); - - test("update all values to reset state", async () => { - const newResetAt = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 200, - granted_balance: 200, - next_reset_at: newResetAt, - customer_entitlement_id: cusEntId, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // Reset: granted = current = 200, usage = 0 - expect(balance).toMatchObject({ - granted_balance: 200, - current_balance: 200, - usage: 0, - purchased_balance: 0, - }); - - // Verify reset time - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - expect(res.balance?.reset?.resets_at).toBeCloseTo(newResetAt, -3); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 200, - current_balance: 200, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/allocated/update-allocated-balance1.test.ts b/server/tests/balances/update/update-current-balance/allocated/update-allocated-balance1.test.ts deleted file mode 100644 index bd5199037..000000000 --- a/server/tests/balances/update/update-current-balance/allocated/update-allocated-balance1.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - ProductItemFeatureType, -} 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { timeout } from "../../../../utils/genUtils.js"; - -/** - * Test: update-allocated-balance1 - * - * Tests updating current_balance on a free allocated (ContinuousUse) feature: - * 1. Attach free allocated feature (users) - * 2. Track value to make current_balance 0 and purchased_balance positive - * 3. Update current_balance to positive (purchased_balance should reset to 0) - * 4. Update current_balance to negative - */ - -const usersItem = constructFeatureItem({ - featureId: TestFeature.Users, - includedUsage: 5, - featureType: ProductItemFeatureType.ContinuousUse, -}); - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [usersItem], -}); - -const testCase = "update-allocated-balance1"; - -describe(`${chalk.yellowBright("update-allocated-balance1: update balance on free allocated feature with overage")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("initial state: should have balance of 5 users", async () => { - const customer = await autumnV2.customers.get(customerId); - - expect(customer.balances[TestFeature.Users]).toMatchObject({ - granted_balance: 5, - current_balance: 5, - purchased_balance: 0, - usage: 0, - }); - }); - - test("track +8 to make current_balance 0 and purchased_balance 3", async () => { - // Track 8 users when we only have 5 allocated - // This should result in: granted=5, usage=8, current=0, purchased=3 - const trackRes = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Users, - value: 8, - }); - - expect(trackRes.balance).toMatchObject({ - granted_balance: 5, - current_balance: 0, - purchased_balance: 3, - usage: 8, - }); - - // Verify via customers.get - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Users]).toMatchObject({ - granted_balance: 5, - current_balance: 0, - purchased_balance: 3, - usage: 8, - }); - }); - - test("update current_balance to 2 (positive): purchased_balance should reset to 0", async () => { - // When we set current_balance to 2 (positive): - // - granted_balance is adjusted to achieve the target current_balance - // - purchased_balance should reset to 0 since we're no longer in overage - // - usage remains unchanged - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Users, - current_balance: 2, - }); - - const customer = await autumnV2.customers.get(customerId); - - // current_balance = granted_balance + purchased_balance - usage - // 2 = granted_balance + 0 - 8 - // granted_balance = 10 - expect(customer.balances[TestFeature.Users]).toMatchObject({ - granted_balance: 10, - current_balance: 2, - purchased_balance: 0, - usage: 8, - }); - }); - - test("update current_balance to -5 (negative): should create overage", async () => { - // When we set current_balance to -5 (negative) on an allocated feature: - // - For allocated features, current_balance floors at 0 - // - purchased_balance absorbs the negative to bring current_balance to 0 - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Users, - current_balance: -5, - }); - - const customer = await autumnV2.customers.get(customerId); - - // For allocated features with overage: - // granted_balance is set to achieve target, purchased_balance absorbs negative - // current_balance = granted_balance + purchased_balance - usage - // 0 = granted_balance + purchased_balance - 8 - // If we want current=-5, granted is set to 3, purchased=5 - // actual_current = 3 + 5 - 8 = 0 (floored) - expect(customer.balances[TestFeature.Users]).toMatchObject({ - granted_balance: 3, - current_balance: 0, - purchased_balance: 5, - usage: 8, - }); - }); - - test("verify database state matches cache", async () => { - await timeout(2000); - - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - - expect(customerFromDb.balances[TestFeature.Users]).toMatchObject({ - granted_balance: 3, - current_balance: 0, - purchased_balance: 5, - usage: 8, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/basic/update-current-balance1.test.ts b/server/tests/balances/update/update-current-balance/basic/update-current-balance1.test.ts deleted file mode 100644 index d69415bb5..000000000 --- a/server/tests/balances/update/update-current-balance/basic/update-current-balance1.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-current-balance1"; - -describe(`${chalk.yellowBright("update-current-balance1: update monthly balance from 100 to 80 then to 120")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update current balance from 100 to 80, granted balance should also be 80", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 80, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 80, - current_balance: 80, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync with skip_cache - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 80, - current_balance: 80, - usage: 0, - purchased_balance: 0, - }); - }); - - test("should update current balance from 80 to 120, granted balance should also be 120", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 120, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 120, - current_balance: 120, - usage: 0, - purchased_balance: 0, - }); - - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 120, - current_balance: 120, - usage: 0, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/basic/update-current-balance2.test.ts b/server/tests/balances/update/update-current-balance/basic/update-current-balance2.test.ts deleted file mode 100644 index 7eb1ed384..000000000 --- a/server/tests/balances/update/update-current-balance/basic/update-current-balance2.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { 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"; - -/** - * Test: Update balance after tracking usage - * - * Scenario: - * 1. Start with 100 messages - * 2. Track 30 usage → balance: 70, usage: 30 - * 3. Update current_balance to 50 → granted_balance adjusts to 80, usage stays 30 - */ - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-current-balance2"; - -describe(`${chalk.yellowBright("update-current-balance2: update balance after track")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("track 30 usage", async () => { - const trackRes = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 30, - }); - - expect(trackRes.balance).toMatchObject({ - granted_balance: 100, - current_balance: 70, - usage: 30, - purchased_balance: 0, - }); - }); - - test("update current_balance to 50 after tracking", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance should adjust to 80 (50 + 30 usage) - expect(balance).toMatchObject({ - granted_balance: 80, - current_balance: 50, - usage: 30, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 80, - current_balance: 50, - usage: 30, - purchased_balance: 0, - }); - }); - - test("update current_balance to 120 (above original granted)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 120, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance should adjust to 150 (120 + 30 usage) - expect(balance).toMatchObject({ - granted_balance: 150, - current_balance: 120, - usage: 30, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 150, - current_balance: 120, - usage: 30, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/basic/update-current-balance3.test.ts b/server/tests/balances/update/update-current-balance/basic/update-current-balance3.test.ts deleted file mode 100644 index 032ffb63c..000000000 --- a/server/tests/balances/update/update-current-balance/basic/update-current-balance3.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { 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"; - -/** - * Test: Update balance to 0 - * - * Scenario: - * 1. Start with 100 messages - * 2. Update current_balance to 0 → granted_balance should also be 0 - * 3. Then update back to 50 - */ - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-current-balance3"; - -describe(`${chalk.yellowBright("update-current-balance3: update balance to 0")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("update current_balance to 0", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 0, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 0, - current_balance: 0, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 0, - current_balance: 0, - usage: 0, - purchased_balance: 0, - }); - }); - - test("update current_balance from 0 to 50", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - purchased_balance: 0, - }); - }); - - test("track 20 then update to 0", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 20, - }); - - // Balance should be 30 now (50 - 20) - const beforeUpdate = await autumnV2.customers.get(customerId); - expect(beforeUpdate.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 30, - usage: 20, - }); - - // Update to 0 - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 0, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance should be 20 (0 + 20 usage) - expect(balance).toMatchObject({ - granted_balance: 20, - current_balance: 0, - usage: 20, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 20, - current_balance: 0, - usage: 20, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/basic/update-current-balance4.test.ts b/server/tests/balances/update/update-current-balance/basic/update-current-balance4.test.ts deleted file mode 100644 index bcd4446ed..000000000 --- a/server/tests/balances/update/update-current-balance/basic/update-current-balance4.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type LimitedItem, - ResetInterval, -} 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 { 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"; - -/** - * Test: Update balance on lifetime (one-off) interval feature - * - * Scenario: - * 1. Attach product with lifetime messages (no reset) - * 2. Update balance from 100 to 50 - * 3. Track 20, then update to 80 - */ - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: null, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-current-balance4"; - -describe(`${chalk.yellowBright("update-current-balance4: update lifetime (one-off) balance")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("initial balance should be 100 with lifetime interval", async () => { - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - purchased_balance: 0, - }); - - // Lifetime features have no reset - expect(balance.reset?.interval).toBe(ResetInterval.OneOff); - }); - - test("update current_balance from 100 to 50", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - purchased_balance: 0, - }); - }); - - test("track 20 then update to 80", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 20, - }); - - // Balance should be 30 now (50 - 20) - const beforeUpdate = await autumnV2.customers.get(customerId); - expect(beforeUpdate.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 30, - usage: 20, - }); - - // Update to 80 - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 80, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance should be 100 (80 + 20 usage) - expect(balance).toMatchObject({ - granted_balance: 100, - current_balance: 80, - usage: 20, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 100, - current_balance: 80, - usage: 20, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/basic/update-current-balance5.test.ts b/server/tests/balances/update/update-current-balance/basic/update-current-balance5.test.ts deleted file mode 100644 index 19f5add31..000000000 --- a/server/tests/balances/update/update-current-balance/basic/update-current-balance5.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { 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"; - -/** - * Test: Update balance with decimal values (credits feature) - * - * Scenario: - * 1. Attach credits feature with 100 credits - * 2. Track 27.35 credits - * 3. Update to 50.50 - * 4. Verify decimal precision is maintained - */ - -const creditsFeature = constructFeatureItem({ - featureId: TestFeature.Credits, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [creditsFeature], -}); - -const testCase = "update-current-balance5"; - -describe(`${chalk.yellowBright("update-current-balance5: update balance with decimal values (credits)")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("update current_balance to decimal value 72.65", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Credits, - current_balance: 72.65, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Credits]; - - expect(balance).toMatchObject({ - granted_balance: 72.65, - current_balance: 72.65, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Credits]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 72.65, - current_balance: 72.65, - usage: 0, - purchased_balance: 0, - }); - }); - - test("track decimal value 27.35 then update to 50.50", async () => { - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Credits, - value: 27.35, - }); - - // Balance should be 45.30 now (72.65 - 27.35) - const beforeUpdate = await autumnV2.customers.get(customerId); - expect( - beforeUpdate.balances[TestFeature.Credits].current_balance, - ).toBeCloseTo(45.3, 2); - expect(beforeUpdate.balances[TestFeature.Credits].usage).toBeCloseTo( - 27.35, - 2, - ); - - // Update to 50.50 - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Credits, - current_balance: 50.5, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Credits]; - - // granted_balance should be 77.85 (50.50 + 27.35 usage) - expect(balance.granted_balance).toBeCloseTo(77.85, 2); - expect(balance.current_balance).toBeCloseTo(50.5, 2); - expect(balance.usage).toBeCloseTo(27.35, 2); - expect(balance.purchased_balance).toBe(0); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Credits]; - - expect(balanceFromDb.granted_balance).toBeCloseTo(77.85, 2); - expect(balanceFromDb.current_balance).toBeCloseTo(50.5, 2); - expect(balanceFromDb.usage).toBeCloseTo(27.35, 2); - }); - - test("update to very small decimal 0.01", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Credits, - current_balance: 0.01, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Credits]; - - // granted_balance should be 27.36 (0.01 + 27.35 usage) - expect(balance.granted_balance).toBeCloseTo(27.36, 2); - expect(balance.current_balance).toBeCloseTo(0.01, 2); - expect(balance.usage).toBeCloseTo(27.35, 2); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Credits]; - - expect(balanceFromDb.granted_balance).toBeCloseTo(27.36, 2); - expect(balanceFromDb.current_balance).toBeCloseTo(0.01, 2); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/basic/update-current-balance6.test.ts b/server/tests/balances/update/update-current-balance/basic/update-current-balance6.test.ts deleted file mode 100644 index e81a7927f..000000000 --- a/server/tests/balances/update/update-current-balance/basic/update-current-balance6.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, -} 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 { - constructArrearItem, - constructFeatureItem, - constructPrepaidItem, -} from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { timeout } from "../../../../utils/genUtils.js"; - -/** - * Test: update-current-balance6 - * - * Tests sync delta calculation with multiple breakdowns (different usage models): - * - Product A (Free): 10 messages included (granted_balance) - * - Product B (Prepaid): 0 included, purchase 20 credits (purchased_balance) - * - Product C (Arrear): 15 messages included, pay-per-use overage - * - * Test flow: - * 1. Attach all 3 products (total: 45 messages) - * 2. Track usage to create various states (overage, partial usage) - * 3. Update balance and verify sync delta calculation - * 4. Verify each breakdown is correctly adjusted - * - * Note: Prepaid quantity goes to purchased_balance, not granted_balance. - * With billingUnits: 1, quantity: 20 gives exactly 20 credits. - */ - -// Free item - 10 messages included (goes to granted_balance) -const freeMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 10, -}); - -// Prepaid item - 0 included, will purchase 20 credits -// Using billingUnits: 1 to get exact quantity (no rounding) -const prepaidMessages = constructPrepaidItem({ - featureId: TestFeature.Messages, - includedUsage: 0, // No free credits - price: 1, - billingUnits: 1, // 1 credit per unit = exact quantity -}); - -// Arrear (pay-per-use) item - 15 messages included, overage allowed -const arrearMessages = constructArrearItem({ - featureId: TestFeature.Messages, - includedUsage: 15, - price: 0.1, - billingUnits: 1, -}); - -const productA = constructProduct({ - id: "free-messages", - type: "free", - isDefault: false, - items: [freeMessages], -}); - -const productB = constructProduct({ - id: "prepaid-messages", - type: "free", - isDefault: false, - isAddOn: true, - items: [prepaidMessages], -}); - -const productC = constructProduct({ - id: "arrear-messages", - type: "free", - isAddOn: true, - isDefault: false, - items: [arrearMessages], -}); - -const testCase = "update-current-balance6"; - -describe(`${chalk.yellowBright("update-current-balance6: sync delta with free/prepaid/arrear breakdowns")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [productA, productB, productC], - prefix: testCase, - }); - - // Attach free product (Product A) - await autumnV2.attach({ - customer_id: customerId, - product_id: productA.id, - }); - - // Attach prepaid product (Product B) with quantity option - // With billingUnits: 1, quantity: 20 gives exactly 20 credits - // These go to purchased_balance, not granted_balance - await autumnV2.attach({ - customer_id: customerId, - product_id: productB.id, - options: [ - { - feature_id: TestFeature.Messages, - quantity: 20, - }, - ], - }); - - // Attach arrear product (Product C) - await autumnV2.attach({ - customer_id: customerId, - product_id: productC.id, - }); - - await timeout(3000); // let stripe webhooks catch up - }); - - test("initial state: should have 45 total messages (10 granted + 20 purchased + 15 granted)", async () => { - const customer = await autumnV2.customers.get(customerId); - - // Free: 10, Prepaid: 20, Arrear: 15 - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 25, - current_balance: 45, - purchased_balance: 20, - usage: 0, - }); - - // Check breakdown has 3 items - const checkRes = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(checkRes.balance?.breakdown).toHaveLength(3); - }); - - test("track 15: exceeds Product A (10), spills into Product B prepaid", async () => { - // Free: 0, Prepaid: 15, Arrear: 15 - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 15, - }); - - const customer = await autumnV2.customers.get(customerId); - - // Total: granted=25, usage=15, current=30, purchased=20 - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 25, - current_balance: 30, - purchased_balance: 20, - usage: 15, - }); - }); - - test("track 10: partial usage from prepaid (Product B)", async () => { - // Track 10 more - should use from prepaid balance - // New: Free: 0, Prepaid: 5, Arrear: 15 - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 10, - }); - - const customer = await autumnV2.customers.get(customerId); - - // Total: granted=25, usage=25, current=20, purchased=20 - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 25, - current_balance: 20, - purchased_balance: 20, - usage: 25, - }); - }); - - test("track 25: exhausts prepaid and arrear, creates overage on arrear", async () => { - // Track 25 more - exhausts prepaid (15 remaining) and arrear (15) - // Old: Free: 0, Prepaid: 5, Arrear: 15 - // New: Free: 0, Prepaid: 0, Arrear: -5 - await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 25, - }); - - const customer = await autumnV2.customers.get(customerId); - - // Total usage: 25 + 25 = 50 - // Total available was 45, so we're in overage - expect(customer.balances[TestFeature.Messages].usage).toBe(50); - expect( - customer.balances[TestFeature.Messages].current_balance, - ).toBeLessThanOrEqual(5); - }); - - test("update balance to 20: should adjust granted_balance across breakdowns", async () => { - const beforeCustomer = - await autumnV2.customers.get(customerId); - const beforeBalance = beforeCustomer.balances[TestFeature.Messages]; - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 20, - }); - - const customer = await autumnV2.customers.get(customerId); - - // After update: - // - current_balance should be 20 - // - usage should be unchanged - // - granted_balance should adjust to make current=20 - expect(customer.balances[TestFeature.Messages].current_balance).toBe(20); - expect(customer.balances[TestFeature.Messages].usage).toBe( - beforeBalance.usage, - ); - - // Expect free breakdown to be 20, prepaid to be 0, arrear to be 0, purchased balance 5 - const freeBreakdown = customer.balances[ - TestFeature.Messages - ].breakdown?.find((b) => b.plan_id === productA.id); - - const prepaidBreakdown = customer.balances[ - TestFeature.Messages - ].breakdown?.find((b) => b.plan_id === productB.id); - const arrearBreakdown = customer.balances[ - TestFeature.Messages - ].breakdown?.find((b) => b.plan_id === productC.id); - - expect(freeBreakdown).toMatchObject({ - current_balance: 20, - granted_balance: 30, - purchased_balance: 0, - usage: 10, - }); - - expect(prepaidBreakdown).toMatchObject({ - current_balance: 0, - granted_balance: 0, - purchased_balance: 20, - }); - - expect(arrearBreakdown).toMatchObject({ - granted_balance: 20, - purchased_balance: 0, - current_balance: 0, - }); - }); - - test("verify breakdown state after update", async () => { - const checkRes = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Verify each breakdown - for (const breakdown of checkRes.balance?.breakdown ?? []) { - // current_balance formula: granted + purchased - usage - const expectedCurrent = - (breakdown.granted_balance ?? 0) + - (breakdown.purchased_balance ?? 0) - - (breakdown.usage ?? 0); - - // current_balance should match the formula (floored at 0) - expect(breakdown.current_balance).toBe(Math.max(0, expectedCurrent)); - } - - // Total current_balance across breakdowns should sum to 20 - const totalCurrent = - checkRes.balance?.breakdown?.reduce( - (sum, b) => sum + (b.current_balance ?? 0), - 0, - ) ?? 0; - expect(totalCurrent).toBe(20); - }); - - test("verify database state matches cache", async () => { - await timeout(2000); - - const customerFromCache = - await autumnV2.customers.get(customerId); - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject( - customerFromCache.balances[TestFeature.Messages], - ); - }); - - test("update balance to -10 (negative): should create overage on arrear breakdown", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: -10, - }); - - const customer = await autumnV2.customers.get(customerId); - - // For arrear items: - // - current_balance floors at 0 - // - purchased_balance absorbs the negative - expect(customer.balances[TestFeature.Messages].current_balance).toBe(0); - expect( - customer.balances[TestFeature.Messages].purchased_balance, - ).toBeGreaterThan(0); - }); - - test("update balance back to positive (50): purchased_balance should reset", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - const customer = await autumnV2.customers.get(customerId); - - console.log( - "Balance after positive update:", - customer.balances[TestFeature.Messages], - ); - - // When back to positive: - // - current_balance should be 50 - // - purchased_balance should be 0 (no overage) - expect(customer.balances[TestFeature.Messages].current_balance).toBe(50); - expect(customer.balances[TestFeature.Messages].purchased_balance).toBe(20); // 20 from prepaid - - // Verify breakdowns - const checkRes = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total current_balance should be 50 - const totalCurrent = - checkRes.balance?.breakdown?.reduce( - (sum, b) => sum + (b.current_balance ?? 0), - 0, - ) ?? 0; - expect(totalCurrent).toBe(50); - }); - - test("final verification: database matches cache", async () => { - await timeout(2000); - - const customerFromCache = - await autumnV2.customers.get(customerId); - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject( - customerFromCache.balances[TestFeature.Messages], - ); - - console.log( - "Final balance from DB:", - customerFromDb.balances[TestFeature.Messages], - ); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown1.test.ts b/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown1.test.ts deleted file mode 100644 index 40c5dbcd9..000000000 --- a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown1.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, -} 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 { 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"; - -/** - * Test: Update balance with multiple products (3 products, same feature) - * - * Scenario: - * - Product A: 100 messages (monthly) - * - Product B: 50 messages (monthly) - * - Product C: 200 messages (lifetime) - * - Total: 350 messages across 3 breakdown items - * - * Tests: - * 1. Update without filter - should update all breakdowns proportionally? or first? - * 2. Verify breakdown state after update - */ - -const messagesItemA = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const messagesItemB = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: ProductItemInterval.Month, -}); - -const messagesItemC = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, -}); - -const productA = constructProduct({ - type: "free", - id: "prod-a", - isDefault: false, - items: [messagesItemA], -}); - -const productB = constructProduct({ - type: "free", - id: "prod-b", - isDefault: false, - isAddOn: true, - items: [messagesItemB], -}); - -const productC = constructProduct({ - type: "free", - id: "prod-c", - isDefault: false, - isAddOn: true, - items: [messagesItemC], -}); - -const testCase = "update-current-balance-breakdown1"; - -describe(`${chalk.yellowBright("update-current-balance-breakdown1: 3 products same feature")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [productA, productB, productC], - prefix: testCase, - }); - - // Attach all three products - await autumnV2.attach({ customer_id: customerId, product_id: productA.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productB.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productC.id }); - }); - - test("initial: customer has 350 with 3 breakdown items", async () => { - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance).toMatchObject({ - granted_balance: 350, - current_balance: 350, - usage: 0, - }); - - // Should have 3 breakdown items - expect(res.balance?.breakdown).toHaveLength(3); - - // Verify each breakdown exists with correct values - const breakdowns = res.balance?.breakdown ?? []; - const balances = breakdowns - .map((b) => b.granted_balance) - .sort((a, b) => (a ?? 0) - (b ?? 0)); - expect(balances).toEqual([50, 100, 200]); - }); - - test("update current_balance to 300 (decrease by 50)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 300, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // Total should be 300 - expect(balance).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - purchased_balance: 0, - }); - - // Check breakdown state - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance?.breakdown).toHaveLength(3); - - // Sum of breakdown current_balances should equal total - const breakdownSum = - res.balance?.breakdown?.reduce( - (sum, b) => sum + (b.current_balance ?? 0), - 0, - ) ?? 0; - expect(breakdownSum).toBe(300); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - }); - - test("update current_balance to 400 (increase by 100)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 400, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // Total should be 400 - expect(balance).toMatchObject({ - granted_balance: 400, - current_balance: 400, - usage: 0, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - const balanceFromDb = customerFromDb.balances[TestFeature.Messages]; - - expect(balanceFromDb).toMatchObject({ - granted_balance: 400, - current_balance: 400, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown2.test.ts b/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown2.test.ts deleted file mode 100644 index 873102fe3..000000000 --- a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown2.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, - ResetInterval, -} 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 { 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"; - -/** - * Test: Update balance with interval filter (monthly vs lifetime) - * - * Scenario: - * - Product A: 100 messages (monthly) - * - Product B: 200 messages (lifetime) - * - Total: 300 messages - * - * Tests: - * 1. Update with interval: "month" filter - only monthly breakdown affected - * 2. Update with interval: "lifetime" filter - only lifetime breakdown affected - */ - -const monthlyMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const lifetimeMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, -}); - -const monthlyProd = constructProduct({ - type: "free", - id: "monthly-prod", - isDefault: false, - items: [monthlyMessages], -}); - -const lifetimeProd = constructProduct({ - type: "free", - id: "lifetime-prod", - isDefault: false, - isAddOn: true, - items: [lifetimeMessages], -}); - -const testCase = "update-current-balance-breakdown2"; - -describe(`${chalk.yellowBright("update-current-balance-breakdown2: filter by interval")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [monthlyProd, lifetimeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: monthlyProd.id, - }); - await autumnV2.attach({ - customer_id: customerId, - product_id: lifetimeProd.id, - }); - }); - - test("initial: customer has 300 with 2 breakdown items", async () => { - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - expect(res.balance).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - expect(res.balance?.breakdown).toHaveLength(2); - }); - - test("update with interval: month filter - only monthly affected", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - interval: ResetInterval.Month, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 250 (50 monthly + 200 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 250, - current_balance: 250, - usage: 0, - }); - - // Verify breakdown: monthly should be 50, lifetime should be 200 - const breakdowns = res.balance?.breakdown ?? []; - const monthlyBreakdown = breakdowns.find( - (b) => b.reset?.interval === "month", - ); - const lifetimeBreakdown = breakdowns.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - expect(monthlyBreakdown?.granted_balance).toBe(50); - expect(monthlyBreakdown?.current_balance).toBe(50); - expect(lifetimeBreakdown?.granted_balance).toBe(200); - expect(lifetimeBreakdown?.current_balance).toBe(200); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 250, - current_balance: 250, - }); - }); - - test("update with interval: lifetime filter - only lifetime affected", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 100, - interval: ResetInterval.OneOff, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 150 (50 monthly + 100 lifetime) - expect(res.balance).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - - // Verify breakdown - const breakdowns = res.balance?.breakdown ?? []; - const monthlyBreakdown = breakdowns.find( - (b) => b.reset?.interval === "month", - ); - const lifetimeBreakdown = breakdowns.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - expect(monthlyBreakdown?.granted_balance).toBe(50); - expect(lifetimeBreakdown?.granted_balance).toBe(100); - expect(lifetimeBreakdown?.current_balance).toBe(100); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown3.test.ts b/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown3.test.ts deleted file mode 100644 index 6c6f18842..000000000 --- a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown3.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, -} 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 { 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"; - -/** - * Test: Update balance with customer_entitlement_id filter - * - * Scenario: - * - Product A: 100 messages (monthly) - * - Product B: 50 messages (monthly) - * - Product C: 200 messages (lifetime) - * - * Tests: - * 1. Get breakdown IDs - * 2. Update specific breakdown by customer_entitlement_id - * 3. Verify only that breakdown was affected - */ - -const messagesItemA = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const messagesItemB = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: ProductItemInterval.Month, -}); - -const messagesItemC = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, -}); - -const productA = constructProduct({ - type: "free", - id: "prod-a", - isDefault: false, - items: [messagesItemA], -}); - -const productB = constructProduct({ - type: "free", - id: "prod-b", - isDefault: false, - isAddOn: true, - items: [messagesItemB], -}); - -const productC = constructProduct({ - type: "free", - id: "prod-c", - isDefault: false, - isAddOn: true, - items: [messagesItemC], -}); - -const testCase = "update-current-balance-breakdown3"; - -describe(`${chalk.yellowBright("update-current-balance-breakdown3: filter by customer_entitlement_id")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - let breakdownIds: { id: string; grantedBalance: number }[] = []; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [productA, productB, productC], - prefix: testCase, - }); - - await autumnV2.attach({ customer_id: customerId, product_id: productA.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productB.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productC.id }); - - // Get breakdown IDs - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - breakdownIds = - res.balance?.breakdown?.map((b) => ({ - id: b.id!, - grantedBalance: b.granted_balance!, - })) ?? []; - }); - - test("initial: customer has 350 with 3 breakdown items", async () => { - expect(breakdownIds).toHaveLength(3); - - const balances = breakdownIds - .map((b) => b.grantedBalance) - .sort((a, b) => a - b); - expect(balances).toEqual([50, 100, 200]); - }); - - test("update specific breakdown (100 → 75) by customer_entitlement_id", async () => { - // Find the breakdown with 100 granted_balance - const targetBreakdown = breakdownIds.find((b) => b.grantedBalance === 100); - expect(targetBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 75, - customer_entitlement_id: targetBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 325 (75 + 50 + 200) - expect(res.balance).toMatchObject({ - granted_balance: 325, - current_balance: 325, - usage: 0, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.id === targetBreakdown!.id, - ); - expect(updatedBreakdown?.granted_balance).toBe(75); - expect(updatedBreakdown?.current_balance).toBe(75); - - // Verify other breakdowns unchanged - const otherBreakdowns = - res.balance?.breakdown?.filter((b) => b.id !== targetBreakdown!.id) ?? []; - const otherBalances = otherBreakdowns - .map((b) => b.granted_balance) - .sort((a, b) => (a ?? 0) - (b ?? 0)); - expect(otherBalances).toEqual([50, 200]); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 325, - current_balance: 325, - }); - }); - - test("update lifetime breakdown (200 → 150) by customer_entitlement_id", async () => { - // Find the breakdown with 200 granted_balance (lifetime) - const targetBreakdown = breakdownIds.find((b) => b.grantedBalance === 200); - expect(targetBreakdown).toBeDefined(); - - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - customer_entitlement_id: targetBreakdown!.id, - }); - - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Total should be 275 (75 + 50 + 150) - expect(res.balance).toMatchObject({ - granted_balance: 275, - current_balance: 275, - usage: 0, - }); - - // Verify the specific breakdown was updated - const updatedBreakdown = res.balance?.breakdown?.find( - (b) => b.id === targetBreakdown!.id, - ); - expect(updatedBreakdown?.granted_balance).toBe(150); - expect(updatedBreakdown?.current_balance).toBe(150); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 275, - current_balance: 275, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown4.test.ts b/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown4.test.ts deleted file mode 100644 index 5e5a1ba7c..000000000 --- a/server/tests/balances/update/update-current-balance/breakdown/update-current-balance-breakdown4.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, -} 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; -import { timeout } from "../../../../utils/genUtils"; - -/** - * Test: Update balance after track that spans multiple breakdowns - * - * Scenario: - * - Product A: 100 messages (monthly) - * - Product B: 50 messages (monthly) - * - Product C: 200 messages (lifetime) - * - Total: 350 messages - * - * Tests: - * 1. Track 120 → depletes monthly breakdowns (100 + 50), leaves lifetime at 200 - * 2. Update current_balance to 150 → see how it distributes across breakdowns - */ - -const messagesItemA = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, -}); - -const messagesItemB = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: ProductItemInterval.Month, -}); - -const messagesItemC = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 200, - interval: null, -}); - -const productA = constructProduct({ - type: "free", - id: "prod-a", - isDefault: false, - items: [messagesItemA], -}); - -const productB = constructProduct({ - type: "free", - id: "prod-b", - isDefault: false, - isAddOn: true, - items: [messagesItemB], -}); - -const productC = constructProduct({ - type: "free", - id: "prod-c", - isDefault: false, - isAddOn: true, - items: [messagesItemC], -}); - -const testCase = "update-current-balance-breakdown4"; - -describe(`${chalk.yellowBright("update-current-balance-breakdown4: update after track spans breakdowns")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [productA, productB, productC], - prefix: testCase, - }); - - await autumnV2.attach({ customer_id: customerId, product_id: productA.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productB.id }); - await autumnV2.attach({ customer_id: customerId, product_id: productC.id }); - }); - - test("track 120: depletes across multiple breakdowns", async () => { - const trackRes = await autumnV2.track({ - customer_id: customerId, - feature_id: TestFeature.Messages, - value: 120, - }); - - expect(trackRes.balance).toMatchObject({ - granted_balance: 350, - current_balance: 230, - usage: 120, - }); - - // Check breakdown state - monthly ones should be depleted - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - const breakdownSum = - res.balance?.breakdown?.reduce( - (sum, b) => sum + (b.current_balance ?? 0), - 0, - ) ?? 0; - expect(breakdownSum).toBe(230); - - const usageSum = - res.balance?.breakdown?.reduce((sum, b) => sum + (b.usage ?? 0), 0) ?? 0; - expect(usageSum).toBe(120); - }); - - test("update current_balance to 150 after tracking", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // Total should be 150, usage should still be 120 - // granted_balance = current_balance + usage = 150 + 120 = 270 - expect(balance).toMatchObject({ - granted_balance: 270, - current_balance: 150, - usage: 120, - purchased_balance: 0, - }); - - // Check breakdown state - const res = await autumnV2.check({ - customer_id: customerId, - feature_id: TestFeature.Messages, - }); - - // Sum of breakdown current_balances should equal total - const breakdownSum = - res.balance?.breakdown?.reduce( - (sum, b) => sum + (b.current_balance ?? 0), - 0, - ) ?? 0; - expect(breakdownSum).toBe(150); - - // Sum of breakdown usages should equal total usage - const usageSum = - res.balance?.breakdown?.reduce((sum, b) => sum + (b.usage ?? 0), 0) ?? 0; - expect(usageSum).toBe(120); - - // Verify DB sync - await timeout(2000); - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 270, - current_balance: 150, - usage: 120, - }); - }); - - test("update current_balance to 300 (increase after tracking)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 300, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - // granted_balance = current_balance + usage = 300 + 120 = 420 - expect(balance).toMatchObject({ - granted_balance: 420, - current_balance: 300, - usage: 120, - purchased_balance: 0, - }); - - // Verify DB sync - const customerFromDb = await autumnV2.customers.get( - customerId, - { skip_cache: "true" }, - ); - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 420, - current_balance: 300, - usage: 120, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/entity/update-entity-balance1.test.ts b/server/tests/balances/update/update-current-balance/entity/update-entity-balance1.test.ts deleted file mode 100644 index f0261327d..000000000 --- a/server/tests/balances/update/update-current-balance/entity/update-entity-balance1.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, type ApiEntityV1, 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const messagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - entityFeatureId: TestFeature.Users, // Per-entity balance -}); - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesItem], -}); - -const testCase = "update-entity-balance1"; - -describe(`${chalk.yellowBright("update-entity-balance1: update per-entity balance at customer level")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-3`, - name: "User 3", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - - await autumnV2.entities.create(customerId, entities); - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - }); - - test("initial state: customer should have 300 messages (100 per entity)", async () => { - const customer = await autumnV2.customers.get(customerId); - const balance = customer.balances[TestFeature.Messages]; - - // 3 entities × 100 messages each = 300 total - expect(balance).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - purchased_balance: 0, - }); - }); - - test("initial state: each entity should have 100 messages", async () => { - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - const balance = fetchedEntity.balances?.[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - purchased_balance: 0, - }); - } - }); - - test("update customer balance from 300 to 240 (sequential deduction)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 240, - }); - - // Customer should have updated balance - const customer = await autumnV2.customers.get(customerId); - const balance = customer.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 240, - current_balance: 240, - usage: 0, - purchased_balance: 0, - }); - - // Sequential deduction: 60 deducted from first entity (100 → 40) - // Entity 1: 40, Entity 2: 100, Entity 3: 100 - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 40, - current_balance: 40, - usage: 0, - purchased_balance: 0, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - purchased_balance: 0, - }); - - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - purchased_balance: 0, - }); - }); - - test("verify database state matches cache after update", async () => { - // // Wait for database sync - // await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Verify customer from DB - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - const customerFromCache = - await autumnV2.customers.get(customerId); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 240, - current_balance: 240, - usage: 0, - purchased_balance: 0, - }); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject( - customerFromCache.balances[TestFeature.Messages], - ); - - // Verify all entities from DB - sequential deduction means: 40, 100, 100 - const expectedBalances = [40, 100, 100]; - - for (let i = 0; i < entities.length; i++) { - const entityFromDb = (await autumnV2.entities.get( - customerId, - entities[i].id, - { - skip_cache: "true", - }, - )) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entities[i].id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: expectedBalances[i], - current_balance: expectedBalances[i], - usage: 0, - purchased_balance: 0, - }); - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - }); - - test("update customer balance from 240 to 150 (sequential deduction from 40, 100, 100)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 150, - }); - - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - - // Sequential deduction of 90: First entity 40→0, second entity 100→50, third entity stays 100 - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 0, - current_balance: 0, - usage: 0, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - }); - - test("increase customer balance from 150 to 280 (sequential addition)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 280, - }); - - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 280, - current_balance: 280, - usage: 0, - }); - - // Sequential addition of 130: First entity 0→100, second entity 50→80, third entity 100→100 - // (Assuming entities can go back to their original granted amount of 100) - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 130, - current_balance: 130, - usage: 0, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/entity/update-entity-balance2.test.ts b/server/tests/balances/update/update-current-balance/entity/update-entity-balance2.test.ts deleted file mode 100644 index 74380f411..000000000 --- a/server/tests/balances/update/update-current-balance/entity/update-entity-balance2.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, type ApiEntityV1, 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const messagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - entityFeatureId: TestFeature.Users, // Per-entity balance -}); - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesItem], -}); - -const testCase = "update-entity-balance2"; - -describe(`${chalk.yellowBright("update-entity-balance2: update specific entity balance")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-3`, - name: "User 3", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - - await autumnV2.entities.create(customerId, entities); - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - }); - - test("initial state: customer should have 300 messages, each entity 100", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - } - }); - - test("update first entity balance from 100 to 70", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 70, - }); - - // First entity should have 70 - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 70, - current_balance: 70, - usage: 0, - }); - - // Other entities should still have 100 - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - // Customer balance should be 270 (70 + 100 + 100) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 270, - current_balance: 270, - usage: 0, - }); - }); - - test("update second entity balance from 100 to 120", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - current_balance: 120, - }); - - // Second entity should have 120 - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 120, - current_balance: 120, - usage: 0, - }); - - // Customer balance should be 290 (70 + 120 + 100) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 290, - current_balance: 290, - usage: 0, - }); - }); - - test("update third entity balance from 100 to 50", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[2].id, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - // Third entity should have 50 - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - - // Customer balance should be 240 (70 + 120 + 50) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 240, - current_balance: 240, - usage: 0, - }); - }); - - test("verify database state matches cache for all entities", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const expectedEntityBalances = [70, 120, 50]; - - for (let i = 0; i < entities.length; i++) { - const entityFromDb = (await autumnV2.entities.get( - customerId, - entities[i].id, - { - skip_cache: "true", - }, - )) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entities[i].id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: expectedEntityBalances[i], - current_balance: expectedEntityBalances[i], - usage: 0, - }); - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - - // Verify customer balance - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 240, - current_balance: 240, - usage: 0, - }); - }); - - test("track usage on updated entity and verify balances", async () => { - // Track 20 messages on entity 1 (currently has 70) - await autumnV2.track({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - value: 20, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 70, - current_balance: 50, - usage: 20, - }); - - // Customer balance should be 220 (50 + 120 + 50) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 240, - current_balance: 220, - usage: 20, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/entity/update-entity-balance3.test.ts b/server/tests/balances/update/update-current-balance/entity/update-entity-balance3.test.ts deleted file mode 100644 index 5cd7147c9..000000000 --- a/server/tests/balances/update/update-current-balance/entity/update-entity-balance3.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - type ApiEntityV1, - ApiVersion, - type CheckResponseV2, - ProductItemInterval, - ResetInterval, -} 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const monthlyMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - interval: ProductItemInterval.Month, - intervalCount: 1, - entityFeatureId: TestFeature.Users, -}); - -const lifetimeMessages = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: null, - entityFeatureId: TestFeature.Users, -}); - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [monthlyMessages, lifetimeMessages], -}); - -const testCase = "update-entity-balance3"; - -describe(`${chalk.yellowBright("update-entity-balance3: update entity balance with multiple intervals (breakdown)")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - - await autumnV2.entities.create(customerId, entities); - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - }); - - test("initial state: customer should have 300 messages (150 per entity), each entity 150", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - } - }); - - test("check breakdown for entity shows 2 items (monthly and lifetime)", async () => { - const checkRes = (await autumnV2.check({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(checkRes.balance?.breakdown).toHaveLength(2); - - // Find monthly and lifetime breakdowns - const monthlyBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - const lifetimeBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - expect(monthlyBreakdown).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - }); - - test("update first entity balance from 150 to 120", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 120, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 120, - current_balance: 120, - usage: 0, - }); - - // Check breakdown is proportionally updated - const checkRes = (await autumnV2.check({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - const monthlyBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - const lifetimeBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - // Deduction of 30 is sequential from first breakdown (monthly) - expect(monthlyBreakdown).toMatchObject({ - granted_balance: 70, - current_balance: 70, - usage: 0, - }); - - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - - // Customer balance should be 270 (120 + 150) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 270, - current_balance: 270, - usage: 0, - }); - }); - - test("track usage on updated entity and verify breakdown", async () => { - // Track 60 on entity 1 (currently has 120: 80 monthly + 40 lifetime) - await autumnV2.track({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - value: 60, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 120, - current_balance: 60, - usage: 60, - }); - - // Check breakdown - should deduct from monthly first - const checkRes = (await autumnV2.check({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - const monthlyBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - const lifetimeBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - // Should deduct 60 from monthly (was 70, now 10) - expect(monthlyBreakdown).toMatchObject({ - granted_balance: 70, - current_balance: 10, - usage: 60, - }); - - // Lifetime should remain untouched - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - }); - - test("update entity balance after usage to 180", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 180, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 240, - current_balance: 180, - usage: 60, - }); - - // Check breakdown is proportionally updated (180 / 150 = 1.2) - const checkRes = (await autumnV2.check({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - const monthlyBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - const lifetimeBreakdown = checkRes.balance?.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - expect(monthlyBreakdown).toMatchObject({ - granted_balance: 190, - current_balance: 130, - usage: 60, - }); - - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - }); - - // Customer balance should be 330 (180 + 150) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 390, - current_balance: 330, - usage: 60, - }); - }); - - test("verify database state matches cache for all entities", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const expectedEntityBalances = [180, 150]; - const expectedEntityGrantedBalances = [240, 150]; - - for (let i = 0; i < entities.length; i++) { - const entityFromDb = (await autumnV2.entities.get( - customerId, - entities[i].id, - { - skip_cache: "true", - }, - )) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entities[i].id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: expectedEntityGrantedBalances[i], - current_balance: expectedEntityBalances[i], - usage: i === 0 ? 60 : 0, - }); - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 390, - current_balance: 330, - usage: 60, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/entity/update-entity-balance4.test.ts b/server/tests/balances/update/update-current-balance/entity/update-entity-balance4.test.ts deleted file mode 100644 index bfeeacb2c..000000000 --- a/server/tests/balances/update/update-current-balance/entity/update-entity-balance4.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - type ApiEntityV1, - ApiVersion, - type CheckResponseV2, -} 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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -/** - * Test: update-entity-balance4 - * - * Tests setting negative current_balance values on entity balances - * using arrear items (pay-per-use) which allow going into negative. - */ - -const arrearMessages = constructArrearItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - entityFeatureId: TestFeature.Users, - price: 0.1, - billingUnits: 1, -}); - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [arrearMessages], -}); - -const testCase = "update-entity-balance4"; - -describe(`${chalk.yellowBright("update-entity-balance4: set negative balance on arrear entity items")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - attachPm: "success", - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - - await autumnV2.entities.create(customerId, entities); - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - }); - - test("initial state: customer should have 200 messages (100 per entity)", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 200, - current_balance: 200, - usage: 0, - }); - - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - } - }); - - test("check shows overage_allowed=true for arrear item", async () => { - const checkRes = (await autumnV2.check({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(checkRes.balance?.overage_allowed).toBe(true); - }); - - test("update first entity balance to negative (-50)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: -50, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - // Arrear items: current_balance goes to 0, purchased_balance absorbs the negative - // granted_balance is set to -50, purchased_balance = 50, so current = -50 + 50 = 0 - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: -50, - current_balance: 0, - purchased_balance: 50, - usage: 0, - }); - - // Entity 2 should remain unchanged - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - purchased_balance: 0, - usage: 0, - }); - - // Customer balance should reflect the sum: granted=-50+100=50, current=0+100=100 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 100, - purchased_balance: 50, - usage: 0, - }); - }); - - test("update first entity to deeper negative (-100)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: -100, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - // granted_balance = -100, purchased_balance = 100, current = -100 + 100 = 0 - // usage stays 0 (updating current_balance doesn't change usage) - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: -100, - current_balance: 0, - purchased_balance: 100, - usage: 0, - }); - - // Customer balance: granted=-100+100=0, current=0+100=100 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 0, - current_balance: 100, - purchased_balance: 100, - usage: 0, - }); - }); - - test("update second entity to negative as well (-25)", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - current_balance: -25, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - - // granted_balance = -25, purchased_balance = 25, current = -25 + 25 = 0 - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: -25, - current_balance: 0, - purchased_balance: 25, - usage: 0, - }); - - // Customer balance: granted=-100+(-25)=-125, current=0+0=0 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: -125, - current_balance: 0, - purchased_balance: 125, - usage: 0, - }); - }); - - test("update entity from negative back to positive", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - - // When positive, purchased_balance = 0, granted = current = 50 - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - purchased_balance: 0, - usage: 0, - }); - - // Customer balance: granted=50+(-25)=25, current=50+0=50 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 25, - current_balance: 50, - purchased_balance: 25, - usage: 0, - }); - }); - - test("update entity from negative back to zero", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - current_balance: 0, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - - // Zero: granted = current = 0, purchased = 0 - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 0, - current_balance: 0, - purchased_balance: 0, - usage: 0, - }); - - // Customer balance: granted=50+0=50, current=50+0=50 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - purchased_balance: 0, - usage: 0, - }); - }); - - test("verify database state matches cache for all entities", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const expectedEntityBalances = [ - { granted: 50, current: 50, purchased: 0, usage: 0 }, - { granted: 0, current: 0, purchased: 0, usage: 0 }, - ]; - - for (let i = 0; i < entities.length; i++) { - const entityFromDb = (await autumnV2.entities.get( - customerId, - entities[i].id, - { - skip_cache: "true", - }, - )) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entities[i].id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: expectedEntityBalances[i].granted, - current_balance: expectedEntityBalances[i].current, - purchased_balance: expectedEntityBalances[i].purchased, - usage: expectedEntityBalances[i].usage, - }); - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 50, - current_balance: 50, - purchased_balance: 0, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/entity/update-entity-products1.test.ts b/server/tests/balances/update/update-current-balance/entity/update-entity-products1.test.ts deleted file mode 100644 index a0a9e4491..000000000 --- a/server/tests/balances/update/update-current-balance/entity/update-entity-products1.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, type ApiEntityV1, 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const messagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}); - -const entityProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesItem], -}); - -const testCase = "update-entity-products1"; - -describe(`${chalk.yellowBright("update-entity-products1: update entity balance with entity products")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-3`, - name: "User 3", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [entityProd], - prefix: testCase, - }); - - await autumnV2.entities.create(customerId, entities); - - // Attach product to each entity - for (const entity of entities) { - await autumnV2.attach({ - customer_id: customerId, - entity_id: entity.id, - product_id: entityProd.id, - }); - } - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - }); - - test("initial state: customer should have 300 messages (100 per entity), each entity 100", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - } - }); - - test("update first entity balance from 100 to 80", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 80, - }); - - // First entity should have 80 - const entity1 = await autumnV2.entities.get( - customerId, - entities[0].id, - ); - expect(entity1.balances![TestFeature.Messages]).toMatchObject({ - granted_balance: 80, - current_balance: 80, - usage: 0, - }); - - // Other entities should still have 100 - const entity2 = await autumnV2.entities.get( - customerId, - entities[1].id, - ); - expect(entity2.balances![TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - // Customer balance should be 280 (80 + 100 + 100) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 280, - current_balance: 280, - usage: 0, - }); - }); - - test("update second entity balance from 100 to 150", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - current_balance: 150, - }); - - const entity2 = await autumnV2.entities.get( - customerId, - entities[1].id, - ); - expect(entity2.balances![TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - - // Customer balance should be 330 (80 + 150 + 100) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 330, - current_balance: 330, - usage: 0, - }); - }); - - test("update at customer level with sequential deduction", async () => { - // Current state: Entity 1: 80, Entity 2: 150, Entity 3: 100, Customer: 330 - // Update customer balance from 330 to 165 (sequential deduction of 165) - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 165, - }); - - // Sequential deduction: Deduct 80 from Entity 1 (80 → 0), then 85 from Entity 2 (150 → 65), Entity 3 untouched - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 0, - current_balance: 0, - usage: 0, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 65, - current_balance: 65, - usage: 0, - }); - - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - // Customer should have 165 - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 165, - current_balance: 165, - usage: 0, - }); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const expectedEntityBalances = [0, 65, 100]; - - for (let i = 0; i < entities.length; i++) { - const entityFromDb = (await autumnV2.entities.get( - customerId, - entities[i].id, - { - skip_cache: "true", - }, - )) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entities[i].id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: expectedEntityBalances[i], - current_balance: expectedEntityBalances[i], - usage: 0, - }); - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 165, - current_balance: 165, - usage: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-current-balance/entity/update-entity-products2.test.ts b/server/tests/balances/update/update-current-balance/entity/update-entity-products2.test.ts deleted file mode 100644 index b494cb2ad..000000000 --- a/server/tests/balances/update/update-current-balance/entity/update-entity-products2.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, type ApiEntityV1, 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const customerMessagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, -}); - -const entityMessagesItem = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}); - -const customerProd = constructProduct({ - type: "free", - isDefault: false, - items: [customerMessagesItem], -}); - -const entityProd = constructProduct({ - type: "free", - id: "entity_prod", - isDefault: false, - items: [entityMessagesItem], -}); - -const testCase = "update-entity-products2"; - -describe(`${chalk.yellowBright("update-entity-products2: update with mixed customer and entity products")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - const entities = [ - { - id: `${testCase}-user-1`, - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-2`, - name: "User 2", - feature_id: TestFeature.Users, - }, - { - id: `${testCase}-user-3`, - name: "User 3", - feature_id: TestFeature.Users, - }, - ]; - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [customerProd, entityProd], - prefix: testCase, - }); - - await autumnV2.entities.create(customerId, entities); - - // Attach customer product - await autumnV2.attach({ - customer_id: customerId, - product_id: customerProd.id, - }); - - // Attach entity product to each entity - for (const entity of entities) { - await autumnV2.attach({ - customer_id: customerId, - entity_id: entity.id, - product_id: entityProd.id, - }); - } - - // Initialize caches - await autumnV2.customers.get(customerId); - for (const entity of entities) { - await autumnV2.entities.get(customerId, entity.id); - } - }); - - test("initial state: customer should have 350 messages (50 + 3*100), each entity 150 (50 + 100)", async () => { - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 350, - current_balance: 350, - usage: 0, - }); - - for (const entity of entities) { - const fetchedEntity = (await autumnV2.entities.get( - customerId, - entity.id, - )) as ApiEntityV1; - expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 150, - current_balance: 150, - usage: 0, - }); - } - }); - - test("update first entity balance from 150 to 100", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[0].id, - feature_id: TestFeature.Messages, - current_balance: 100, - }); - - const entity1 = await autumnV2.entities.get( - customerId, - entities[0].id, - ); - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - // Customer balance should be 300 (350 - 50 deducted from E1) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 300, - current_balance: 300, - usage: 0, - }); - }); - - test("update second entity balance from 150 to 200", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - current_balance: 200, - }); - - const entity2 = await autumnV2.entities.get( - customerId, - entities[1].id, - ); - expect(entity2.balances![TestFeature.Messages]).toMatchObject({ - granted_balance: 200, - current_balance: 200, - usage: 0, - }); - - // Customer balance should be 350 (300 + 50 added to E2) - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 350, - current_balance: 350, - usage: 0, - }); - }); - - test("update customer balance from 350 to 175 (sequential distribution)", async () => { - // Current state: Entity 1: 50, Entity 2: 150, Entity 3: 100, Customer: 50 - // Update to 175: distribute sequentially - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 175, - }); - - // Customer goes down to 0, E1 goes down to 0, E2 goes down to 75, E3 stays at 100 - const entity1 = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 0, - current_balance: 0, - usage: 0, - }); - - const entity2 = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 75, - current_balance: 75, - usage: 0, - }); - - const entity3 = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - }); - - const customer = await autumnV2.customers.get(customerId); - expect(customer.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 175, - current_balance: 175, - usage: 0, - }); - }); - - test("verify database state matches cache", async () => { - // Wait for database sync - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const expectedEntityBalances = [0, 75, 100]; - - for (let i = 0; i < entities.length; i++) { - const entityFromDb = (await autumnV2.entities.get( - customerId, - entities[i].id, - { - skip_cache: "true", - }, - )) as ApiEntityV1; - const entityFromCache = (await autumnV2.entities.get( - customerId, - entities[i].id, - )) as ApiEntityV1; - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: expectedEntityBalances[i], - current_balance: expectedEntityBalances[i], - usage: 0, - }); - - expect(entityFromDb.balances?.[TestFeature.Messages]).toMatchObject( - entityFromCache.balances?.[TestFeature.Messages] ?? {}, - ); - } - - const customerFromDb = await autumnV2.customers.get( - customerId, - { - skip_cache: "true", - }, - ); - - expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 175, - current_balance: 175, - usage: 0, - }); - }); - - test("track on entity, then update customer balance", async () => { - // Current state: E1: 0, E2: 75, E3: 100 (from previous test with customer-first deduction) - // Track 30 on entity 2 (currently has 75) - await autumnV2.track({ - customer_id: customerId, - entity_id: entities[1].id, - feature_id: TestFeature.Messages, - value: 30, - }); - - const entity2Before = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2Before.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 75, - current_balance: 45, - usage: 30, - }); - - // Customer should have 145 (0 + 45 + 100) - const customerBefore = - await autumnV2.customers.get(customerId); - expect(customerBefore.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 175, - current_balance: 145, - usage: 30, - }); - - // Update customer balance to 290 (refund 145) - // With customer-first refund: customer-level gets 145, entities unchanged - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 290, - }); - - // Customer-level refunded first: 0 → 145 - // Merged views: E1 = 145+0, E2 = 145+45, E3 = 145+100 - const entity1After = (await autumnV2.entities.get( - customerId, - entities[0].id, - )) as ApiEntityV1; - expect(entity1After.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 145, - current_balance: 145, - usage: 0, - }); - - const entity2After = (await autumnV2.entities.get( - customerId, - entities[1].id, - )) as ApiEntityV1; - expect(entity2After.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 220, - current_balance: 190, - usage: 30, - }); - - const entity3After = (await autumnV2.entities.get( - customerId, - entities[2].id, - )) as ApiEntityV1; - expect(entity3After.balances?.[TestFeature.Messages]).toMatchObject({ - granted_balance: 245, - current_balance: 245, - usage: 0, - }); - - const customerAfter = await autumnV2.customers.get(customerId); - expect(customerAfter.balances[TestFeature.Messages]).toMatchObject({ - granted_balance: 320, - current_balance: 290, - usage: 30, - }); - }); -}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance1.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance1.test.ts deleted file mode 100644 index d8c9ba8b4..000000000 --- a/server/tests/balances/update/update-granted-balance/update-granted-balance1.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - -const testCase = "update-granted-balance1"; - -describe(`${chalk.yellowBright("update-granted-balance1: testing update granted balance")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update granted balance to 150", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 100, - granted_balance: 150, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 150, - current_balance: 100, - usage: 50, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance2.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance2.test.ts deleted file mode 100644 index 4f7ef8d30..000000000 --- a/server/tests/balances/update/update-granted-balance/update-granted-balance2.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiCustomer, - ApiVersion, - type LimitedItem, - ResetInterval, -} 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const monthlyMsges = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const lifetimeMsges = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 50, - interval: null, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [monthlyMsges, lifetimeMsges], -}); - -const testCase = "update-granted-balance2"; - -describe(`${chalk.yellowBright("update-granted-balance2: testing update granted balance when there's a breakdown")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update granted balance to 150 for monthly feature", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - granted_balance: 75, - interval: ResetInterval.Month, - }); - - const customerV2 = await autumnV2.customers.get(customerId); - const balance = customerV2.balances[TestFeature.Messages]; - - const monthlyBreakdown = balance.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.Month, - ); - - const lifetimeBreakdown = balance.breakdown?.find( - (b) => b.reset?.interval === ResetInterval.OneOff, - ); - - expect(monthlyBreakdown).toMatchObject({ - granted_balance: 75, - current_balance: 50, - usage: 25, - purchased_balance: 0, - }); - - expect(lifetimeBreakdown).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance3.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance3.test.ts deleted file mode 100644 index f8cd36cd6..000000000 --- a/server/tests/balances/update/update-granted-balance/update-granted-balance3.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { - type ApiEntityV1, - ApiVersion, - type LimitedItem, - ResetInterval, -} 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const monthlyMsges = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - entityFeatureId: TestFeature.Users, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [monthlyMsges], -}); - -const entities = [ - { - id: "update-granted-balance3-user-1", - name: "User 1", - feature_id: TestFeature.Users, - }, - { - id: "update-granted-balance3-user-2", - name: "User 2", - feature_id: TestFeature.Users, - }, -]; - -const testCase = "update-granted-balance3"; - -describe(`${chalk.yellowBright("update-granted-balance3: testing update granted balance on entity balances (targetting entity)")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.entities.create(customerId, entities); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update granted balance to 75 for monthly feature on entity balance, entity 1", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: entities[0].id, - current_balance: 50, - granted_balance: 75, - interval: ResetInterval.Month, - }); - - const entity1 = await autumnV2.entities.get( - customerId, - entities[0].id, - ); - const balance = entity1.balances![TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 75, - current_balance: 50, - usage: 25, - purchased_balance: 0, - }); - - const entity2 = await autumnV2.entities.get( - customerId, - entities[1].id, - ); - const balance2 = entity2.balances?.[TestFeature.Messages]; - - expect(balance2).toMatchObject({ - granted_balance: 100, - current_balance: 100, - usage: 0, - purchased_balance: 0, - }); - }); - - test("should update granted to 50 for entity 2", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - entity_id: entities[1].id, - current_balance: 25, - granted_balance: 50, - }); - - const entity2 = await autumnV2.entities.get( - customerId, - entities[1].id, - ); - const balance2 = entity2.balances![TestFeature.Messages]; - - expect(balance2).toMatchObject({ - granted_balance: 50, - current_balance: 25, - usage: 25, - purchased_balance: 0, - }); - - const entity1 = await autumnV2.entities.get( - customerId, - entities[0].id, - ); - const balance1 = entity1.balances![TestFeature.Messages]; - - expect(balance1).toMatchObject({ - granted_balance: 75, - current_balance: 50, - usage: 25, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/balances/update/update-granted-balance/update-granted-balance4.test.ts b/server/tests/balances/update/update-granted-balance/update-granted-balance4.test.ts deleted file mode 100644 index fc9f3f44c..000000000 --- a/server/tests/balances/update/update-granted-balance/update-granted-balance4.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { type ApiCustomer, ApiVersion, type LimitedItem } 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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; -import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; -import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; - -const monthlyMsges = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}) as LimitedItem; - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [monthlyMsges], -}); - -const testCase = "update-granted-balance4"; - -describe(`${chalk.yellowBright("update-granted-balance4: testing update current balance, then update granted balance")}`, () => { - const customerId = testCase; - const autumnV2: AutumnInt = new AutumnInt({ version: ApiVersion.V2_0 }); - - beforeAll(async () => { - await initCustomerV3({ - ctx, - customerId, - withTestClock: false, - }); - - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - await autumnV2.attach({ - customer_id: customerId, - product_id: freeProd.id, - }); - }); - - test("should update current balance to 50", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - current_balance: 50, - }); - - const customer = await autumnV2.customers.get(customerId); - const balance = customer.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 50, - current_balance: 50, - usage: 0, - purchased_balance: 0, - }); - }); - - test("should update granted balance to 100", async () => { - await autumnV2.balances.update({ - customer_id: customerId, - feature_id: TestFeature.Messages, - granted_balance: 100, - current_balance: 50, - }); - - const customer = await autumnV2.customers.get(customerId); - const balance = customer.balances[TestFeature.Messages]; - - expect(balance).toMatchObject({ - granted_balance: 100, - current_balance: 50, - usage: 50, - purchased_balance: 0, - }); - }); -}); diff --git a/server/tests/integration/balances/legacy/legacy-update-balance.test.ts b/server/tests/integration/balances/legacy/legacy-update-balance.test.ts new file mode 100644 index 000000000..c37518d06 --- /dev/null +++ b/server/tests/integration/balances/legacy/legacy-update-balance.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Tests for legacy V1 API balance updates. + * These test the deprecated customers.setBalance API for backwards compatibility. + */ + +// ============================================================================= +// Test: legacy-update-balance1 - V1 API setBalance for entity +// ============================================================================= +test.concurrent(`${chalk.yellowBright("legacy-update-balance1: V1 API setBalance for entity")}`, async () => { + const creditsItem = items.monthlyCredits({ includedUsage: 500 }); + const pro = products.pro({ id: "pro", items: [creditsItem] }); + + const { customerId, autumnV1, entities } = await initScenario({ + customerId: "legacy-update-balance1", + setup: [ + s.customer({ testClock: true, paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Credits }), + ], + actions: [ + s.attach({ productId: pro.id, entityIndex: 0 }), + ], + }); + + const entityId = entities[0].id; + + // Use legacy V1 API to set balance + await autumnV1.customers.setBalance({ + customerId: customerId, + entityId: entityId, + balances: [ + { + feature_id: TestFeature.Credits, + balance: 100, + }, + ], + }); + + // Verify via V1 API + const entity = await autumnV1.entities.get(customerId, entityId); + expect(entity.features.credits.balance).toBe(100); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-allocated.test.ts b/server/tests/integration/balances/update/balance/update-balance-allocated.test.ts new file mode 100644 index 000000000..81f198695 --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-allocated.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; +import { ProductItemFeatureType } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-ALLOCATED1: Update balance on free allocated feature with overage +// Tests ContinuousUse feature type where overage goes to purchased_balance +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-allocated1: update balance on free allocated feature with overage")}`, async () => { + // Create allocated users item (ContinuousUse type) + const usersItem = constructFeatureItem({ + featureId: TestFeature.Users, + includedUsage: 5, + featureType: ProductItemFeatureType.ContinuousUse, + }); + + const freeProd = products.base({ id: "free", items: [usersItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-allocated1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Initial state: should have balance of 5 users + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Users]).toMatchObject({ + granted_balance: 5, + current_balance: 5, + purchased_balance: 0, + usage: 0, + }); + + // Track +8 to make current_balance 0 and purchased_balance 3 + // Track 8 users when we only have 5 allocated + // Result: granted=5, usage=8, current=0, purchased=3 + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Users, + value: 8, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 5, + current_balance: 0, + purchased_balance: 3, + usage: 8, + }); + + // Verify via customers.get + const afterTrack = await autumnV2.customers.get(customerId); + expect(afterTrack.balances[TestFeature.Users]).toMatchObject({ + granted_balance: 5, + current_balance: 0, + purchased_balance: 3, + usage: 8, + }); + + // Update current_balance to 2 (positive): purchased_balance should reset to 0 + // NEW BEHAVIOR: granted_balance does NOT change when only current_balance is passed + // Instead, usage changes to achieve the target current_balance + // current_balance = granted_balance - usage => usage = granted_balance - current_balance + // usage = 5 - 2 = 3 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Users, + current_balance: 2, + }); + + const afterUpdate1 = await autumnV2.customers.get(customerId); + expect(afterUpdate1.balances[TestFeature.Users]).toMatchObject({ + granted_balance: 5, // Unchanged + current_balance: 2, + purchased_balance: 0, // Reset since we're no longer in overage + usage: 3, // 5 - 2 = 3 + }); + + // Update current_balance to -5 (negative): should create overage + // For allocated features, current_balance floors at 0 + // purchased_balance absorbs the overage + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Users, + current_balance: -5, + }); + + const afterUpdate2 = await autumnV2.customers.get(customerId); + // granted_balance stays at 5, usage = 5 - (-5) = 10 + // But since current_balance floors at 0, purchased_balance = 5 + expect(afterUpdate2.balances[TestFeature.Users]).toMatchObject({ + granted_balance: 5, // Unchanged + current_balance: 0, // Floored at 0 + purchased_balance: 5, // Overage absorbed here + usage: 10, // 5 + 5 = 10 + }); + + // Verify database state matches cache + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customerFromDb.balances[TestFeature.Users]).toMatchObject({ + granted_balance: 5, + current_balance: 0, + purchased_balance: 5, + usage: 10, + }); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-basic.test.ts b/server/tests/integration/balances/update/balance/update-balance-basic.test.ts new file mode 100644 index 000000000..f25251750 --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-basic.test.ts @@ -0,0 +1,622 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, CheckResponseV2 } from "@autumn/shared"; +import { ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BASIC1: Update monthly balance from 100 to 80 then to 120 +// NEW BEHAVIOR: granted_balance does NOT change, only usage changes +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-basic1: update monthly balance from 100 to 80 then to 120")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-basic1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Update current balance from 100 to 80 + // NEW: granted_balance stays 100, usage becomes 20 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 80, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 80, + usage: 20, // 100 - 80 + purchased_balance: 0, + }); + + // Verify DB sync + const customer1Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer1Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 80, + usage: 20, + purchased_balance: 0, + }); + + // Update current balance from 80 to 120 (above granted) + // NEW: granted_balance stays 100, usage becomes -20 (credit) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 120, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 120, + usage: -20, // 100 - 120 = -20 (credit) + purchased_balance: 0, + }); + + // Verify DB sync + const customer2Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer2Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 120, + usage: -20, + purchased_balance: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BASIC2: Update balance after tracking usage +// NEW BEHAVIOR: granted_balance does NOT change, usage adjusts +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-basic2: update balance after track")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-basic2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Track 30 usage + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 100, + current_balance: 70, + usage: 30, + purchased_balance: 0, + }); + + // Update current_balance to 50 after tracking + // NEW: granted_balance stays 100, usage becomes 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, + usage: 50, // 100 - 50 + purchased_balance: 0, + }); + + // Verify DB sync + const customer1Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer1Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 50, + usage: 50, + purchased_balance: 0, + }); + + // Update current_balance to 120 (above original granted) + // NEW: granted_balance stays 100, usage becomes -20 (credit) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 120, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 120, + usage: -20, // 100 - 120 = -20 (credit) + purchased_balance: 0, + }); + + // Verify DB sync + const customer2Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer2Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 120, + usage: -20, + purchased_balance: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BASIC3: Update balance to 0 +// NEW BEHAVIOR: granted_balance does NOT change, usage = granted +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-basic3: update balance to 0")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-basic3", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Update current_balance to 0 + // NEW: granted_balance stays 100, usage becomes 100 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 0, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 0, + usage: 100, // 100 - 0 + purchased_balance: 0, + }); + + // Verify DB sync + const customer1Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer1Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 0, + usage: 100, + purchased_balance: 0, + }); + + // Update current_balance from 0 to 50 + // NEW: granted_balance stays 100, usage becomes 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, + usage: 50, // 100 - 50 + purchased_balance: 0, + }); + + // Verify DB sync + const customer2Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer2Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 50, + usage: 50, + purchased_balance: 0, + }); + + // Track 20 then update to 0 + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + // Balance should be 30 now (50 - 20), usage = 70 + const beforeUpdate = await autumnV2.customers.get(customerId); + expect(beforeUpdate.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 30, + usage: 70, // 50 usage + 20 track + }); + + // Update to 0 + // NEW: granted_balance stays 100, usage becomes 100 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 0, + }); + + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 0, + usage: 100, // 100 - 0 + purchased_balance: 0, + }); + + // Verify DB sync + const customer3Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer3Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 0, + usage: 100, + purchased_balance: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BASIC4: Update lifetime (one-off) balance +// NEW BEHAVIOR: granted_balance does NOT change, usage adjusts +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-basic4: update lifetime (one-off) balance")}`, async () => { + const messagesItem = items.lifetimeMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-basic4", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Verify initial balance with lifetime interval + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + purchased_balance: 0, + }); + expect(initialCustomer.balances[TestFeature.Messages].reset?.interval).toBe(ResetInterval.OneOff); + + // Update current_balance from 100 to 50 + // NEW: granted_balance stays 100, usage becomes 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, + usage: 50, // 100 - 50 + purchased_balance: 0, + }); + + // Verify DB sync + const customer1Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer1Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 50, + usage: 50, + purchased_balance: 0, + }); + + // Track 20 then update to 80 + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + // Balance should be 30 now (50 - 20), usage = 70 + const beforeUpdate = await autumnV2.customers.get(customerId); + expect(beforeUpdate.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 30, + usage: 70, + }); + + // Update to 80 + // NEW: granted_balance stays 100, usage becomes 20 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 80, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 80, + usage: 20, // 100 - 80 + purchased_balance: 0, + }); + + // Verify DB sync + const customer2Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer2Db.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 80, + usage: 20, + purchased_balance: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BASIC5: Update balance with decimal values (credits) +// NEW BEHAVIOR: granted_balance does NOT change, usage adjusts +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-basic5: update balance with decimal values (credits)")}`, async () => { + const creditsItem = items.monthlyCredits({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [creditsItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-basic5", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Update current_balance to decimal value 72.65 + // NEW: granted_balance stays 100, usage becomes 27.35 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 72.65, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Credits].granted_balance).toBe(100); // Unchanged + expect(customer1.balances[TestFeature.Credits].current_balance).toBeCloseTo(72.65, 2); + expect(customer1.balances[TestFeature.Credits].usage).toBeCloseTo(27.35, 2); // 100 - 72.65 + expect(customer1.balances[TestFeature.Credits].purchased_balance).toBe(0); + + // Verify DB sync + const customer1Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer1Db.balances[TestFeature.Credits].granted_balance).toBe(100); + expect(customer1Db.balances[TestFeature.Credits].current_balance).toBeCloseTo(72.65, 2); + expect(customer1Db.balances[TestFeature.Credits].usage).toBeCloseTo(27.35, 2); + + // Track decimal value 27.35 then update to 50.50 + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Credits, + value: 27.35, + }); + + // Balance should be 45.30 now (72.65 - 27.35), usage = 54.70 + const beforeUpdate = await autumnV2.customers.get(customerId); + expect(beforeUpdate.balances[TestFeature.Credits].current_balance).toBeCloseTo(45.3, 2); + expect(beforeUpdate.balances[TestFeature.Credits].usage).toBeCloseTo(54.7, 2); + + // Update to 50.50 + // NEW: granted_balance stays 100, usage becomes 49.50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 50.5, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Credits].granted_balance).toBe(100); // Unchanged + expect(customer2.balances[TestFeature.Credits].current_balance).toBeCloseTo(50.5, 2); + expect(customer2.balances[TestFeature.Credits].usage).toBeCloseTo(49.5, 2); // 100 - 50.50 + expect(customer2.balances[TestFeature.Credits].purchased_balance).toBe(0); + + // Verify DB sync + const customer2Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer2Db.balances[TestFeature.Credits].granted_balance).toBe(100); + expect(customer2Db.balances[TestFeature.Credits].current_balance).toBeCloseTo(50.5, 2); + expect(customer2Db.balances[TestFeature.Credits].usage).toBeCloseTo(49.5, 2); + + // Update to very small decimal 0.01 + // NEW: granted_balance stays 100, usage becomes 99.99 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Credits, + current_balance: 0.01, + }); + + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Credits].granted_balance).toBe(100); // Unchanged + expect(customer3.balances[TestFeature.Credits].current_balance).toBeCloseTo(0.01, 2); + expect(customer3.balances[TestFeature.Credits].usage).toBeCloseTo(99.99, 2); // 100 - 0.01 + + // Verify DB sync + const customer3Db = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customer3Db.balances[TestFeature.Credits].granted_balance).toBe(100); + expect(customer3Db.balances[TestFeature.Credits].current_balance).toBeCloseTo(0.01, 2); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BASIC6: Sync delta with free/prepaid/arrear breakdowns +// Tests multiple breakdown types and how update distributes across them +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-basic6: sync delta with free/prepaid/arrear breakdowns")}`, async () => { + // Free item - 10 messages (goes to granted_balance) + const freeMessages = items.monthlyMessages({ includedUsage: 10 }); + // Prepaid item - 0 included, will purchase 20 credits (goes to purchased_balance) + const prepaidMessages = items.prepaidMessages({ includedUsage: 0, price: 1, billingUnits: 1 }); + // Arrear (pay-per-use) item - 15 messages included, overage allowed + const arrearMessages = items.consumableMessages({ includedUsage: 15, price: 0.1 }); + + const productA = products.base({ id: "free-messages", items: [freeMessages] }); + const productB = products.base({ id: "prepaid-messages", items: [prepaidMessages], isAddOn: true }); + const productC = products.base({ id: "arrear-messages", items: [arrearMessages], isAddOn: true }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-basic6", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [productA, productB, productC] }), + ], + actions: [ + s.attach({ productId: productA.id }), + s.attach({ productId: productB.id, options: [{ feature_id: TestFeature.Messages, quantity: 20 }] }), + s.attach({ productId: productC.id }), + ], + }); + + // Wait for Stripe webhooks + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Initial state: 45 total (10 granted + 20 purchased + 15 granted) + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 25, // 10 free + 15 arrear + current_balance: 45, // 25 granted + 20 purchased + purchased_balance: 20, + usage: 0, + }); + + // Check breakdown has 3 items + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(initialCheck.balance?.breakdown).toHaveLength(3); + + // Track 15: exceeds Product A (10), spills into Product B prepaid + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 15, + }); + + const afterTrack1 = await autumnV2.customers.get(customerId); + expect(afterTrack1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 25, + current_balance: 30, + purchased_balance: 20, + usage: 15, + }); + + // Track 10 more: partial usage from prepaid + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + const afterTrack2 = await autumnV2.customers.get(customerId); + expect(afterTrack2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 25, + current_balance: 20, + purchased_balance: 20, + usage: 25, + }); + + // Track 25 more: exhausts remaining and creates overage on arrear + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 25, + }); + + const afterTrack3 = await autumnV2.customers.get(customerId); + expect(afterTrack3.balances[TestFeature.Messages].usage).toBe(50); + expect(afterTrack3.balances[TestFeature.Messages].current_balance).toBeLessThanOrEqual(5); + + // Update balance to 20 + // NEW: granted_balance does NOT change, usage adjusts + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 20, + }); + + const afterUpdate1 = await autumnV2.customers.get(customerId); + expect(afterUpdate1.balances[TestFeature.Messages].current_balance).toBe(20); + // Usage changes to achieve the target current_balance + // granted (25) + purchased (20) - usage = current (20) + // usage = 25 + + // Verify breakdown state + const checkAfterUpdate = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total current_balance across breakdowns should sum to 20 + const totalCurrent = checkAfterUpdate.balance?.breakdown?.reduce( + (sum, b) => sum + (b.current_balance ?? 0), + 0, + ) ?? 0; + expect(totalCurrent).toBe(20); + + // Verify database matches cache + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromCache = await autumnV2.customers.get(customerId); + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customerFromDb.balances[TestFeature.Messages].current_balance).toBe( + customerFromCache.balances[TestFeature.Messages].current_balance, + ); + + // Update balance to -10 (negative): should create overage + // For arrear items, current_balance floors at 0, overage goes to purchased_balance + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: -10, + }); + + const afterNegative = await autumnV2.customers.get(customerId); + expect(afterNegative.balances[TestFeature.Messages].current_balance).toBe(0); // Floored + + // Update balance back to positive (50) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + }); + + const afterPositive = await autumnV2.customers.get(customerId); + expect(afterPositive.balances[TestFeature.Messages].current_balance).toBe(50); + + // Verify breakdowns + const finalCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total current_balance should be 50 + const finalTotal = finalCheck.balance?.breakdown?.reduce( + (sum, b) => sum + (b.current_balance ?? 0), + 0, + ) ?? 0; + expect(finalTotal).toBe(50); + + // Final verification: database matches cache + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const finalFromCache = await autumnV2.customers.get(customerId); + const finalFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(finalFromDb.balances[TestFeature.Messages].current_balance).toBe( + finalFromCache.balances[TestFeature.Messages].current_balance, + ); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-breakdown.test.ts b/server/tests/integration/balances/update/balance/update-balance-breakdown.test.ts new file mode 100644 index 000000000..7eb2ee11b --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-breakdown.test.ts @@ -0,0 +1,429 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, CheckResponseV2 } from "@autumn/shared"; +import { ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Tests for updating balance with multiple products and breakdowns. + * NEW BEHAVIOR: granted_balance does NOT change when only current_balance is passed. + * Instead, usage = granted_balance - current_balance. + */ + +// ============================================================================= +// Test: update-balance-breakdown1 - 3 products same feature +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-breakdown1: 3 products same feature")}`, async () => { + // Setup: Create 3 products with different message amounts + const prodA = products.base({ id: "prod-a", items: [items.monthlyMessages({ includedUsage: 100 })] }); + const prodB = products.base({ id: "prod-b", isAddOn: true, items: [items.monthlyMessages({ includedUsage: 50 })] }); + const prodC = products.base({ id: "prod-c", isAddOn: true, items: [items.lifetimeMessages({ includedUsage: 200 })] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-breakdown1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [prodA, prodB, prodC] }), + ], + actions: [ + s.attach({ productId: prodA.id }), + s.attach({ productId: prodB.id }), + s.attach({ productId: prodC.id }), + ], + }); + + // Initial check: 350 total (100 + 50 + 200) + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(initialCheck.balance).toMatchObject({ + granted_balance: 350, + current_balance: 350, + usage: 0, + }); + expect(initialCheck.balance?.breakdown).toHaveLength(3); + + // Update 1: current_balance to 300 (decrease by 50) + // NEW: granted stays 350, usage becomes 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 300, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 300, + usage: 50, // 350 - 300 + purchased_balance: 0, + }); + + // Verify breakdown sums + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(check1.balance?.breakdown).toHaveLength(3); + const breakdownSum1 = check1.balance?.breakdown?.reduce( + (sum, b) => sum + (b.current_balance ?? 0), + 0, + ) ?? 0; + expect(breakdownSum1).toBe(300); + + // Update 2: current_balance to 400 (increase by 100 from 300) + // NEW: granted stays 350, usage becomes -50 (negative = credit) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 400, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 400, + usage: -50, // 350 - 400 = -50 (credit) + purchased_balance: 0, + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, + current_balance: 400, + usage: -50, + }); +}); + +// ============================================================================= +// Test: update-balance-breakdown2 - filter by interval +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-breakdown2: filter by interval")}`, async () => { + const monthlyProd = products.base({ id: "monthly-prod", items: [items.monthlyMessages({ includedUsage: 100 })] }); + const lifetimeProd = products.base({ id: "lifetime-prod", isAddOn: true, items: [items.lifetimeMessages({ includedUsage: 200 })] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-breakdown2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [monthlyProd, lifetimeProd] }), + ], + actions: [ + s.attach({ productId: monthlyProd.id }), + s.attach({ productId: lifetimeProd.id }), + ], + }); + + // Initial: 300 total (100 monthly + 200 lifetime) + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(initialCheck.balance).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + }); + expect(initialCheck.balance?.breakdown).toHaveLength(2); + + // Update 1: filter by month, set current_balance to 50 + // NEW: monthly breakdown granted stays 100, current becomes 50, usage becomes 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + interval: ResetInterval.Month, + }); + + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, current = 250 (50 monthly + 200 lifetime) + expect(check1.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 250, + usage: 50, // 300 - 250 + }); + + const breakdowns1 = check1.balance?.breakdown ?? []; + const monthlyBreakdown1 = breakdowns1.find((b) => b.reset?.interval === "month"); + const lifetimeBreakdown1 = breakdowns1.find((b) => b.reset?.interval === ResetInterval.OneOff); + + expect(monthlyBreakdown1).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, + usage: 50, // 100 - 50 + }); + expect(lifetimeBreakdown1).toMatchObject({ + granted_balance: 200, + current_balance: 200, + usage: 0, + }); + + // Update 2: filter by lifetime, set current_balance to 100 + // NEW: lifetime breakdown granted stays 200, current becomes 100, usage becomes 100 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 100, + interval: ResetInterval.OneOff, + }); + + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, current = 150 (50 monthly + 100 lifetime) + expect(check2.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 150, + usage: 150, // 300 - 150 (50 from monthly + 100 from lifetime) + }); + + const breakdowns2 = check2.balance?.breakdown ?? []; + const monthlyBreakdown2 = breakdowns2.find((b) => b.reset?.interval === "month"); + const lifetimeBreakdown2 = breakdowns2.find((b) => b.reset?.interval === ResetInterval.OneOff); + + expect(monthlyBreakdown2?.granted_balance).toBe(100); // Unchanged + expect(lifetimeBreakdown2).toMatchObject({ + granted_balance: 200, // Unchanged + current_balance: 100, + usage: 100, // 200 - 100 + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 150, + }); +}); + +// ============================================================================= +// Test: update-balance-breakdown3 - filter by customer_entitlement_id +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-breakdown3: filter by customer_entitlement_id")}`, async () => { + const prodA = products.base({ id: "prod-a", items: [items.monthlyMessages({ includedUsage: 100 })] }); + const prodB = products.base({ id: "prod-b", isAddOn: true, items: [items.monthlyMessages({ includedUsage: 50 })] }); + const prodC = products.base({ id: "prod-c", isAddOn: true, items: [items.lifetimeMessages({ includedUsage: 200 })] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-breakdown3", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [prodA, prodB, prodC] }), + ], + actions: [ + s.attach({ productId: prodA.id }), + s.attach({ productId: prodB.id }), + s.attach({ productId: prodC.id }), + ], + }); + + // Get breakdown IDs + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const breakdownIds = initialCheck.balance?.breakdown?.map((b) => ({ + id: b.id!, + grantedBalance: b.granted_balance!, + })) ?? []; + + expect(breakdownIds).toHaveLength(3); + const balances = breakdownIds.map((b) => b.grantedBalance).sort((a, b) => a - b); + expect(balances).toEqual([50, 100, 200]); + + // Update 1: specific breakdown (100 → 75 current) + // NEW: granted stays 100, current becomes 75, usage becomes 25 + const targetBreakdown100 = breakdownIds.find((b) => b.grantedBalance === 100); + expect(targetBreakdown100).toBeDefined(); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 75, + customer_entitlement_id: targetBreakdown100!.id, + }); + + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 350, current = 325 (75 + 50 + 200) + expect(check1.balance).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 325, + usage: 25, // 350 - 325 + }); + + const updatedBreakdown1 = check1.balance?.breakdown?.find((b) => b.id === targetBreakdown100!.id); + expect(updatedBreakdown1).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 75, + usage: 25, // 100 - 75 + }); + + // Other breakdowns unchanged + const otherBreakdowns1 = check1.balance?.breakdown?.filter((b) => b.id !== targetBreakdown100!.id) ?? []; + const otherBalances = otherBreakdowns1.map((b) => b.granted_balance).sort((a, b) => (a ?? 0) - (b ?? 0)); + expect(otherBalances).toEqual([50, 200]); + + // Update 2: lifetime breakdown (200 → 150 current) + // NEW: granted stays 200, current becomes 150, usage becomes 50 + const targetBreakdown200 = breakdownIds.find((b) => b.grantedBalance === 200); + expect(targetBreakdown200).toBeDefined(); + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + customer_entitlement_id: targetBreakdown200!.id, + }); + + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 350, current = 275 (75 + 50 + 150) + expect(check2.balance).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 275, + usage: 75, // 350 - 275 (25 from first + 50 from second update) + }); + + const updatedBreakdown2 = check2.balance?.breakdown?.find((b) => b.id === targetBreakdown200!.id); + expect(updatedBreakdown2).toMatchObject({ + granted_balance: 200, // Unchanged + current_balance: 150, + usage: 50, // 200 - 150 + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, + current_balance: 275, + }); +}); + +// ============================================================================= +// Test: update-balance-breakdown4 - update after track spans breakdowns +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-breakdown4: update after track spans breakdowns")}`, async () => { + const prodA = products.base({ id: "prod-a", items: [items.monthlyMessages({ includedUsage: 100 })] }); + const prodB = products.base({ id: "prod-b", isAddOn: true, items: [items.monthlyMessages({ includedUsage: 50 })] }); + const prodC = products.base({ id: "prod-c", isAddOn: true, items: [items.lifetimeMessages({ includedUsage: 200 })] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-breakdown4", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [prodA, prodB, prodC] }), + ], + actions: [ + s.attach({ productId: prodA.id }), + s.attach({ productId: prodB.id }), + s.attach({ productId: prodC.id }), + ], + }); + + // Track 120: depletes across multiple breakdowns + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 350, + current_balance: 230, + usage: 120, + }); + + // Verify breakdown state after tracking + const checkAfterTrack = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + const breakdownSum = checkAfterTrack.balance?.breakdown?.reduce( + (sum, b) => sum + (b.current_balance ?? 0), + 0, + ) ?? 0; + expect(breakdownSum).toBe(230); + const usageSum = checkAfterTrack.balance?.breakdown?.reduce( + (sum, b) => sum + (b.usage ?? 0), + 0, + ) ?? 0; + expect(usageSum).toBe(120); + + // Update 1: current_balance to 150 after tracking + // NEW: granted stays 350, usage = 350 - 150 = 200 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 150, + usage: 200, // 350 - 150 + purchased_balance: 0, + }); + + // Verify breakdown state + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + const breakdownSum1 = check1.balance?.breakdown?.reduce( + (sum, b) => sum + (b.current_balance ?? 0), + 0, + ) ?? 0; + expect(breakdownSum1).toBe(150); + + // Update 2: current_balance to 300 (increase after tracking) + // NEW: granted stays 350, usage = 350 - 300 = 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 300, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 300, + usage: 50, // 350 - 300 + purchased_balance: 0, + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, + current_balance: 300, + usage: 50, + }); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-combined.test.ts b/server/tests/integration/balances/update/balance/update-balance-combined.test.ts new file mode 100644 index 000000000..15e49c91c --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-combined.test.ts @@ -0,0 +1,290 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-COMBINED1: current_balance + granted_balance together +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-combined1: current_balance + granted_balance together")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-combined1", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Track 30 usage first + const trackRes = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + expect(trackRes.balance).toMatchObject({ + granted_balance: 100, + current_balance: 70, + usage: 30, + }); + + // Update current_balance: 50 and granted_balance: 100 + // When BOTH are passed, granted_balance IS updated + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + granted_balance: 100, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 50, + usage: 50, // 100 - 50 + purchased_balance: 0, + }); + + // Update current_balance: 80 and granted_balance: 150 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 80, + granted_balance: 150, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 80, + usage: 70, // 150 - 80 + purchased_balance: 0, + }); + + // Update to reset usage: current_balance: 100, granted_balance: 100 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 100, + granted_balance: 100, + }); + + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + purchased_balance: 0, + }); + + // Verify DB sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-COMBINED2: current_balance + next_reset_at together +// NOTE: When only current_balance is passed (not granted_balance), +// granted_balance does NOT change - only usage changes +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-combined2: current_balance + next_reset_at together")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-combined2", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Get original reset time and customer_entitlement_id + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const originalResetAt = initialCheck.balance?.reset?.resets_at ?? 0; + const cusEntId = initialCheck.balance?.breakdown?.[0]?.id ?? ""; + + expect(originalResetAt).toBeGreaterThan(Date.now()); + expect(cusEntId).toBeTruthy(); + + // Update current_balance and next_reset_at together + // NOTE: granted_balance stays at 100, only usage changes + const newResetAt1 = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + next_reset_at: newResetAt1, + customer_entitlement_id: cusEntId, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged - only current_balance was passed + current_balance: 50, + usage: 50, // 100 - 50 + }); + + // Verify reset time was updated + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(check1.balance?.reset?.resets_at).toBeCloseTo(newResetAt1, -3); + + // Update current_balance and push next_reset_at to 30 days + const newResetAt2 = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 200, + next_reset_at: newResetAt2, + customer_entitlement_id: cusEntId, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Still unchanged + current_balance: 200, + usage: -100, // 100 - 200 = -100 (credit) + }); + + // Verify reset time + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(check2.balance?.reset?.resets_at).toBeCloseTo(newResetAt2, -3); + + // Verify DB sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 200, + usage: -100, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-COMBINED3: current_balance + granted_balance + next_reset_at all together +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-combined3: current_balance + granted_balance + next_reset_at")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-combined3", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Get customer_entitlement_id + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + const cusEntId = initialCheck.balance?.breakdown?.[0]?.id ?? ""; + + // Track 30 usage first + await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + const afterTrack = await autumnV2.customers.get(customerId); + expect(afterTrack.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 70, + usage: 30, + }); + + // Update all three values at once + const newResetAt1 = Date.now() + 14 * 24 * 60 * 60 * 1000; // 14 days + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 80, + granted_balance: 150, + next_reset_at: newResetAt1, + customer_entitlement_id: cusEntId, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 80, + usage: 70, // 150 - 80 + purchased_balance: 0, + }); + + // Verify reset time + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(check1.balance?.reset?.resets_at).toBeCloseTo(newResetAt1, -3); + + // Update all values to reset state + const newResetAt2 = Date.now() + 30 * 24 * 60 * 60 * 1000; // 30 days + + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 200, + granted_balance: 200, + next_reset_at: newResetAt2, + customer_entitlement_id: cusEntId, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 200, + current_balance: 200, + usage: 0, + purchased_balance: 0, + }); + + // Verify reset time + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + expect(check2.balance?.reset?.resets_at).toBeCloseTo(newResetAt2, -3); + + // Verify DB sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 200, + current_balance: 200, + usage: 0, + }); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-entity-product.test.ts b/server/tests/integration/balances/update/balance/update-balance-entity-product.test.ts new file mode 100644 index 000000000..f61a72212 --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-entity-product.test.ts @@ -0,0 +1,325 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, ApiEntityV1 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Tests for updating entity balance when products are attached to entities directly. + * These tests have products attached to entities (not via entityFeatureId on customer product). + * + * NEW BEHAVIOR: granted_balance does NOT change when only current_balance is passed. + * Instead, usage = granted_balance - current_balance. + */ + +// ============================================================================= +// Test: update-balance-entity-product1 - entity products +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-entity-product1: entity products")}`, async () => { + const entityProd = products.base({ + id: "entity-prod", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-entity-prod1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [entityProd] }), + s.entities({ count: 3, featureId: TestFeature.Users }), + ], + actions: [ + // Attach product to each entity (not to customer) + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + s.attach({ productId: entityProd.id, entityIndex: 2 }), + ], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Initial: customer has 300 (100 per entity × 3), each entity 100 + const customer0 = await autumnV2.customers.get(customerId); + expect(customer0.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + }); + + for (const entity of entities) { + const fetchedEntity = (await autumnV2.entities.get( + customerId, + entity.id, + )) as ApiEntityV1; + expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); + } + + // Update 1: first entity balance from 100 to 80 + // NEW: granted stays 100, current 80, usage 20 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 80, + }); + + const entity1After1 = await autumnV2.entities.get( + customerId, + entities[0].id, + ); + expect(entity1After1.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 80, + usage: 20, // 100 - 80 + }); + + // Other entities unchanged + const entity2After1 = await autumnV2.entities.get( + customerId, + entities[1].id, + ); + expect(entity2After1.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); + + // Customer balance: granted 300, current 280 (80 + 100 + 100) + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 280, + usage: 20, // 300 - 280 + }); + + // Update 2: second entity balance from 100 to 150 (increase) + // NEW: granted stays 100, current 150, usage -50 (credit) + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + current_balance: 150, + }); + + const entity2After2 = await autumnV2.entities.get( + customerId, + entities[1].id, + ); + expect(entity2After2.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 150, + usage: -50, // 100 - 150 = -50 (credit) + }); + + // Customer balance: granted 300, current 330 (80 + 150 + 100) + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 330, + usage: -30, // 20 - 50 = -30 + }); + + // Update 3: customer level update from 330 to 165 (sequential deduction) + // NEW: granted stays 300, usage becomes 135 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 165, + }); + + // Customer should have 165 + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 165, + usage: 135, // 300 - 165 + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 165, + usage: 135, + }); +}); + +// ============================================================================= +// Test: update-balance-entity-product2 - mixed customer and entity products +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-entity-product2: mixed customer and entity products")}`, async () => { + const customerProd = products.base({ + id: "customer-prod", + items: [items.monthlyMessages({ includedUsage: 50 })], + }); + const entityProd = products.base({ + id: "entity-prod", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-entity-prod2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [customerProd, entityProd] }), + s.entities({ count: 3, featureId: TestFeature.Users }), + ], + actions: [ + // Attach customer product + s.attach({ productId: customerProd.id }), + // Attach entity product to each entity + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + s.attach({ productId: entityProd.id, entityIndex: 2 }), + ], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Initial: customer has 350 (50 customer + 100×3 entity = 350) + // Each entity sees 150 (50 customer-level + 100 entity-level) + const customer0 = await autumnV2.customers.get(customerId); + expect(customer0.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, + current_balance: 350, + usage: 0, + }); + + for (const entity of entities) { + const fetchedEntity = (await autumnV2.entities.get( + customerId, + entity.id, + )) as ApiEntityV1; + expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // 50 + 100 + current_balance: 150, + usage: 0, + }); + } + + // Update 1: first entity balance from 150 to 100 + // NEW: granted stays 150, current 100, usage 50 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 100, + }); + + const entity1After1 = await autumnV2.entities.get( + customerId, + entities[0].id, + ); + expect(entity1After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 100, + usage: 50, // 150 - 100 + }); + + // Customer balance: granted 350, current 300 (350 - 50) + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 300, + usage: 50, // 350 - 300 + }); + + // Update 2: second entity balance from 150 to 200 (increase) + // NEW: granted stays 150, current 200, usage -50 (credit) + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + current_balance: 200, + }); + + const entity2After2 = await autumnV2.entities.get( + customerId, + entities[1].id, + ); + expect(entity2After2.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 200, + usage: -50, // 150 - 200 = -50 (credit) + }); + + // Customer balance: granted 350, current 350 (300 + 50) + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 350, + usage: 0, // 50 - 50 = 0 + }); + + // Update 3: customer balance from 350 to 175 (sequential deduction) + // NEW: granted stays 350, usage becomes 175 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 175, + }); + + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, // Unchanged + current_balance: 175, + usage: 175, // 350 - 175 + }); + + // Track on entity 2, then update customer balance + await autumnV2.track({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + value: 30, + }); + + const entity2AfterTrack = (await autumnV2.entities.get( + customerId, + entities[1].id, + )) as ApiEntityV1; + // After track: current decreased by 30 + const entity2BalanceAfterTrack = + entity2AfterTrack.balances?.[TestFeature.Messages]; + expect(entity2BalanceAfterTrack?.current_balance).toBeLessThan( + entity2AfterTrack.balances?.[TestFeature.Messages]?.granted_balance ?? 0, + ); + + // Customer should have decreased by 30 + const customerAfterTrack = + await autumnV2.customers.get(customerId); + expect(customerAfterTrack.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, + current_balance: 145, // 175 - 30 + usage: 205, // 175 + 30 = 205 + }); + + await timeout(6000); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 350, + current_balance: 145, + usage: 205, + }); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-per-entity.test.ts b/server/tests/integration/balances/update/balance/update-balance-per-entity.test.ts new file mode 100644 index 000000000..ed8c8fe03 --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-per-entity.test.ts @@ -0,0 +1,682 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, ApiEntityV1, CheckResponseV2 } from "@autumn/shared"; +import { ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { timeout } from "@/utils/genUtils"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; + +/** + * Tests for updating per-entity balance items (where entityFeatureId is set). + * These tests have customer-level products with items allocated per entity. + * + * NEW BEHAVIOR: granted_balance does NOT change when only current_balance is passed. + * Instead, usage = granted_balance - current_balance. + */ + +// ============================================================================= +// Test: update-balance-per-entity1 - customer level update on per-entity balance +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-per-entity1: customer level update on per-entity balance")}`, async () => { + const messagesItem = items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-per-entity1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 3, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Initial: customer has 300 (100 per entity × 3) + const customer0 = await autumnV2.customers.get(customerId); + expect(customer0.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + purchased_balance: 0, + }); + + // Each entity has 100 + for (const entity of entities) { + const fetchedEntity = (await autumnV2.entities.get( + customerId, + entity.id, + )) as ApiEntityV1; + expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + purchased_balance: 0, + }); + } + + // Update 1: customer balance from 300 to 240 (sequential deduction) + // NEW: granted stays 300, usage becomes 60 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 240, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 240, + usage: 60, // 300 - 240 + purchased_balance: 0, + }); + + // Sequential deduction: 60 deducted from first entity + // Entity 1: granted 100, current 40, usage 60 + const entity1After1 = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 40, + usage: 60, // 100 - 40 + purchased_balance: 0, + }); + + // Entity 2 & 3 unchanged + const entity2After1 = (await autumnV2.entities.get( + customerId, + entities[1].id, + )) as ApiEntityV1; + expect(entity2After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + purchased_balance: 0, + }); + + const entity3After1 = (await autumnV2.entities.get( + customerId, + entities[2].id, + )) as ApiEntityV1; + expect(entity3After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + purchased_balance: 0, + }); + + // Update 2: customer balance from 240 to 150 (sequential deduction from 40, 100, 100) + // Deduct 90 more: first entity 40→0 (40 deducted), second entity 100→50 (50 deducted) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 150, + usage: 150, // 300 - 150 + }); + + // Entity 1: 0 + const entity1After2 = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1After2.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 0, + usage: 100, // 100 - 0 + }); + + // Entity 2: 50 + const entity2After2 = (await autumnV2.entities.get( + customerId, + entities[1].id, + )) as ApiEntityV1; + expect(entity2After2.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, + usage: 50, // 100 - 50 + }); + + // Entity 3: unchanged + const entity3After2 = (await autumnV2.entities.get( + customerId, + entities[2].id, + )) as ApiEntityV1; + expect(entity3After2.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); + + // Update 3: increase customer balance from 150 to 280 (sequential addition) + // NEW: granted stays 300, usage becomes 20 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 280, + }); + + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 280, + usage: 20, // 300 - 280 + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 280, + usage: 20, + purchased_balance: 0, + }); +}); + +// ============================================================================= +// Test: update-balance-per-entity2 - update specific entity balance +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-per-entity2: update specific entity balance")}`, async () => { + const messagesItem = items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-per-entity2", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 3, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Initial: 300 total, each entity 100 + const customer0 = await autumnV2.customers.get(customerId); + expect(customer0.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + }); + + // Update 1: first entity balance from 100 to 70 + // NEW: granted stays 100, current 70, usage 30 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 70, + }); + + const entity1After1 = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 70, + usage: 30, // 100 - 70 + }); + + // Other entities unchanged + const entity2After1 = (await autumnV2.entities.get( + customerId, + entities[1].id, + )) as ApiEntityV1; + const entity3After1 = (await autumnV2.entities.get( + customerId, + entities[2].id, + )) as ApiEntityV1; + expect(entity2After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); + expect(entity3After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); + + // Customer balance: granted 300, current 270 (70 + 100 + 100) + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 270, + usage: 30, // 300 - 270 + }); + + // Update 2: second entity balance from 100 to 120 (increase) + // NEW: granted stays 100, current 120, usage -20 (credit) + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + current_balance: 120, + }); + + const entity2After2 = (await autumnV2.entities.get( + customerId, + entities[1].id, + )) as ApiEntityV1; + expect(entity2After2.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 120, + usage: -20, // 100 - 120 = -20 (credit) + }); + + // Customer balance: granted 300, current 290 (70 + 120 + 100) + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 290, + usage: 10, // 30 from E1 - 20 from E2 = 10 + }); + + // Update 3: third entity balance from 100 to 50 + // NEW: granted stays 100, current 50, usage 50 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[2].id, + feature_id: TestFeature.Messages, + current_balance: 50, + }); + + const entity3After3 = (await autumnV2.entities.get( + customerId, + entities[2].id, + )) as ApiEntityV1; + expect(entity3After3.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, + usage: 50, // 100 - 50 + }); + + // Customer balance: granted 300, current 240 (70 + 120 + 50) + const customer3 = await autumnV2.customers.get(customerId); + expect(customer3.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 240, + usage: 60, // 30 - 20 + 50 = 60 + }); + + // Track usage on entity 1 to verify behavior + await autumnV2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 20, + }); + + const entity1AfterTrack = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1AfterTrack.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 50, // 70 - 20 + usage: 50, // 100 - 50 + }); + + const customerAfterTrack = + await autumnV2.customers.get(customerId); + expect(customerAfterTrack.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 220, // 50 + 120 + 50 + usage: 80, // 300 - 220 + }); + + await timeout(4000); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 220, + usage: 80, + }); +}); + +// ============================================================================= +// Test: update-balance-per-entity3 - entity with multiple intervals (breakdown) +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-per-entity3: entity with multiple intervals (breakdown)")}`, async () => { + const monthlyItem = items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + // Use constructFeatureItem for lifetime with entityFeatureId since items.lifetimeMessages doesn't support it + const lifetimeItemWithEntity = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 50, + interval: null, + entityFeatureId: TestFeature.Users, + }); + + const freeProd = products.base({ + id: "free", + items: [monthlyItem, lifetimeItemWithEntity], + }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-per-entity3", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Initial: customer has 300 (150 per entity × 2 = 300) + const customer0 = await autumnV2.customers.get(customerId); + expect(customer0.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + }); + + // Each entity has 150 (100 monthly + 50 lifetime) + for (const entity of entities) { + const fetchedEntity = (await autumnV2.entities.get( + customerId, + entity.id, + )) as ApiEntityV1; + expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 150, + usage: 0, + }); + } + + // Check breakdown for entity shows 2 items + const checkEntity0 = await autumnV2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + }); + expect(checkEntity0.balance?.breakdown).toHaveLength(2); + + const monthlyBreakdown0 = checkEntity0.balance?.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.Month, + ); + const lifetimeBreakdown0 = checkEntity0.balance?.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.OneOff, + ); + expect(monthlyBreakdown0).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + }); + expect(lifetimeBreakdown0).toMatchObject({ + granted_balance: 50, + current_balance: 50, + usage: 0, + }); + + // Update 1: first entity balance from 150 to 120 + // NEW: granted stays 150, current 120, usage 30 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 120, + }); + + const entity1After1 = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 120, + usage: 30, // 150 - 120 + }); + + // Check breakdown is proportionally updated + const checkEntity1 = await autumnV2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + }); + + const monthlyBreakdown1 = checkEntity1.balance?.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.Month, + ); + const lifetimeBreakdown1 = checkEntity1.balance?.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.OneOff, + ); + + // Deduction of 30 is sequential from first breakdown (monthly) + expect(monthlyBreakdown1).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 70, + usage: 30, // 100 - 70 + }); + expect(lifetimeBreakdown1).toMatchObject({ + granted_balance: 50, + current_balance: 50, + usage: 0, + }); + + // Customer balance: granted 300, current 270 (120 + 150) + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 270, + usage: 30, // 300 - 270 + }); + + // Track 60 on entity 1 (currently has 120) + await autumnV2.track({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + value: 60, + }); + + const entity1AfterTrack = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1AfterTrack.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 60, // 120 - 60 + usage: 90, // 150 - 60 + }); + + // Check breakdown - should deduct from monthly first + const checkEntity2 = await autumnV2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + }); + + const monthlyBreakdown2 = checkEntity2.balance?.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.Month, + ); + const lifetimeBreakdown2 = checkEntity2.balance?.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.OneOff, + ); + + // Monthly was 70, track 60 → 10 + expect(monthlyBreakdown2).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 10, // 70 - 60 + usage: 90, // 100 - 10 + }); + expect(lifetimeBreakdown2).toMatchObject({ + granted_balance: 50, + current_balance: 50, + usage: 0, + }); + + // Update 2: entity balance after usage to 180 + // NEW: granted stays 150, current 180, usage -30 (credit) + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 180, + }); + + const entity1After2 = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1After2.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 180, + usage: -30, // 150 - 180 = -30 (credit) + }); + + // Customer balance: granted 300, current 330 (180 + 150) + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 330, + usage: -30, // 300 - 330 = -30 (credit) + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 330, + usage: -30, + }); +}); + +// ============================================================================= +// Test: update-balance-per-entity4 - arrear entity items (overage allowed) +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-balance-per-entity4: arrear entity items (overage allowed)")}`, async () => { + const arrearItem = items.consumableMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + price: 0.1, + }); + const freeProd = products.base({ id: "free", items: [arrearItem] }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-per-entity4", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Initial: customer has 200 (100 per entity × 2) + const customer0 = await autumnV2.customers.get(customerId); + expect(customer0.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 200, + current_balance: 200, + usage: 0, + }); + + // Check overage_allowed=true + const checkEntity0 = await autumnV2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + }); + expect(checkEntity0.balance?.overage_allowed).toBe(true); + + // Update 1: first entity balance to negative (-50) + // Arrear items: current floors at 0, overage goes to purchased_balance + // NEW: granted stays 100, current 0, purchased 50, usage 150 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: -50, + }); + + const entity1After1 = (await autumnV2.entities.get( + customerId, + entities[0].id, + )) as ApiEntityV1; + expect(entity1After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged + current_balance: 0, // Floored at 0 + purchased_balance: 50, // Overage absorbed + usage: 150, // 100 + 50 = 150 + }); + + // Entity 2 unchanged + const entity2After1 = (await autumnV2.entities.get( + customerId, + entities[1].id, + )) as ApiEntityV1; + expect(entity2After1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + purchased_balance: 0, + usage: 0, + }); + + // Customer balance: granted 200, current 100 (0 + 100), purchased 50 + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 200, // Unchanged + current_balance: 100, + purchased_balance: 50, + usage: 150, // 200 - 100 + 50 purchased + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 200, + current_balance: 100, + purchased_balance: 50, + }); +}); diff --git a/server/tests/integration/balances/update/balance/update-balance-with-filters.test.ts b/server/tests/integration/balances/update/balance/update-balance-with-filters.test.ts new file mode 100644 index 000000000..a0caacac8 --- /dev/null +++ b/server/tests/integration/balances/update/balance/update-balance-with-filters.test.ts @@ -0,0 +1,823 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, ApiEntityV1, CheckResponseV2 } from "@autumn/shared"; +import { ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BALANCE-FILTERS1: Filter by customer_entitlement_id with 3 monthly products +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-balance-filters1: filter by customer_entitlement_id, update balance without changing included")}`, async () => { + const messagesItemA = items.monthlyMessages({ includedUsage: 100 }); + const messagesItemB = items.monthlyMessages({ includedUsage: 150 }); + const messagesItemC = items.monthlyMessages({ includedUsage: 200 }); + + const productA = products.base({ id: "prod-a", items: [messagesItemA] }); + const productB = products.base({ id: "prod-b", items: [messagesItemB], isAddOn: true }); + const productC = products.base({ id: "prod-c", items: [messagesItemC], isAddOn: true }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-filters1", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [productA, productB, productC] }), + ], + actions: [ + s.attach({ productId: productA.id }), + s.attach({ productId: productB.id }), + s.attach({ productId: productC.id }), + ], + }); + + // Initial state: customer has 450 with 3 breakdown items (100 + 150 + 200) + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 450, + current_balance: 450, + usage: 0, + }); + + // Get breakdown IDs + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const breakdownIds = initialCheck.balance?.breakdown?.map((b) => ({ + id: b.id!, + grantedBalance: b.granted_balance!, + })) ?? []; + + expect(breakdownIds).toHaveLength(3); + + const balances = breakdownIds.map((b) => b.grantedBalance).sort((a, b) => a - b); + expect(balances).toEqual([100, 150, 200]); + + // All IDs should be unique + const uniqueIds = new Set(breakdownIds.map((b) => b.id)); + expect(uniqueIds.size).toBe(3); + + // TEST 1: Update first breakdown (granted: 100) - set current_balance to 80 + // Expected: usage = 20, granted_balance stays at 100 + const breakdown100 = breakdownIds.find((b) => b.grantedBalance === 100)!; + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 80, + customer_entitlement_id: breakdown100.id, + }); + + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 450, current = 430 (100-20 + 150 + 200), usage = 20 + expect(check1.balance).toMatchObject({ + granted_balance: 450, // Unchanged + current_balance: 430, + usage: 20, + }); + + // Verify the specific breakdown: granted stays 100, current = 80, usage = 20 + const updatedBreakdown1 = check1.balance?.breakdown?.find((b) => b.id === breakdown100.id); + expect(updatedBreakdown1?.granted_balance).toBe(100); // Unchanged + expect(updatedBreakdown1?.current_balance).toBe(80); + expect(updatedBreakdown1?.usage).toBe(20); + + // Other breakdowns should be unchanged + const otherBreakdowns1 = check1.balance?.breakdown?.filter((b) => b.id !== breakdown100.id) ?? []; + const otherGranted1 = otherBreakdowns1.map((b) => b.granted_balance).sort((a, b) => (a ?? 0) - (b ?? 0)); + expect(otherGranted1).toEqual([150, 200]); + + // TEST 2: Update second breakdown (granted: 150) - set current_balance to 200 + // This gives a NEGATIVE usage of -50 (customer gets credit) + const breakdown150 = breakdownIds.find((b) => b.grantedBalance === 150)!; + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 200, + customer_entitlement_id: breakdown150.id, + }); + + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 450, current = 480 (80 + 200 + 200), usage = -30 (20 - 50) + expect(check2.balance).toMatchObject({ + granted_balance: 450, // Unchanged + current_balance: 480, + usage: -30, // 20 from breakdown100 + (-50) from breakdown150 + }); + + // Verify the specific breakdown: granted stays 150, current = 200, usage = -50 + const updatedBreakdown2 = check2.balance?.breakdown?.find((b) => b.id === breakdown150.id); + expect(updatedBreakdown2?.granted_balance).toBe(150); // Unchanged + expect(updatedBreakdown2?.current_balance).toBe(200); + expect(updatedBreakdown2?.usage).toBe(-50); + + // TEST 3: Update third breakdown (granted: 200) - set current_balance to 50 + const breakdown200 = breakdownIds.find((b) => b.grantedBalance === 200)!; + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + customer_entitlement_id: breakdown200.id, + }); + + const check3 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 450, current = 330 (80 + 200 + 50), usage = 120 (20 + (-50) + 150) + expect(check3.balance).toMatchObject({ + granted_balance: 450, // Unchanged + current_balance: 330, + usage: 120, + }); + + // Verify the specific breakdown: granted stays 200, current = 50, usage = 150 + const updatedBreakdown3 = check3.balance?.breakdown?.find((b) => b.id === breakdown200.id); + expect(updatedBreakdown3?.granted_balance).toBe(200); // Unchanged + expect(updatedBreakdown3?.current_balance).toBe(50); + expect(updatedBreakdown3?.usage).toBe(150); + + // Final verification: database state matches cache + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 450, // Unchanged from start + current_balance: 330, + usage: 120, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BALANCE-FILTERS2: Filter with free + prepaid + arrear items +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-balance-filters2: filter by cusEntId with free + prepaid + pay-per-use")}`, async () => { + const freeMessagesItem = items.monthlyMessages({ includedUsage: 100 }); + const prepaidMessagesItem = items.prepaidMessages({ includedUsage: 0, price: 9, billingUnits: 100 }); + const arrearMessagesItem = items.consumableMessages({ includedUsage: 200, price: 0.1 }); + + const freeProd = products.base({ id: "free-prod", items: [freeMessagesItem] }); + const prepaidProd = products.base({ id: "prepaid-prod", items: [prepaidMessagesItem], isAddOn: true }); + const arrearProd = products.base({ id: "arrear-prod", items: [arrearMessagesItem], isAddOn: true }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-filters2", + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [freeProd, prepaidProd, arrearProd] }), + ], + actions: [ + s.attach({ productId: freeProd.id }), + s.attach({ productId: prepaidProd.id, options: [{ feature_id: TestFeature.Messages, quantity: 100 }] }), + s.attach({ productId: arrearProd.id }), + ], + }); + + // Initial state: + // - Free: granted_balance = 100 + // - Prepaid: purchased_balance = 100 (quantity 100, billing units 100) + // - Arrear: granted_balance = 200 + // Total: granted = 300, purchased = 100, current = 400 + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 400, + purchased_balance: 100, + usage: 0, + }); + + // Get breakdown info + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const breakdowns = initialCheck.balance?.breakdown?.map((b) => ({ + id: b.id!, + planId: b.plan_id!, + grantedBalance: b.granted_balance!, + currentBalance: b.current_balance!, + overageAllowed: b.overage_allowed!, + })) ?? []; + + expect(breakdowns).toHaveLength(3); + + // Find breakdowns by plan_id + const freeBreakdown = breakdowns.find((b) => b.planId === freeProd.id)!; + const prepaidBreakdown = breakdowns.find((b) => b.planId === prepaidProd.id)!; + const arrearBreakdown = breakdowns.find((b) => b.planId === arrearProd.id)!; + + // TEST 1: Update free breakdown (granted: 100) - set current_balance to 75 + // Expected: usage = 25, granted_balance stays at 100 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 75, + customer_entitlement_id: freeBreakdown.id, + }); + + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, purchased stays 100, current = 375, usage = 25 + expect(check1.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 375, + purchased_balance: 100, // Unchanged + usage: 25, + }); + + const updatedFreeBreakdown = check1.balance?.breakdown?.find((b) => b.id === freeBreakdown.id); + expect(updatedFreeBreakdown?.granted_balance).toBe(100); // Unchanged + expect(updatedFreeBreakdown?.current_balance).toBe(75); + expect(updatedFreeBreakdown?.usage).toBe(25); + + // TEST 2: Update prepaid breakdown - set current_balance to 150 + // Expected: usage = -50 (credit), purchased_balance stays at 100 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + customer_entitlement_id: prepaidBreakdown.id, + }); + + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const updatedPrepaidBreakdown = check2.balance?.breakdown?.find((b) => b.id === prepaidBreakdown.id); + expect(updatedPrepaidBreakdown?.current_balance).toBe(150); + + // TEST 3: Update arrear breakdown - set current_balance to 150 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + customer_entitlement_id: arrearBreakdown.id, + }); + + const check3 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const updatedArrearBreakdown = check3.balance?.breakdown?.find((b) => b.id === arrearBreakdown.id); + expect(updatedArrearBreakdown?.granted_balance).toBe(200); // Unchanged + expect(updatedArrearBreakdown?.current_balance).toBe(150); + expect(updatedArrearBreakdown?.overage_allowed).toBe(true); + + // TEST 4: Update arrear to negative (-50) - overage goes to purchased_balance + // When current_balance would go negative, it stays at 0 and overage becomes purchased_balance + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: -50, + customer_entitlement_id: arrearBreakdown.id, + }); + + const check4 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + const negativeArrearBreakdown = check4.balance?.breakdown?.find((b) => b.id === arrearBreakdown.id); + expect(negativeArrearBreakdown).toMatchObject({ + granted_balance: 200, // Unchanged + current_balance: 0, // Stays at 0, doesn't go negative + purchased_balance: 50, // Overage goes here + usage: 250, // 200 granted + 50 purchased = 250 usage + }); + + // Final verification + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + const customerFromCache = await autumnV2.customers.get(customerId); + + expect(customerFromDb.balances[TestFeature.Messages].current_balance).toBe( + customerFromCache.balances[TestFeature.Messages].current_balance, + ); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BALANCE-FILTERS3: Entity products and per-entity balances +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-balance-filters3: entity products and per-entity balance filter")}`, async () => { + const entityProductMessages = items.monthlyMessages({ includedUsage: 100 }); + const perEntityMessages = items.monthlyMessages({ includedUsage: 50, entityFeatureId: TestFeature.Users }); + + const entityProd = products.base({ id: "entity-prod", items: [entityProductMessages] }); + const perEntityProd = products.base({ id: "per-entity-prod", items: [perEntityMessages] }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-balance-filters3", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [entityProd, perEntityProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + // Attach entity product to each entity + s.attach({ productId: entityProd.id, entityIndex: 0 }), + s.attach({ productId: entityProd.id, entityIndex: 1 }), + // Attach per-entity product to customer + s.attach({ productId: perEntityProd.id }), + ], + }); + + // Initial: each entity has 150 (100 entity prod + 50 per-entity) + for (const entity of entities) { + const fetchedEntity = await autumnV2.entities.get(customerId, entity.id); + expect(fetchedEntity.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 150, + usage: 0, + }); + } + + // Customer total: 2 * (100 + 50) = 300 + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + }); + + // Get breakdown IDs for entity 0 + const entity0Check = await autumnV2.check({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + }); + + const entityProdBreakdown0 = entity0Check.balance?.breakdown?.find((b) => b.granted_balance === 100); + const perEntityBreakdown = entity0Check.balance?.breakdown?.find((b) => b.granted_balance === 50); + + // Get entity product breakdown for entity 1 + const entity1Check = await autumnV2.check({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + }); + + const entityProdBreakdown1 = entity1Check.balance?.breakdown?.find((b) => b.granted_balance === 100); + + // Entity products have unique cusEntIds per entity + expect(entityProdBreakdown0?.id).not.toBe(entityProdBreakdown1?.id); + + // TEST 1: Update entity product for entity 0 (100 → 75) + // Expected: usage = 25, granted_balance stays at 100 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 75, + customer_entitlement_id: entityProdBreakdown0!.id, + }); + + // Entity 0: granted = 150 (100 + 50), current = 125 (75 + 50), usage = 25 + const entity0 = await autumnV2.entities.get(customerId, entities[0].id); + expect(entity0.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 125, + usage: 25, + }); + + // Entity 1 should be unchanged: granted = 150, current = 150 + const entity1 = await autumnV2.entities.get(customerId, entities[1].id); + expect(entity1.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 150, + usage: 0, + }); + + // Customer total: granted = 300, current = 275, usage = 25 + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 275, + usage: 25, + }); + + // TEST 2: Update entity product for entity 1 (100 → 120) + // Expected: usage = -20 (credit), granted_balance stays at 100 + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[1].id, + feature_id: TestFeature.Messages, + current_balance: 120, + customer_entitlement_id: entityProdBreakdown1!.id, + }); + + // Entity 1: granted = 150, current = 170 (120 + 50), usage = -20 + const entity1Updated = await autumnV2.entities.get(customerId, entities[1].id); + expect(entity1Updated.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 170, + usage: -20, + }); + + // Customer total: granted = 300, current = 295, usage = 5 (25 - 20) + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 295, + usage: 5, + }); + + // TEST 3: Update per-entity balance for entity 0 (50 → 30) + await autumnV2.balances.update({ + customer_id: customerId, + entity_id: entities[0].id, + feature_id: TestFeature.Messages, + current_balance: 30, + customer_entitlement_id: perEntityBreakdown!.id, + }); + + // Entity 0: granted = 150, current = 105 (75 + 30), usage = 45 (25 + 20) + const entity0Updated = await autumnV2.entities.get(customerId, entities[0].id); + expect(entity0Updated.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Unchanged + current_balance: 105, + usage: 45, + }); + + // Entity 1 should still have its per-entity balance of 50 + const entity1Final = await autumnV2.entities.get(customerId, entities[1].id); + expect(entity1Final.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 170, + usage: -20, + }); + + // Final verification + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customerFromDb.balances[TestFeature.Messages].granted_balance).toBe(300); // Unchanged +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BALANCE-FILTERS4: Filter by interval (monthly vs lifetime) +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-balance-filters4: filter by interval (monthly vs lifetime)")}`, async () => { + const monthlyMessages = items.monthlyMessages({ includedUsage: 100 }); + const lifetimeMessages = items.lifetimeMessages({ includedUsage: 200 }); + + const monthlyProd = products.base({ id: "monthly-prod", items: [monthlyMessages] }); + const lifetimeProd = products.base({ id: "lifetime-prod", items: [lifetimeMessages], isAddOn: true }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-filters4", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [monthlyProd, lifetimeProd] }), + ], + actions: [ + s.attach({ productId: monthlyProd.id }), + s.attach({ productId: lifetimeProd.id }), + ], + }); + + // Initial: customer has 300 with monthly (100) and lifetime (200) + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, + current_balance: 300, + usage: 0, + }); + + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + expect(initialCheck.balance?.breakdown).toHaveLength(2); + + const monthlyBreakdown = initialCheck.balance?.breakdown?.find((b) => b.reset?.interval === "month"); + const lifetimeBreakdown = initialCheck.balance?.breakdown?.find((b) => b.reset?.interval === "one_off"); + + expect(monthlyBreakdown?.granted_balance).toBe(100); + expect(lifetimeBreakdown?.granted_balance).toBe(200); + + // TEST 1: Update only monthly breakdown (100 → 75) using interval filter + // Expected: granted stays 100, current = 75, usage = 25 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 75, + interval: ResetInterval.Month, + }); + + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, current = 275, usage = 25 + expect(check1.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 275, + usage: 25, + }); + + const updatedMonthly1 = check1.balance?.breakdown?.find((b) => b.reset?.interval === "month"); + expect(updatedMonthly1?.granted_balance).toBe(100); // Unchanged + expect(updatedMonthly1?.current_balance).toBe(75); + + const unchangedLifetime1 = check1.balance?.breakdown?.find((b) => b.reset?.interval === "one_off"); + expect(unchangedLifetime1?.granted_balance).toBe(200); + expect(unchangedLifetime1?.current_balance).toBe(200); + + // TEST 2: Update only lifetime breakdown (200 → 150) using interval filter + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + interval: ResetInterval.OneOff, + }); + + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, current = 225, usage = 75 + expect(check2.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 225, + usage: 75, + }); + + const updatedLifetime2 = check2.balance?.breakdown?.find((b) => b.reset?.interval === "one_off"); + expect(updatedLifetime2?.granted_balance).toBe(200); // Unchanged + expect(updatedLifetime2?.current_balance).toBe(150); + + // TEST 3: Increase monthly breakdown (75 → 125) using interval filter + // Expected: usage becomes negative (-25) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 125, + interval: ResetInterval.Month, + }); + + const check3 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, current = 275, usage = 25 + expect(check3.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 275, + usage: 25, // monthly (-25) + lifetime (50) = 25 + }); + + const updatedMonthly3 = check3.balance?.breakdown?.find((b) => b.reset?.interval === "month"); + expect(updatedMonthly3?.granted_balance).toBe(100); // Unchanged + expect(updatedMonthly3?.current_balance).toBe(125); + + // TEST 4: Increase lifetime breakdown (150 → 300) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 300, + interval: ResetInterval.OneOff, + }); + + const check4 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 300, current = 425, usage = -125 + expect(check4.balance).toMatchObject({ + granted_balance: 300, // Unchanged + current_balance: 425, + usage: -125, // monthly (-25) + lifetime (-100) = -125 + }); + + const updatedLifetime4 = check4.balance?.breakdown?.find((b) => b.reset?.interval === "one_off"); + expect(updatedLifetime4?.granted_balance).toBe(200); // Unchanged + expect(updatedLifetime4?.current_balance).toBe(300); + + // Final verification + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 300, // Unchanged from start + current_balance: 425, + usage: -125, + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// UPDATE-BALANCE-FILTERS5: Interval filter with multiple products per interval +// ═══════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("update-balance-filters5: interval filter with multiple products, sequential deduction")}`, async () => { + const monthlyMessagesA = items.monthlyMessages({ includedUsage: 100 }); + const monthlyMessagesB = items.monthlyMessages({ includedUsage: 150 }); + const lifetimeMessagesC = items.lifetimeMessages({ includedUsage: 200 }); + const lifetimeMessagesD = items.lifetimeMessages({ includedUsage: 50 }); + + const monthlyProdA = products.base({ id: "monthly-prod-a", items: [monthlyMessagesA] }); + const monthlyProdB = products.base({ id: "monthly-prod-b", items: [monthlyMessagesB], isAddOn: true }); + const lifetimeProdC = products.base({ id: "lifetime-prod-c", items: [lifetimeMessagesC], isAddOn: true }); + const lifetimeProdD = products.base({ id: "lifetime-prod-d", items: [lifetimeMessagesD], isAddOn: true }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-balance-filters5", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [monthlyProdA, monthlyProdB, lifetimeProdC, lifetimeProdD] }), + ], + actions: [ + s.attach({ productId: monthlyProdA.id }), + s.attach({ productId: monthlyProdB.id }), + s.attach({ productId: lifetimeProdC.id }), + s.attach({ productId: lifetimeProdD.id }), + ], + }); + + // Initial: customer has 500 with 2 monthly (250) and 2 lifetime (250) + const initialCustomer = await autumnV2.customers.get(customerId); + expect(initialCustomer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 500, + current_balance: 500, + usage: 0, + }); + + const initialCheck = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + expect(initialCheck.balance?.breakdown).toHaveLength(4); + + const monthlyBreakdowns = initialCheck.balance?.breakdown?.filter((b) => b.reset?.interval === "month") ?? []; + const lifetimeBreakdowns = initialCheck.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") ?? []; + + expect(monthlyBreakdowns).toHaveLength(2); + expect(lifetimeBreakdowns).toHaveLength(2); + + const monthlySum = monthlyBreakdowns.reduce((s, b) => s + (b.granted_balance ?? 0), 0); + const lifetimeSum = lifetimeBreakdowns.reduce((s, b) => s + (b.granted_balance ?? 0), 0); + expect(monthlySum).toBe(250); + expect(lifetimeSum).toBe(250); + + // TEST 1: Decrease monthly balance from 250 to 150 (usage = 100) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 150, + interval: ResetInterval.Month, + }); + + const check1 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 500, current = 400, usage = 100 + expect(check1.balance).toMatchObject({ + granted_balance: 500, // Unchanged + current_balance: 400, + usage: 100, + }); + + const monthlySum1 = check1.balance?.breakdown?.filter((b) => b.reset?.interval === "month") + .reduce((s, b) => s + (b.current_balance ?? 0), 0) ?? 0; + expect(monthlySum1).toBe(150); + + // Lifetime should be unchanged + const lifetimeSum1 = check1.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") + .reduce((s, b) => s + (b.current_balance ?? 0), 0) ?? 0; + expect(lifetimeSum1).toBe(250); + + // TEST 2: Decrease monthly balance from 150 to 50 (additional usage = 100) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + interval: ResetInterval.Month, + }); + + const check2 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 500, current = 300, usage = 200 + expect(check2.balance).toMatchObject({ + granted_balance: 500, // Unchanged + current_balance: 300, + usage: 200, + }); + + const monthlySum2 = check2.balance?.breakdown?.filter((b) => b.reset?.interval === "month") + .reduce((s, b) => s + (b.current_balance ?? 0), 0) ?? 0; + expect(monthlySum2).toBe(50); + + // TEST 3: Decrease lifetime balance from 250 to 100 (usage = 150) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 100, + interval: ResetInterval.OneOff, + }); + + const check3 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 500, current = 150, usage = 350 + expect(check3.balance).toMatchObject({ + granted_balance: 500, // Unchanged + current_balance: 150, + usage: 350, + }); + + const lifetimeSum3 = check3.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") + .reduce((s, b) => s + (b.current_balance ?? 0), 0) ?? 0; + expect(lifetimeSum3).toBe(100); + + // TEST 4: Increase monthly balance from 50 to 200 (credit = 150) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 200, + interval: ResetInterval.Month, + }); + + const check4 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 500, current = 300, usage = 200 + expect(check4.balance).toMatchObject({ + granted_balance: 500, // Unchanged + current_balance: 300, + usage: 200, // monthly (50) + lifetime (150) = 200 + }); + + const monthlySum4 = check4.balance?.breakdown?.filter((b) => b.reset?.interval === "month") + .reduce((s, b) => s + (b.current_balance ?? 0), 0) ?? 0; + expect(monthlySum4).toBe(200); + + // TEST 5: Increase lifetime balance from 100 to 350 (credit = 250) + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 350, + interval: ResetInterval.OneOff, + }); + + const check5 = await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }); + + // Total: granted stays 500, current = 550, usage = -50 + expect(check5.balance).toMatchObject({ + granted_balance: 500, // Unchanged + current_balance: 550, + usage: -50, // monthly (50) + lifetime (-100) = -50 + }); + + const lifetimeSum5 = check5.balance?.breakdown?.filter((b) => b.reset?.interval === "one_off") + .reduce((s, b) => s + (b.current_balance ?? 0), 0) ?? 0; + expect(lifetimeSum5).toBe(350); + + // Final verification + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerFromDb = await autumnV2.customers.get(customerId, { skip_cache: "true" }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 500, // Unchanged from start + current_balance: 550, + usage: -50, + }); +}); diff --git a/server/tests/integration/balances/update/included/update-included-basic.test.ts b/server/tests/integration/balances/update/included/update-included-basic.test.ts new file mode 100644 index 000000000..ae0bfe960 --- /dev/null +++ b/server/tests/integration/balances/update/included/update-included-basic.test.ts @@ -0,0 +1,268 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, ApiEntityV1 } from "@autumn/shared"; +import { ResetInterval } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Tests for updating granted_balance (included usage). + * When granted_balance is explicitly passed, it DOES change the included amount. + * This is different from updating only current_balance (which leaves granted_balance unchanged). + */ + +// ============================================================================= +// Test: update-included1 - basic update granted_balance +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-included1: basic update granted_balance")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-included1", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Update granted_balance to 150, current_balance to 100 + // This should result in usage = 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 100, + granted_balance: 150, + }); + + const customer = await autumnV2.customers.get(customerId); + expect(customer.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 100, + usage: 50, + purchased_balance: 0, + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 100, + usage: 50, + purchased_balance: 0, + }); +}); + +// ============================================================================= +// Test: update-included2 - update granted_balance with breakdown (interval filter) +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-included2: update granted_balance with breakdown")}`, async () => { + const monthlyItem = items.monthlyMessages({ includedUsage: 100 }); + const lifetimeItem = items.lifetimeMessages({ includedUsage: 50 }); + const freeProd = products.base({ + id: "free", + items: [monthlyItem, lifetimeItem], + }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-included2", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Update granted_balance to 75 for monthly feature only + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + granted_balance: 75, + interval: ResetInterval.Month, + }); + + const customer = await autumnV2.customers.get(customerId); + const balance = customer.balances[TestFeature.Messages]; + + // Monthly breakdown should be updated + const monthlyBreakdown = balance.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.Month, + ); + expect(monthlyBreakdown).toMatchObject({ + granted_balance: 75, + current_balance: 50, + usage: 25, + purchased_balance: 0, + }); + + // Lifetime breakdown should be unchanged + const lifetimeBreakdown = balance.breakdown?.find( + (b) => b.reset?.interval === ResetInterval.OneOff, + ); + expect(lifetimeBreakdown).toMatchObject({ + granted_balance: 50, + current_balance: 50, + usage: 0, + purchased_balance: 0, + }); + + // Total balance + expect(balance).toMatchObject({ + granted_balance: 125, // 75 + 50 + current_balance: 100, // 50 + 50 + usage: 25, + purchased_balance: 0, + }); +}); + +// ============================================================================= +// Test: update-included3 - update granted_balance on entity balances +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-included3: update granted_balance on entity balances")}`, async () => { + const messagesItem = items.monthlyMessages({ + includedUsage: 100, + entityFeatureId: TestFeature.Users, + }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2, entities } = await initScenario({ + customerId: "update-included3", + setup: [ + s.customer({ testClock: false }), + s.products({ list: [freeProd] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Initialize caches + await autumnV2.customers.get(customerId); + for (const entity of entities) { + await autumnV2.entities.get(customerId, entity.id); + } + + // Update granted_balance to 75 for entity 1 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entities[0].id, + current_balance: 50, + granted_balance: 75, + interval: ResetInterval.Month, + }); + + // Entity 1 should be updated + const entity1 = await autumnV2.entities.get( + customerId, + entities[0].id, + ); + expect(entity1.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 75, + current_balance: 50, + usage: 25, + purchased_balance: 0, + }); + + // Entity 2 should be unchanged + const entity2 = await autumnV2.entities.get( + customerId, + entities[1].id, + ); + expect(entity2.balances?.[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, + current_balance: 100, + usage: 0, + purchased_balance: 0, + }); + + // Update entity 2 granted_balance to 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + entity_id: entities[1].id, + current_balance: 25, + granted_balance: 50, + }); + + // Entity 2 should be updated + const entity2After = await autumnV2.entities.get( + customerId, + entities[1].id, + ); + expect(entity2After.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 50, + current_balance: 25, + usage: 25, + purchased_balance: 0, + }); + + // Entity 1 should still be unchanged from earlier + const entity1After = await autumnV2.entities.get( + customerId, + entities[0].id, + ); + expect(entity1After.balances![TestFeature.Messages]).toMatchObject({ + granted_balance: 75, + current_balance: 50, + usage: 25, + purchased_balance: 0, + }); +}); + +// ============================================================================= +// Test: update-included4 - update current_balance then update granted_balance +// ============================================================================= +test.concurrent(`${chalk.yellowBright("update-included4: update current_balance then granted_balance")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { customerId, autumnV2 } = await initScenario({ + customerId: "update-included4", + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], + }); + + // Step 1: Update only current_balance to 50 + // NEW BEHAVIOR: granted_balance stays 100, usage becomes 50 + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + current_balance: 50, + }); + + const customer1 = await autumnV2.customers.get(customerId); + expect(customer1.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 100, // Unchanged (new behavior) + current_balance: 50, + usage: 50, // 100 - 50 + purchased_balance: 0, + }); + + // Step 2: Now explicitly update granted_balance to 150 + // This should change granted_balance since we're passing it explicitly + await autumnV2.balances.update({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 150, + current_balance: 50, + }); + + const customer2 = await autumnV2.customers.get(customerId); + expect(customer2.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, // Now changed because we passed it + current_balance: 50, + usage: 100, // 150 - 50 + purchased_balance: 0, + }); + + // Verify DB sync + const customerFromDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerFromDb.balances[TestFeature.Messages]).toMatchObject({ + granted_balance: 150, + current_balance: 50, + usage: 100, + purchased_balance: 0, + }); +}); diff --git a/shared/api/customers/cusFeatures/apiBalanceV1.ts b/shared/api/customers/cusFeatures/apiBalanceV1.ts index c152b1b18..31dbed5e6 100644 --- a/shared/api/customers/cusFeatures/apiBalanceV1.ts +++ b/shared/api/customers/cusFeatures/apiBalanceV1.ts @@ -16,6 +16,8 @@ export const ApiBalanceBreakdownPriceSchema = z.object({ }); export const ApiBalanceBreakdownV1Schema = z.object({ + object: z.literal("balance_breakdown"), + id: z.string().default(""), plan_id: z.string().nullable(), @@ -31,9 +33,15 @@ export const ApiBalanceBreakdownV1Schema = z.object({ // Extra fields expires_at: z.number().nullable(), // For loose entitlements with expiry + + overage: z.number().meta({ + internal: true, + }), }); export const ApiBalanceV1Schema = z.object({ + object: z.literal("balance"), + feature_id: z.string(), feature: ApiFeatureV1Schema.optional(), diff --git a/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts b/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts index 7d02a8ec8..02821899b 100644 --- a/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts +++ b/shared/api/customers/cusFeatures/mappers/balanceV1ToV0.ts @@ -10,15 +10,7 @@ export function balanceBreakdownV1ToV0({ }: { input: ApiBalanceBreakdownV1; }): ApiBalanceBreakdown { - // For usage-based billing, purchased_balance includes prepaid + overage - // Overage = usage beyond what was granted and prepaid - const totalGrantedAndPrepaid = new Decimal(input.included_grant) - .add(input.prepaid_grant) - .toNumber(); - const overage = Math.max( - 0, - new Decimal(input.usage).sub(totalGrantedAndPrepaid).toNumber(), - ); + const overage = input.overage ?? 0; const purchasedBalance = new Decimal(input.prepaid_grant) .add(overage) .toNumber(); @@ -57,6 +49,7 @@ export function balanceV1ToV0({ input }: { input: ApiBalanceV1 }): ApiBalance { const purchasedBalance = apiBalanceV1ToPurchasedBalance({ apiBalance: input, }); + const prepaidQuantity = apiBalanceV1ToPrepaidQuantity({ apiBalance: input }); // V0 granted_balance = V1 granted - prepaid_quantity diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToOverage.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToOverage.ts index 9878216de..710ba99dd 100644 --- a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToOverage.ts +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToOverage.ts @@ -1,5 +1,4 @@ import { sumValues } from "@utils/utils.js"; -import { Decimal } from "decimal.js"; import type { ApiBalanceBreakdownV1, ApiBalanceV1, @@ -10,14 +9,15 @@ export const apiBalanceBreakdownV1ToOverage = ({ }: { apiBalanceBreakdown: ApiBalanceBreakdownV1; }) => { - // Overage = usage beyond what was granted and prepaid, clamped to 0 - return Math.max( - 0, - new Decimal(apiBalanceBreakdown.usage) - .sub(apiBalanceBreakdown.included_grant) - .sub(apiBalanceBreakdown.prepaid_grant) - .toNumber(), - ); + // const overage = Math.max( + // 0, + // new Decimal(apiBalanceBreakdown.usage) + // .sub(apiBalanceBreakdown.included_grant) + // .sub(apiBalanceBreakdown.prepaid_grant) + // .toNumber(), + // ); + + return apiBalanceBreakdown.overage ?? 0; }; export const apiBalanceV1ToOverage = ({ diff --git a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts index 607d00717..c4ff85e4d 100644 --- a/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts +++ b/shared/api/customers/cusFeatures/utils/convert/apiBalanceV1ToPurchasedBalance.ts @@ -26,5 +26,6 @@ export const apiBalanceV1ToPurchasedBalance = ({ }) => { const totalOverage = apiBalanceV1ToOverage({ apiBalance }); const totalPrepaid = apiBalanceV1ToPrepaidQuantity({ apiBalance }); + return new Decimal(totalOverage).add(totalPrepaid).toNumber(); }; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts index aab7763be..9862a796f 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToCurrentBalance.ts @@ -21,6 +21,7 @@ export const cusEntToCurrentBalance = ({ 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; @@ -55,26 +56,6 @@ 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 currentBalance = new Decimal(Math.max(0, balance)).toNumber(); - - // return currentBalance; - // }; - return sumValues( cusEnts.map((cusEnt) => cusEntToCurrentBalance({ cusEnt, entityId, withRollovers }), diff --git a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts index 3457a9a3b..e450dfefc 100644 --- a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts +++ b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceOverage.ts @@ -1,22 +1,32 @@ +import { nullish } from "@utils/utils"; import { Decimal } from "decimal.js"; import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { isEntityScopedCusEnt } from "../classifyCusEntUtils"; export const cusEntToInvoiceOverage = ({ cusEnt, + entityId, }: { cusEnt: FullCusEntWithFullCusProduct; + entityId?: string; }) => { // 1. If entity scoped if (isEntityScopedCusEnt(cusEnt)) { let totalOverage = new Decimal(0); - for (const [_, entity] of Object.entries(cusEnt.entities || {})) { + if (nullish(entityId)) { + for (const [_, entity] of Object.entries(cusEnt.entities || {})) { + const overage = Decimal.max(0, new Decimal(-entity.balance)); + + totalOverage = totalOverage.add(overage); + } + + return totalOverage.toNumber(); // this is NOT to be used for any amount calculations OR billing calculations. ONLY display purposes (invoice descriptions) + } else { + const entity = cusEnt.entities?.[entityId]; + if (nullish(entity)) return 0; const overage = Decimal.max(0, new Decimal(-entity.balance)); - - totalOverage = totalOverage.add(overage); + return overage.toNumber(); } - - return totalOverage.toNumber(); // this is NOT to be used for any amount calculations OR billing calculations. ONLY display purposes (invoice descriptions) } // 2. If not entity scoped