diff --git a/scripts/testScripts/runTestsV2.tsx b/scripts/testScripts/runTestsV2.tsx index a5d994fd6..232b27b2e 100644 --- a/scripts/testScripts/runTestsV2.tsx +++ b/scripts/testScripts/runTestsV2.tsx @@ -364,8 +364,9 @@ async function runTestFile({ // A file is considered failed if: // 1. Any individual test failed, OR // 2. The process exited non-zero (e.g. module import error), OR - // 3. Zero tests were found (likely a silent import failure) - const isFailed = hasFailures || processExitedNonZero || hasNoTests; + // 3. Zero tests were found AND process exited non-zero (likely a silent import failure) + // Note: Empty files that run successfully (exit 0) are treated as passed/skipped + const isFailed = hasFailures || processExitedNonZero; const finalResult: TestFileResult = { file, diff --git a/server/src/internal/balances/utils/sql/resetCusEnts.sql b/server/src/internal/balances/utils/sql/resetCusEnts.sql index 93024a681..6c8a4f876 100644 --- a/server/src/internal/balances/utils/sql/resetCusEnts.sql +++ b/server/src/internal/balances/utils/sql/resetCusEnts.sql @@ -73,6 +73,12 @@ BEGIN WHERE ce.id = ent_id FOR UPDATE; + -- Skip if the row doesn't exist (stale ID from a deleted cusEnt) + IF NOT FOUND THEN + skipped_ids := skipped_ids || to_jsonb(ent_id); + CONTINUE; + END IF; + -- Optimistic lock: skip if next_reset_at already equals the new value IF db_next_reset_at IS NOT DISTINCT FROM new_next_reset_at THEN skipped_ids := skipped_ids || to_jsonb(ent_id); diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts index 86262b901..2a4b663c2 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts @@ -2,11 +2,10 @@ import { CusProductStatus } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import type { BatchResetCusEntsPayload } from "@/queue/workflows.js"; import { CusService } from "../../CusService.js"; -import { resetCustomerEntitlements } from "./resetCustomerEntitlements.js"; /** - * SQS worker handler: fetches cusEnts by ID, groups by customer, - * fetches each FullCustomer, and runs the lazy reset logic. + * SQS worker handler: fetches each FullCustomer via CusService.getFull, + * which triggers the lazy reset internally. */ export const batchResetCustomerEntitlements = async ({ ctx, @@ -25,18 +24,13 @@ export const batchResetCustomerEntitlements = async ({ const batch = resets.slice(i, i + BATCH_SIZE); await Promise.all( - batch.map(async (reset) => { - const fullCus = await CusService.getFull({ + batch.map((reset) => + CusService.getFull({ ctx, idOrInternalId: reset.internalCustomerId, inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], - }); - - await resetCustomerEntitlements({ - ctx, - fullCus, - }); - }), + }), + ), ); } }; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts index 823e78355..73067679d 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts @@ -1,4 +1,5 @@ import type { FullCustomer } from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { type ResetCusEntParam, @@ -53,55 +54,63 @@ export const resetCustomerEntitlements = async ({ if (cusEntsNeedingReset.length === 0) return false; - logger.info( - `[resetCustomerEntitlements] customer=${customerId}, cusEnts needing reset: ${cusEntsNeedingReset.length}`, - ); + try { + logger.info( + `[resetCustomerEntitlements] customer=${customerId}, cusEnts needing reset: ${cusEntsNeedingReset.length}`, + ); - // 1. Compute all resets (pure computation, no DB writes) - const computed: Array<{ - cusEntId: string; - result: ProcessResetResult; - }> = []; + // 1. Compute all resets (pure computation, no DB writes) + const computed: Array<{ + cusEntId: string; + result: ProcessResetResult; + }> = []; - for (const cusEnt of cusEntsNeedingReset) { - const result = await processReset({ cusEnt, ctx }); - if (!result) continue; - computed.push({ cusEntId: cusEnt.id, result }); - } + for (const cusEnt of cusEntsNeedingReset) { + const result = await processReset({ cusEnt, ctx }); + if (!result) continue; + computed.push({ cusEntId: cusEnt.id, result }); + } - if (computed.length === 0) return false; + if (computed.length === 0) return false; - // 2. Execute atomic DB writes via Postgres function - const resets = computed.map(({ cusEntId, result }) => - toResetParam({ cusEntId, result }), - ); + // 2. Execute atomic DB writes via Postgres function + const resets = computed.map(({ cusEntId, result }) => + toResetParam({ cusEntId, result }), + ); - const { applied, skipped } = await resetCusEnts({ ctx, resets }); - - logger.info( - `[resetCustomerEntitlements] customer=${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`, - ); - - // 3. Apply computed reset values to in-memory FullCustomer. - // Both DB-applied and DB-skipped cusEnts get their in-memory state updated - // (skipped means another request already wrote the same values to DB). - // Rollover clearing only runs for DB-applied entries. - await applyResetResults({ ctx, fullCus, computed, skipped }); - - // 4. Update Redis cache atomically (fire-and-forget) - // Only needed when we actually wrote to DB — skipped means cache was - // already updated by the winning request. - if (Object.keys(applied).length > 0) { - await executeResetCache({ - ctx, - customerId, - resets, - }); + const { applied, skipped } = await resetCusEnts({ ctx, resets }); logger.info( - `[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`, + `[resetCustomerEntitlements] customer=${customerId}, applied: ${Object.keys(applied).length}, skipped: ${skipped.length}`, ); - } - return true; + // 3. Apply computed reset values to in-memory FullCustomer. + // Both DB-applied and DB-skipped cusEnts get their in-memory state updated + // (skipped means another request already wrote the same values to DB). + // Rollover clearing only runs for DB-applied entries. + await applyResetResults({ ctx, fullCus, computed, skipped }); + + // 4. Update Redis cache atomically (fire-and-forget) + // Only needed when we actually wrote to DB — skipped means cache was + // already updated by the winning request. + if (Object.keys(applied).length > 0) { + await executeResetCache({ + ctx, + customerId, + resets, + }); + + logger.info( + `[resetCustomerEntitlements] customer=${customerId}, Redis cache updated`, + ); + } + + return true; + } catch (error) { + logger.error( + `[resetCustomerEntitlements] customer=${customerId}, failed: ${error}`, + ); + Sentry.captureException(error); + return false; + } }; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.ts index 13a7b7f39..19a88df42 100644 --- a/server/src/internal/customers/actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.ts +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.ts @@ -23,6 +23,8 @@ export const triggerBatchResetCustomerEntitlements = async ({ now, }); + if (cusEntsNeedingReset.length === 0) continue; + resets.push({ internalCustomerId: fullCus.internal_id, customerId: fullCus.id ?? "", diff --git a/server/tests/balances/check/loose/loose-expiry.test.ts b/server/tests/balances/check/loose/loose-expiry.test.ts index 07b7ea26b..9124992ad 100644 --- a/server/tests/balances/check/loose/loose-expiry.test.ts +++ b/server/tests/balances/check/loose/loose-expiry.test.ts @@ -1,205 +1,200 @@ -import { beforeAll, describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { ApiVersion, type CheckResponseV2, ResetInterval, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.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 { 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"; -/** - * Sleep until a specific epoch time in milliseconds - */ function sleepUntil(epochMs: number): Promise { const delay = epochMs - Date.now(); - - if (delay <= 0) { - return Promise.resolve(); - } - + if (delay <= 0) return Promise.resolve(); return new Promise((resolve) => setTimeout(resolve, delay)); } -const messagesFeature = constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 100, -}); - -const freeProd = constructProduct({ - type: "free", - isDefault: false, - items: [messagesFeature], -}); - const testCase = "check-loose-expiry"; -describe(`${chalk.yellowBright(`${testCase}: expiring loose entitlement check`)}`, () => { - const customerBasic = `${testCase}-basic`; - const customerProductMix = `${testCase}-prod`; - const customerResetMix = `${testCase}-reset`; +test.concurrent(chalk.yellowBright(`${testCase}-basic: expiring loose entitlement should be allowed before expiry, then denied after`), async () => { + const customerId = `${testCase}-basic`; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); - const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); - const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); - - beforeAll(async () => { - // Setup products - await initProductsV0({ - ctx, - products: [freeProd], - prefix: testCase, - }); - - // Setup customers only - await initCustomerV3({ - ctx, - customerId: customerBasic, - withTestClock: false, - }); - - await initCustomerV3({ - ctx, - customerId: customerProductMix, - withTestClock: false, - }); - - await initCustomerV3({ - ctx, - customerId: customerResetMix, - withTestClock: false, - }); + const { ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [], }); - test("basic: expiring loose entitlement should be allowed before expiry, then denied after", async () => { - const expiresAt = Date.now() + 3000; + const autumnV1 = new AutumnInt({ + version: ApiVersion.V1_2, + secretKey: ctx.orgSecretKey, + }); + const autumnV2 = new AutumnInt({ + version: ApiVersion.V2_0, + secretKey: ctx.orgSecretKey, + }); - // Create expiring loose entitlement - await autumnV1.balances.create({ - customer_id: customerBasic, + const expiresAt = Date.now() + 3000; + + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 500, + expires_at: expiresAt, + }); + + const resBefore = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resBefore).toMatchObject({ + allowed: true, + customer_id: customerId, + balance: { + plan_id: null, feature_id: TestFeature.Messages, granted_balance: 500, - expires_at: expiresAt, - }); - - // Check before expiry - const resBefore = (await autumnV2.check({ - customer_id: customerBasic, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(resBefore.allowed).toBe(true); - expect(resBefore.customer_id).toBe(customerBasic); - expect(resBefore.balance).toBeDefined(); - expect(resBefore.balance?.plan_id).toBeNull(); - expect(resBefore.balance?.feature_id).toBe(TestFeature.Messages); - expect(resBefore.balance?.granted_balance).toBe(500); - expect(resBefore.balance?.current_balance).toBe(500); - expect(resBefore.balance?.usage).toBe(0); - expect(resBefore.balance?.unlimited).toBe(false); - - // Wait until expiry - await sleepUntil(expiresAt + 1000); - - // Check after expiry - const resAfter = (await autumnV2.check({ - customer_id: customerBasic, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(resAfter.allowed).toBe(false); - expect(resAfter.balance).toBeNull(); + current_balance: 500, + usage: 0, + unlimited: false, + }, }); - test("product-mix: should combine product and expiring loose ent, then only product after expiry", async () => { - const expiresAt = Date.now() + 3000; + await sleepUntil(expiresAt + 1000); - // Attach product with 100 messages - await autumnV1.attach({ - customer_id: customerProductMix, - product_id: freeProd.id, - }); + const resAfter = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; - // Create expiring loose entitlement with 200 messages - await autumnV1.balances.create({ - customer_id: customerProductMix, - feature_id: TestFeature.Messages, - granted_balance: 200, - expires_at: expiresAt, - }); + expect(resAfter).toMatchObject({ + allowed: false, + balance: null, + }); +}); - // Check before expiry - const resBefore = (await autumnV2.check({ - customer_id: customerProductMix, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; +test.concurrent(chalk.yellowBright(`${testCase}-product-mix: should combine product and expiring loose ent, then only product after expiry`), async () => { + const customerId = `${testCase}-product-mix`; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); - expect(resBefore.allowed).toBe(true); - expect(resBefore.balance?.granted_balance).toBe(300); // 100 from product + 200 from loose - expect(resBefore.balance?.current_balance).toBe(300); - - // Wait until expiry - await sleepUntil(expiresAt + 1000); - - // Check after expiry - const resAfter = (await autumnV2.check({ - customer_id: customerProductMix, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(resAfter.allowed).toBe(true); - expect(resAfter.balance?.granted_balance).toBe(100); // Only product balance remains - expect(resAfter.balance?.current_balance).toBe(100); + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [s.attach({ productId: freeProd.id })], }); - test("reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry", async () => { - const expiresAt = Date.now() + 3000; + const autumnV2 = new AutumnInt({ + version: ApiVersion.V2_0, + secretKey: ctx.orgSecretKey, + }); - // Create expiring loose entitlement - await autumnV1.balances.create({ - customer_id: customerResetMix, - feature_id: TestFeature.Messages, - granted_balance: 200, - expires_at: expiresAt, - }); + const expiresAt = Date.now() + 3000; - // Create resetting loose entitlement (no expiry) - await autumnV1.balances.create({ - customer_id: customerResetMix, - feature_id: TestFeature.Messages, - granted_balance: 100, + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 200, + expires_at: expiresAt, + }); + + const resBefore = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resBefore).toMatchObject({ + allowed: true, + balance: { + granted_balance: 300, // 100 from product + 200 from loose + current_balance: 300, + }, + }); + + await sleepUntil(expiresAt + 1000); + + const resAfter = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfter).toMatchObject({ + allowed: true, + balance: { + granted_balance: 100, // Only product balance remains + current_balance: 100, + }, + }); +}); + +test.concurrent(chalk.yellowBright(`${testCase}-reset-mix: should combine expiring and resetting loose ents, then only resetting after expiry`), async () => { + const customerId = `${testCase}-reset-mix`; + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freeProd = products.base({ id: "free", items: [messagesItem] }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })], + actions: [], + }); + + const autumnV2 = new AutumnInt({ + version: ApiVersion.V2_0, + secretKey: ctx.orgSecretKey, + }); + + const expiresAt = Date.now() + 3000; + + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 200, + expires_at: expiresAt, + }); + + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + reset: { + interval: ResetInterval.Month, + }, + }); + + const resBefore = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resBefore).toMatchObject({ + allowed: true, + balance: { + granted_balance: 300, // 200 expiring + 100 resetting + current_balance: 300, + }, + }); + + await sleepUntil(expiresAt + 1000); + + const resAfter = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(resAfter).toMatchObject({ + allowed: true, + balance: { + granted_balance: 100, // Only resetting balance remains + current_balance: 100, reset: { interval: ResetInterval.Month, }, - }); - - // Check before expiry - const resBefore = (await autumnV2.check({ - customer_id: customerResetMix, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(resBefore.allowed).toBe(true); - expect(resBefore.balance?.granted_balance).toBe(300); // 200 expiring + 100 resetting - expect(resBefore.balance?.current_balance).toBe(300); - - // Wait until expiry - await sleepUntil(expiresAt + 1000); - - // Check after expiry - const resAfter = (await autumnV2.check({ - customer_id: customerResetMix, - feature_id: TestFeature.Messages, - })) as unknown as CheckResponseV2; - - expect(resAfter.allowed).toBe(true); - expect(resAfter.balance?.granted_balance).toBe(100); // Only resetting balance remains - expect(resAfter.balance?.current_balance).toBe(100); - expect(resAfter.balance?.reset).toBeDefined(); - expect(resAfter.balance?.reset?.interval).toBe(ResetInterval.Month); + }, }); }); diff --git a/server/tests/integration/balances/track/track-race.test.ts b/server/tests/integration/balances/track/track-race.test.ts deleted file mode 100644 index e69de29bb..000000000