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/_luaScriptsV2/luaScriptsV2.ts b/server/src/_luaScriptsV2/luaScriptsV2.ts index b04919b7c..bca1abd42 100644 --- a/server/src/_luaScriptsV2/luaScriptsV2.ts +++ b/server/src/_luaScriptsV2/luaScriptsV2.ts @@ -8,6 +8,7 @@ const __dirname = dirname(__filename); // Path to script folders const DEDUCT_DIR = join(__dirname, "deductFromCustomerEntitlements"); const DELETE_CACHE_DIR = join(__dirname, "deleteFullCustomerCache"); +const RESET_DIR = join(__dirname, "resetCustomerEntitlements"); // ============================================================================ // HELPER MODULES @@ -93,3 +94,20 @@ export const BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT = readFileSync( join(DELETE_CACHE_DIR, "batchDeleteFullCustomerCache.lua"), "utf-8", ); + +// ============================================================================ +// RESET CUSTOMER ENTITLEMENTS SCRIPT +// ============================================================================ + +const resetMainScript = readFileSync( + join(RESET_DIR, "resetCustomerEntitlements.lua"), + "utf-8", +); + +/** + * Lua script for atomically resetting cusEnt fields in the cached FullCustomer. + * Reuses luaUtils helpers for find_entitlement navigation. + * Skips if cache doesn't exist or cusEnt already reset (optimistic guard). + */ +export const RESET_CUSTOMER_ENTITLEMENTS_SCRIPT = `${LUA_UTILS} +${resetMainScript}`; diff --git a/server/src/_luaScriptsV2/resetCustomerEntitlements/resetCustomerEntitlements.lua b/server/src/_luaScriptsV2/resetCustomerEntitlements/resetCustomerEntitlements.lua new file mode 100644 index 000000000..c14465aa5 --- /dev/null +++ b/server/src/_luaScriptsV2/resetCustomerEntitlements/resetCustomerEntitlements.lua @@ -0,0 +1,112 @@ +--[[ + Lua Script: Reset Customer Entitlements in Redis Cache + + Atomically updates cached cusEnt fields after a DB reset. + Skips if cache doesn't exist or if the cusEnt's next_reset_at already + equals the new value (same optimistic guard as the SQL function). + + Helper functions prepended via string interpolation from: + - luaUtils.lua (find_entitlement, safe_number, is_nil) + + KEYS[1] = FullCustomer cache key + + ARGV[1] = JSON params: + { + resets: [{ + cus_ent_id: string, + balance: number | null, + additional_balance: number | null, + adjustment: number, + entities: object | null, + next_reset_at: number, + rollover_insert: { id, cus_ent_id, balance, usage, expires_at, entities } | null + }] + } + + Returns JSON: + { "applied": { "": true }, "skipped": ["id1"] } +]] + +local cache_key = KEYS[1] +local params = cjson.decode(ARGV[1]) +local resets = params.resets or {} + +-- Early return if no resets +if #resets == 0 then + return cjson.encode({ applied = {}, skipped = {} }) +end + +-- Check if cache exists +local key_exists = redis.call('EXISTS', cache_key) +if key_exists == 0 then + return cjson.encode({ applied = {}, skipped = {}, cache_miss = true }) +end + +-- Read the full customer structure for entitlement path lookups +local full_customer_json = redis.call('JSON.GET', cache_key, '.') +if not full_customer_json then + return cjson.encode({ applied = {}, skipped = {}, cache_miss = true }) +end + +local full_customer = cjson.decode(full_customer_json) + +local applied = {} +local skipped = {} + +for _, reset in ipairs(resets) do + local ent_id = reset.cus_ent_id + local new_next_reset_at = reset.next_reset_at + + -- Find the cusEnt in the FullCustomer structure + local cus_ent, cus_product, ce_idx, cp_idx = find_entitlement(full_customer, ent_id) + + if not cus_ent then + table.insert(skipped, ent_id) + else + -- Build the JSON path to this cusEnt + local base_path + local is_loose = (cp_idx == nil) + + if is_loose then + base_path = '$.extra_customer_entitlements[' .. (ce_idx - 1) .. ']' + else + base_path = '$.customer_products[' .. (cp_idx - 1) .. '].customer_entitlements[' .. (ce_idx - 1) .. ']' + end + + -- Optimistic guard: skip if next_reset_at already equals the new value + local current_reset_at = safe_number(cus_ent.next_reset_at) + if current_reset_at == new_next_reset_at then + table.insert(skipped, ent_id) + else + -- Apply reset fields using JSON.SET for absolute values + if not is_nil(reset.balance) then + redis.call('JSON.SET', cache_key, base_path .. '.balance', tostring(reset.balance)) + end + + if not is_nil(reset.additional_balance) then + redis.call('JSON.SET', cache_key, base_path .. '.additional_balance', tostring(reset.additional_balance)) + end + + redis.call('JSON.SET', cache_key, base_path .. '.adjustment', tostring(reset.adjustment)) + redis.call('JSON.SET', cache_key, base_path .. '.next_reset_at', tostring(new_next_reset_at)) + + -- Set entities if provided (entity-scoped entitlement) + if not is_nil(reset.entities) then + redis.call('JSON.SET', cache_key, base_path .. '.entities', cjson.encode(reset.entities)) + end + + -- Increment cache_version + redis.call('JSON.NUMINCRBY', cache_key, base_path .. '.cache_version', 1) + + -- Append rollover if provided + if not is_nil(reset.rollover_insert) then + local rollover_json = cjson.encode(reset.rollover_insert) + redis.call('JSON.ARRAPPEND', cache_key, base_path .. '.rollovers', rollover_json) + end + + applied[ent_id] = true + end + end +end + +return cjson.encode({ applied = applied, skipped = skipped }) diff --git a/server/src/db/initializeDatabaseFunctions.ts b/server/src/db/initializeDatabaseFunctions.ts index 811397c14..d6033c655 100644 --- a/server/src/db/initializeDatabaseFunctions.ts +++ b/server/src/db/initializeDatabaseFunctions.ts @@ -28,6 +28,7 @@ export const initializeDatabaseFunctions = async () => { "performDeduction.sql", "syncBalances.sql", "syncBalancesV2.sql", + "resetCusEnts.sql", ]; for (const file of sqlFiles) { diff --git a/server/src/external/redis/initRedis.ts b/server/src/external/redis/initRedis.ts index c689ca9d5..e6fa3fb54 100644 --- a/server/src/external/redis/initRedis.ts +++ b/server/src/external/redis/initRedis.ts @@ -17,6 +17,7 @@ import { BATCH_DELETE_FULL_CUSTOMER_CACHE_SCRIPT, DEDUCT_FROM_CUSTOMER_ENTITLEMENTS_SCRIPT, DELETE_FULL_CUSTOMER_CACHE_SCRIPT, + RESET_CUSTOMER_ENTITLEMENTS_SCRIPT, SET_FULL_CUSTOMER_CACHE_SCRIPT, } from "../../_luaScriptsV2/luaScriptsV2.js"; @@ -179,6 +180,11 @@ const configureRedisInstance = (redisInstance: Redis): Redis => { lua: SET_FULL_CUSTOMER_CACHE_SCRIPT, }); + redisInstance.defineCommand("resetCustomerEntitlements", { + numberOfKeys: 1, + lua: RESET_CUSTOMER_ENTITLEMENTS_SCRIPT, + }); + redisInstance.on("error", (error) => { console.error(`[Redis] Connection error:`, error.message); }); @@ -353,6 +359,10 @@ declare module "ioredis" { serializedData: string, overwrite: string, ): Promise<"STALE_WRITE" | "CACHE_EXISTS" | "OK">; + resetCustomerEntitlements( + cacheKey: string, + paramsJson: string, + ): Promise; } } diff --git a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts index f309e00e2..eb614369e 100644 --- a/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts +++ b/server/src/external/revenueCat/misc/resolveRevenuecatResources.ts @@ -63,10 +63,8 @@ export const resolveRevenuecatResources = async ({ customerId, }) : CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, }), ]); diff --git a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts index 088cce5a0..793bb68f1 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeToAutumnCustomerMiddleware.ts @@ -39,10 +39,8 @@ const getAutumnCustomerId = async ({ ctx }: { ctx: StripeWebhookContext }) => { if (!cus) return; const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: cus.internal_id, - orgId: ctx.org.id, - env: ctx.env, withEntities: true, withSubs: true, inStatuses: RELEVANT_STATUSES, diff --git a/server/src/external/vercel/handlers/handleListBillingPlans.ts b/server/src/external/vercel/handlers/handleListBillingPlans.ts index 27c19a7f5..c2ff709d6 100644 --- a/server/src/external/vercel/handlers/handleListBillingPlans.ts +++ b/server/src/external/vercel/handlers/handleListBillingPlans.ts @@ -211,7 +211,8 @@ export const handleListBillingPlansPerInstall = createRoute({ integrationConfigurationId?: string; productId?: string; }; - const { db, org, logger } = c.get("ctx"); + const ctx = c.get("ctx"); + const { db, org, logger } = ctx; if (!integrationConfigurationId && !productId) { return c.json( @@ -245,10 +246,8 @@ export const handleListBillingPlansPerInstall = createRoute({ } const customer = await CusService.getByVercelId({ - db, + ctx, vercelInstallationId: integrationConfigurationId, - orgId: org.id, - env: env as AppEnv, }); // Parse metadata from query params diff --git a/server/src/external/vercel/handlers/handleUpdateBillingPlan.ts b/server/src/external/vercel/handlers/handleUpdateBillingPlan.ts index 06b20bf94..a10861217 100644 --- a/server/src/external/vercel/handlers/handleUpdateBillingPlan.ts +++ b/server/src/external/vercel/handlers/handleUpdateBillingPlan.ts @@ -1,4 +1,4 @@ -import { type AppEnv, CustomerExpand, RecaseError } from "@autumn/shared"; +import { CustomerExpand, RecaseError } from "@autumn/shared"; import { ErrCode } from "@shared/enums/ErrCode.js"; import { StatusCodes } from "http-status-codes"; import { z } from "zod/v4"; @@ -15,17 +15,16 @@ export const handleUpdateVercelBillingPlan = createRoute({ }), // assertIdempotence: "Idempotency-Key", handler: async (c) => { - const { orgId, env, integrationConfigurationId } = c.req.param(); - const { db, org, features, logger } = c.get("ctx"); + const { integrationConfigurationId } = c.req.param(); + const ctx = c.get("ctx"); + const { org, logger } = ctx; const { billingPlanId } = c.req.valid("json"); // Get customer by Vercel installation ID (not by customer.id which may differ) const customer = await CusService.getByVercelId({ - db, + ctx, vercelInstallationId: integrationConfigurationId, - orgId, - env: env as AppEnv, expand: [CustomerExpand.Entities], }); @@ -55,7 +54,7 @@ export const handleUpdateVercelBillingPlan = createRoute({ const stripeCli = createStripeCli({ org, - env: env as AppEnv, + env: ctx.env, }); const stripeCustomer = await stripeCli.customers.retrieve( @@ -90,16 +89,12 @@ export const handleUpdateVercelBillingPlan = createRoute({ if (!existingSubscription) { // New subscription flow - create installation-level subscription const { product } = await createVercelSubscription({ - db, - org, - env: env as AppEnv, + ctx, customer, stripeCustomer, stripeCli, integrationConfigurationId, billingPlanId, - features, - logger, c, }); diff --git a/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts b/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts index 993f463be..d61fe9af7 100644 --- a/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleDeleteInstallation.ts @@ -51,10 +51,8 @@ export const handleDeleteInstallation = createRoute({ // 2. Get customer by Vercel installation ID (customer.id may differ from installation_id) const customer = await CusService.getByVercelId({ - db, + ctx, vercelInstallationId: integrationConfigurationId, - orgId, - env: ctx.env, }); // 3. Delete the customer/installation using the actual customer ID diff --git a/server/src/external/vercel/handlers/installations/handleGetInstallation.ts b/server/src/external/vercel/handlers/installations/handleGetInstallation.ts index 1e7204943..f89965862 100644 --- a/server/src/external/vercel/handlers/installations/handleGetInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleGetInstallation.ts @@ -1,7 +1,7 @@ import { cusProductToProduct, - mapToProductV2, type FullCusProduct, + mapToProductV2, productV2ToBasePrice, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -13,13 +13,11 @@ export const handleGetInstallation = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); const { integrationConfigurationId } = c.req.param(); - const { db, org } = ctx; + const { org } = ctx; const customer = await CusService.getByVercelId({ - db, + ctx, vercelInstallationId: integrationConfigurationId, - orgId: org.id, - env: ctx.env, }); if (!customer) { diff --git a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts index 8d7f1bc44..afe1ff182 100644 --- a/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts +++ b/server/src/external/vercel/handlers/installations/handleUpsertInstallation.ts @@ -144,10 +144,8 @@ export const handleUpsertInstallation = createRoute({ if (createdCustomer) { const fullCreatedCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: createdCustomer.internal_id, - orgId: ctx.org.id, - env: ctx.env, }); const installation = { diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts index e40f34119..6981986e9 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoicePaid.ts @@ -72,10 +72,8 @@ export const handleMarketplaceInvoicePaid = async ({ } const customer = await CusService.getFull({ - db, + ctx, idOrInternalId: partialCustomer.internal_id, - orgId: org.id, - env, }); if (!customer) { diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoidNotPaid.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoidNotPaid.ts index 8155c6c84..536a724eb 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoidNotPaid.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoidNotPaid.ts @@ -65,10 +65,8 @@ export const handleMarketplaceInvoiceNotPaid = async ({ } const customer = await CusService.getFull({ - db, + ctx, idOrInternalId: partialCustomer.internal_id, - orgId: org.id, - env, }); if (!customer) { diff --git a/server/src/external/vercel/handlers/resources/handleCreateResource.ts b/server/src/external/vercel/handlers/resources/handleCreateResource.ts index 5fba059b6..a5970c3f1 100644 --- a/server/src/external/vercel/handlers/resources/handleCreateResource.ts +++ b/server/src/external/vercel/handlers/resources/handleCreateResource.ts @@ -41,15 +41,14 @@ export const handleCreateResource = createRoute({ }), handler: async (c) => { const { orgId, env, integrationConfigurationId } = c.req.param(); - const { db, org, features, logger } = c.get("ctx"); + const ctx = c.get("ctx"); + const { db, org } = ctx; const { productId, name, metadata, billingPlanId } = c.req.valid("json"); // 1. Get customer by Vercel installation ID (not by customer.id which may differ) const customer = await CusService.getByVercelId({ - db, + ctx, vercelInstallationId: integrationConfigurationId, - orgId, - env: env as AppEnv, expand: [CustomerExpand.Entities], }); @@ -100,16 +99,12 @@ export const handleCreateResource = createRoute({ try { // 3. Create subscription (installation-level billing) const { product } = await createVercelSubscription({ - db: tx as unknown as DrizzleCli, - org, - env: env as AppEnv, + ctx: { ...ctx, db: tx as unknown as DrizzleCli }, customer, stripeCustomer, stripeCli, integrationConfigurationId, billingPlanId, - features, - logger, c, metadata, resourceId, diff --git a/server/src/external/vercel/handlers/resources/handleDeleteResource.ts b/server/src/external/vercel/handlers/resources/handleDeleteResource.ts index 8616da2a1..5c4ef239c 100644 --- a/server/src/external/vercel/handlers/resources/handleDeleteResource.ts +++ b/server/src/external/vercel/handlers/resources/handleDeleteResource.ts @@ -17,7 +17,8 @@ export const handleDeleteResource = createRoute({ handler: async (c) => { const { orgId, env, integrationConfigurationId, resourceId } = c.req.param(); - const { db, org } = c.get("ctx"); + const ctx = c.get("ctx"); + const { db, org } = ctx; const stripeCli = createStripeCli({ org, env: env as AppEnv }); await VercelResourceService.delete({ @@ -45,10 +46,8 @@ export const handleDeleteResource = createRoute({ }); const customer = await CusService.getByVercelId({ - db, + ctx, vercelInstallationId: integrationConfigurationId, - orgId, - env: env as AppEnv, }); customer?.customer_products.forEach(async (x) => { diff --git a/server/src/external/vercel/handlers/transfers/handleResourceTransfers.ts b/server/src/external/vercel/handlers/transfers/handleResourceTransfers.ts index a4cb4b5b5..d05dd63fe 100644 --- a/server/src/external/vercel/handlers/transfers/handleResourceTransfers.ts +++ b/server/src/external/vercel/handlers/transfers/handleResourceTransfers.ts @@ -1,5 +1,5 @@ -import { ErrCode } from "@shared/enums/ErrCode.js"; import { RecaseError } from "@autumn/shared"; +import { ErrCode } from "@shared/enums/ErrCode.js"; import { StatusCodes } from "http-status-codes"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; diff --git a/server/src/external/vercel/misc/vercelSubscriptions.ts b/server/src/external/vercel/misc/vercelSubscriptions.ts index 1c48d4b51..09155ceda 100644 --- a/server/src/external/vercel/misc/vercelSubscriptions.ts +++ b/server/src/external/vercel/misc/vercelSubscriptions.ts @@ -1,19 +1,15 @@ import { - type AppEnv, - type Feature, type FullCustomer, type FullProduct, - type Organization, RecaseError, } from "@autumn/shared"; import { ErrCode } from "@shared/enums/ErrCode.js"; import type { Context } from "hono"; import { StatusCodes } from "http-status-codes"; import type Stripe from "stripe"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js"; import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js"; -import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; +import type { AutumnContext, HonoEnv } from "@/honoUtils/HonoEnv.js"; import { createStripeSub2 } from "@/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.js"; import { handleFreeProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js"; import { CusService } from "@/internal/customers/CusService.js"; @@ -40,34 +36,27 @@ import { * Future: handleUpdateBillingPlan will also handle upgrades/downgrades when subscription exists */ export const createVercelSubscription = async ({ - db, - org, - env, + ctx, customer, stripeCustomer, stripeCli, integrationConfigurationId, billingPlanId, - features, - logger, c, metadata, resourceId, }: { - db: DrizzleCli; - org: Organization; - env: AppEnv; + ctx: AutumnContext; customer: FullCustomer; stripeCustomer: Stripe.Customer; stripeCli: Stripe; integrationConfigurationId: string; billingPlanId: string; - features: Feature[]; - logger: any; c: Context; metadata?: Record; resourceId?: string; }): Promise<{ product: FullProduct }> => { + const { db, org, env, features, logger } = ctx; // 1. Check for existing non-incomplete subscription (only allow one per installation) const existingSubscription = stripeCustomer.subscriptions?.data.find( (s) => @@ -105,10 +94,8 @@ export const createVercelSubscription = async ({ } const refreshedCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: customer.internal_id, - orgId: org.id, - env, }); // 3. Get custom payment method (created in handleUpsertInstallation) diff --git a/server/src/internal/analytics/handlers/handleProductsUpdated.ts b/server/src/internal/analytics/handlers/handleProductsUpdated.ts index 534f43edb..b5a086a7a 100644 --- a/server/src/internal/analytics/handlers/handleProductsUpdated.ts +++ b/server/src/internal/analytics/handlers/handleProductsUpdated.ts @@ -110,10 +110,8 @@ export const handleProductsUpdated = async ({ const fullProduct: FullProduct = cusProductToProduct({ cusProduct }); const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: data.customerId || data.internalCustomerId, - orgId: org.id, - env: env, entityId: cusProduct.internal_entity_id || undefined, allowNotFound: true, }); diff --git a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts index 9824eddd2..66442415a 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts @@ -45,10 +45,8 @@ export const handleInternalAggregateEvents = createRoute({ } else { // Customer ID provided, fetch customer data customer = await CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, withSubs: true, }); diff --git a/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts index 732a173c3..f67b153d3 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts @@ -29,10 +29,8 @@ export const handleInternalListRawEvents = createRoute({ } else { // Customer ID provided, fetch customer data customer = await CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, withSubs: true, }); diff --git a/server/src/internal/api/check/getCheckPreview.ts b/server/src/internal/api/check/getCheckPreview.ts index 5f242d39e..4c0396ab8 100644 --- a/server/src/internal/api/check/getCheckPreview.ts +++ b/server/src/internal/api/check/getCheckPreview.ts @@ -41,10 +41,8 @@ export const getCheckPreview = async ({ const { db, org, env, features: allFeatures } = ctx; const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, entityId, }); diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index 7089467d6..68710c4f6 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -23,10 +23,8 @@ export const handleCreateBalance = createRoute({ } const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env: env, entityId: entity_id, withEntities: true, }); diff --git a/server/src/internal/balances/handlers/handleListBalances.ts b/server/src/internal/balances/handlers/handleListBalances.ts index 2127a157d..e247acd53 100644 --- a/server/src/internal/balances/handlers/handleListBalances.ts +++ b/server/src/internal/balances/handlers/handleListBalances.ts @@ -21,10 +21,8 @@ export const handleListBalances = createRoute({ const { customer_id } = c.req.valid("query"); const fullCus = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customer_id, - orgId: ctx.org.id, - env: ctx.env, }); if (!fullCus) { diff --git a/server/src/internal/balances/handlers/handleUpdateBalance.ts b/server/src/internal/balances/handlers/handleUpdateBalance.ts index 309c5681e..539640fcb 100644 --- a/server/src/internal/balances/handlers/handleUpdateBalance.ts +++ b/server/src/internal/balances/handlers/handleUpdateBalance.ts @@ -44,10 +44,8 @@ export const handleUpdateBalance = createRoute({ }); const fullCus = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: params.customer_id, - orgId: ctx.org.id, - env: ctx.env, entityId: params.entity_id, withEntities: true, }); diff --git a/server/src/internal/balances/setUsage/handleSetUsage.ts b/server/src/internal/balances/setUsage/handleSetUsage.ts index c70929e5a..ed7851d6f 100644 --- a/server/src/internal/balances/setUsage/handleSetUsage.ts +++ b/server/src/internal/balances/setUsage/handleSetUsage.ts @@ -12,11 +12,9 @@ export const handleSetUsage = createRoute({ const ctx = c.get("ctx"); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: body.customer_id, entityId: body.entity_id, - orgId: ctx.org.id, - env: ctx.env, inStatuses: ACTIVE_STATUSES, }); diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index a08204ad8..5cf1b5ed4 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -57,10 +57,8 @@ export const executePostgresDeduction = async ({ // Need to getOrCreateCustomer here too... if (!fullCustomer) { fullCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, inStatuses: ACTIVE_STATUSES, entityId, withSubs: true, diff --git a/server/src/internal/balances/utils/sql/client.ts b/server/src/internal/balances/utils/sql/client.ts new file mode 100644 index 000000000..e16986ffc --- /dev/null +++ b/server/src/internal/balances/utils/sql/client.ts @@ -0,0 +1,64 @@ +import type { EntityBalance, Rollover } from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; + +export type ResetCusEntParam = { + cus_ent_id: string; + balance: number | null; + additional_balance: number | null; + adjustment: number; + entities: Record | null; + next_reset_at: number; + rollover_insert: Pick< + Rollover, + "id" | "cus_ent_id" | "balance" | "usage" | "expires_at" | "entities" + > | null; +}; + +export type AppliedCusEntReset = { + balance: number; + additional_balance: number; + adjustment: number; + entities: Record | null; + next_reset_at: number; + cache_version: number; + rollover: Pick< + Rollover, + "id" | "cus_ent_id" | "balance" | "usage" | "expires_at" | "entities" + > | null; +}; + +type ResetCusEntsResult = { + applied: Record; + skipped: string[]; +}; + +/** Calls the `reset_customer_entitlements` PL/pgSQL function atomically. */ +export const resetCusEnts = async ({ + ctx, + resets, +}: { + ctx: AutumnContext; + resets: ResetCusEntParam[]; +}): Promise => { + const { db } = ctx; + if (resets.length === 0) { + return { applied: {}, skipped: [] }; + } + + const result = await db.execute( + sql`SELECT * FROM reset_customer_entitlements(${JSON.stringify({ + resets, + })}::jsonb)`, + ); + + const raw = result[0]?.reset_customer_entitlements as + | ResetCusEntsResult + | undefined; + + return { + applied: raw?.applied ?? {}, + skipped: raw?.skipped ?? [], + }; +}; diff --git a/server/src/internal/balances/utils/sql/resetCusEnts.sql b/server/src/internal/balances/utils/sql/resetCusEnts.sql new file mode 100644 index 000000000..6c8a4f876 --- /dev/null +++ b/server/src/internal/balances/utils/sql/resetCusEnts.sql @@ -0,0 +1,139 @@ +-- Atomically reset customer entitlements that have passed their next_reset_at. +-- Uses per-row locking + optimistic check: only resets a cusEnt if its +-- next_reset_at does NOT already equal the new value (prevents double-resets). +-- +-- Params (JSONB): +-- resets: array of objects with: +-- - cus_ent_id: text +-- - balance: numeric (null if entity-scoped) +-- - additional_balance: numeric (null if entity-scoped) +-- - adjustment: numeric +-- - entities: jsonb (null if non-entity) +-- - next_reset_at: bigint (new next_reset_at value) +-- - rollover_insert: jsonb object or null, with fields: +-- id, cus_ent_id, balance, usage, expires_at, entities +-- +-- Returns JSONB: +-- { +-- "applied": { +-- "": { +-- "balance": number, +-- "additional_balance": number, +-- "adjustment": number, +-- "entities": jsonb, +-- "next_reset_at": number, +-- "cache_version": number, +-- "rollover": jsonb or null +-- } +-- }, +-- "skipped": ["id1", "id2"] +-- } +-- +DROP FUNCTION IF EXISTS reset_customer_entitlements(jsonb); + +CREATE FUNCTION reset_customer_entitlements(params jsonb) +RETURNS jsonb +LANGUAGE plpgsql +AS $$ +DECLARE + resets_param jsonb := params->'resets'; + + reset_obj jsonb; + ent_id text; + new_balance numeric; + new_additional_balance numeric; + new_adjustment numeric; + new_entities jsonb; + new_next_reset_at bigint; + rollover_obj jsonb; + + db_next_reset_at bigint; + updated_row record; + + applied_json jsonb := '{}'::jsonb; + skipped_ids jsonb := '[]'::jsonb; +BEGIN + IF resets_param IS NULL OR jsonb_array_length(resets_param) = 0 THEN + RETURN jsonb_build_object('applied', '{}'::jsonb, 'skipped', '[]'::jsonb); + END IF; + + FOR reset_obj IN SELECT * FROM jsonb_array_elements(resets_param) + LOOP + ent_id := reset_obj->>'cus_ent_id'; + new_balance := (reset_obj->>'balance')::numeric; + new_additional_balance := (reset_obj->>'additional_balance')::numeric; + new_adjustment := (reset_obj->>'adjustment')::numeric; + new_entities := reset_obj->'entities'; + new_next_reset_at := (reset_obj->>'next_reset_at')::bigint; + rollover_obj := reset_obj->'rollover_insert'; + + -- Lock and read the single row + SELECT ce.next_reset_at INTO db_next_reset_at + FROM customer_entitlements ce + 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); + CONTINUE; + END IF; + + -- Apply the reset update and capture the updated row + UPDATE customer_entitlements ce + SET + balance = COALESCE(new_balance, ce.balance), + additional_balance = COALESCE(new_additional_balance, ce.additional_balance), + adjustment = COALESCE(new_adjustment, ce.adjustment), + entities = COALESCE(new_entities, ce.entities), + next_reset_at = new_next_reset_at, + cache_version = COALESCE(ce.cache_version, 0) + 1 + WHERE ce.id = ent_id + RETURNING ce.balance, ce.additional_balance, ce.adjustment, ce.entities, + ce.next_reset_at, ce.cache_version + INTO updated_row; + + -- Insert rollover row if provided + IF rollover_obj IS NOT NULL AND rollover_obj != 'null'::jsonb THEN + INSERT INTO rollovers (id, cus_ent_id, balance, usage, expires_at, entities) + VALUES ( + rollover_obj->>'id', + rollover_obj->>'cus_ent_id', + (rollover_obj->>'balance')::numeric, + (rollover_obj->>'usage')::numeric, + (rollover_obj->>'expires_at')::numeric, + COALESCE(rollover_obj->'entities', '{}'::jsonb) + ); + END IF; + + -- Record the latest state of the updated cusEnt + applied_json := jsonb_set( + applied_json, + ARRAY[ent_id], + jsonb_build_object( + 'balance', updated_row.balance, + 'additional_balance', updated_row.additional_balance, + 'adjustment', updated_row.adjustment, + 'entities', updated_row.entities, + 'next_reset_at', updated_row.next_reset_at, + 'cache_version', updated_row.cache_version, + 'rollover', CASE + WHEN rollover_obj IS NOT NULL AND rollover_obj != 'null'::jsonb THEN rollover_obj + ELSE NULL + END + ) + ); + END LOOP; + + RETURN jsonb_build_object( + 'applied', applied_json, + 'skipped', skipped_ids + ); +END; +$$; diff --git a/server/src/internal/balances/utils/sync/syncItemV3.ts b/server/src/internal/balances/utils/sync/syncItemV3.ts index 4eb3bce39..301e006a1 100644 --- a/server/src/internal/balances/utils/sync/syncItemV3.ts +++ b/server/src/internal/balances/utils/sync/syncItemV3.ts @@ -196,14 +196,13 @@ export const syncItemV3 = async ({ ctx: AutumnContext; payload: SyncItemV3; }): Promise => { - const { customerId, orgId, env, region, cusEntIds, rolloverIds } = payload; + const { customerId, region, cusEntIds, rolloverIds } = payload; const { db, logger } = ctx; const redisInstance = region ? getRegionalRedis(region) : undefined; const fullCustomer = await getCachedFullCustomer({ - orgId, - env, + ctx, customerId, redisInstance, }); diff --git a/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts b/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts index da25a88fd..c4f79d8e3 100644 --- a/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts +++ b/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts @@ -9,14 +9,11 @@ export const setupFullCustomerContext = async ({ ctx: AutumnContext; params: { customer_id: string; entity_id?: string }; }) => { - const { db, org, env } = ctx; const { customer_id: customerId } = params; const fullCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, withSubs: true, withEntities: true, entityId: params.entity_id ?? undefined, diff --git a/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts b/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts index 37080c0e5..6f4d78c01 100644 --- a/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts +++ b/server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts @@ -42,10 +42,8 @@ export const sendProductsUpdated = async ({ // Fetch FullCustomer const fullCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId ?? "", - orgId: org.id, - env, withEntities: true, withSubs: true, allowNotFound: true, diff --git a/server/src/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.ts b/server/src/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.ts index 09186eff2..2255210c3 100644 --- a/server/src/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.ts +++ b/server/src/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.ts @@ -79,8 +79,7 @@ verifyCacheConsistency?.task({ // Get from cache (now using full customer cache) const cachedFullCustomer = await getCachedFullCustomer({ - orgId: autumnContext.org.id, - env: autumnContext.env, + ctx: autumnContext, customerId, }); @@ -97,10 +96,8 @@ verifyCacheConsistency?.task({ // Get fresh from DB const fullCus = await CusService.getFull({ - db, + ctx: autumnContext, idOrInternalId: customerId, - orgId: autumnContext.org.id, - env: autumnContext.env, withEntities: true, withSubs: true, expand: [CustomerExpand.Invoices], diff --git a/server/src/internal/customers/CusBatchService.ts b/server/src/internal/customers/CusBatchService.ts index 0c33213cb..1b2457496 100644 --- a/server/src/internal/customers/CusBatchService.ts +++ b/server/src/internal/customers/CusBatchService.ts @@ -1,36 +1,32 @@ import { AffectedResource, type ApiCustomerV5, - type AppEnv, applyResponseVersionChanges, type CusProductStatus, CustomerExpand, type CustomerLegacyData, type FullCustomer, type ListCustomersV2Params, - type Organization, RELEVANT_STATUSES, } from "@autumn/shared"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; -import type { RequestContext } from "@/honoUtils/HonoEnv.js"; +import * as Sentry from "@sentry/bun"; +import type { AutumnContext, RequestContext } from "@/honoUtils/HonoEnv.js"; +import { triggerBatchResetCustomerEntitlements } from "./actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.js"; import { getApiCustomerBase } from "./cusUtils/apiCusUtils/getApiCustomerBase.js"; import { getPaginatedFullCusQuery } from "./getFullCusQuery.js"; export class CusBatchService { static async getByInternalIds({ - db, - org, - env, + ctx, internalCustomerIds, }: { - db: DrizzleCli; - org: Organization; - env: AppEnv; + ctx: AutumnContext; internalCustomerIds: string[]; }) { + const { org, env, db } = ctx; const query = getPaginatedFullCusQuery({ - orgId: org.id, - env, + orgId: ctx.org.id, + env: ctx.env, includeInvoices: true, withEntities: true, withTrialsUsed: false, @@ -40,8 +36,20 @@ export class CusBatchService { internalCustomerIds, }); const results = await db.execute(query); + const fullCustomers = results as unknown as FullCustomer[]; - return results as unknown as FullCustomer[]; + // Fire-and-forget: queue SQS job for any stale entitlement resets + triggerBatchResetCustomerEntitlements({ + ctx, + fullCustomers, + }).catch((err) => { + ctx.logger.error( + `[CusBatchService.getByInternalIds] batch reset failed: ${err}`, + ); + Sentry.captureException(err); + }); + + return fullCustomers; } static async getPage({ @@ -75,12 +83,14 @@ export class CusBatchService { }); const results = await ctx.db.execute(sqlQuery); const finals = []; + const fullCustomers: FullCustomer[] = []; for (const result of results) { try { const normalizedCustomer = CusBatchService.normalizeCustomerData(result); const fullCus = normalizedCustomer as FullCustomer; + fullCustomers.push(fullCus); // Since we already have fullCus from DB, call getApiCustomerBase directly const { apiCustomer: baseCustomer, legacyData } = @@ -104,10 +114,19 @@ export class CusBatchService { finals.push(versionedCustomer); } catch (error) { - console.error(`Failed to process customer ${result.id}:`, error); + ctx.logger.error(`Failed to process customer ${result.id}: ${error}`); } } + // Fire-and-forget: queue SQS job for any stale entitlement resets + triggerBatchResetCustomerEntitlements({ + ctx, + fullCustomers, + }).catch((err) => { + ctx.logger.error("[CusBatchService.getPage] batch reset failed:", err); + Sentry.captureException(err); + }); + return finals; } diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 2daf723f6..dc696852b 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -25,6 +25,7 @@ import { import type { DrizzleCli } from "@/db/initDrizzle.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { withSpan } from "../analytics/tracer/spanUtils.js"; +import { resetCustomerEntitlements } from "./actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; import { RELEVANT_STATUSES } from "./cusProducts/CusProductService.js"; import { getFullCusQuery } from "./getFullCusQuery.js"; @@ -32,10 +33,8 @@ import { getFullCusQuery } from "./getFullCusQuery.js"; export class CusService { static async getFull({ - db, + ctx, idOrInternalId, - orgId, - env, inStatuses = RELEVANT_STATUSES, withEntities = false, entityId, @@ -44,10 +43,8 @@ export class CusService { allowNotFound = false, withEvents = false, }: { - db: DrizzleCli; + ctx: AutumnContext; idOrInternalId: string; - orgId: string; - env: AppEnv; inStatuses?: CusProductStatus[]; withEntities?: boolean; entityId?: string; @@ -56,6 +53,9 @@ export class CusService { allowNotFound?: boolean; withEvents?: boolean; }): Promise { + const { db, org, env } = ctx; + const orgId = org.id; + const includeInvoices = expand?.includes(CustomerExpand.Invoices) || false; const withTrialsUsed = expand?.includes(CustomerExpand.TrialsUsed) || false; @@ -109,7 +109,15 @@ export class CusService { } } - return data as FullCustomer; + const fullCus = data as FullCustomer; + + // Lazy reset stale entitlements (mutates fullCus in-memory + writes DB) + await resetCustomerEntitlements({ + fullCus, + ctx, + }); + + return fullCus; }, }); } @@ -418,25 +426,23 @@ export class CusService { } static async getByVercelId({ - db, + ctx, vercelInstallationId, - orgId, - env, expand, }: { - db: DrizzleCli; + ctx: AutumnContext; vercelInstallationId: string; - orgId: string; - env: AppEnv; expand?: (CustomerExpand | EntityExpand)[]; }) { + const { db, org, env } = ctx; + // This assumes the "processors" column is a JSONB object that can have a "vercel" object with "installation_id" const results = await db .select() .from(customers as unknown as Table) .where( and( - eq(customers.org_id, orgId), + eq(customers.org_id, org.id), eq(customers.env, env), // This JSON path works for Postgres jsonb column // Check for 'vercel.installation_id' inside the processors JSONB column @@ -449,10 +455,8 @@ export class CusService { if (!customer) return null; else { return (await CusService.getFull({ - db, + ctx, idOrInternalId: customer.internal_id, - orgId, - env, expand, })) as FullCustomer; } diff --git a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts index 48b95e465..5c936d813 100644 --- a/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts +++ b/server/src/internal/customers/actions/createWithDefaults/execute/executeAutumnCreateCustomerPlan.ts @@ -57,20 +57,18 @@ export const executeAutumnCreateCustomerPlan = async ({ if (error) { if (isUniqueConstraintError(error)) { - logger.info( - `Customer already exists, returning existing: ${fullCustomer.id || fullCustomer.email}`, - ); - const existingCustomer = await CusService.getFull({ - db, - idOrInternalId: fullCustomer.id || fullCustomer.internal_id, - orgId: ctx.org.id, - env: ctx.env, - withEntities: true, - withSubs: true, - expand: [CustomerExpand.Invoices], - }); - context.fullCustomer = existingCustomer; - return { type: "existing" }; + logger.info( + `Customer already exists, returning existing: ${fullCustomer.id || fullCustomer.email}`, + ); + const existingCustomer = await CusService.getFull({ + ctx, + idOrInternalId: fullCustomer.id || fullCustomer.internal_id, + withEntities: true, + withSubs: true, + expand: [CustomerExpand.Invoices], + }); + context.fullCustomer = existingCustomer; + return { type: "existing" }; } throw error; } @@ -80,10 +78,8 @@ export const executeAutumnCreateCustomerPlan = async ({ `Customer already exists (claimed or existing): ${fullCustomer.id || fullCustomer.internal_id}`, ); const existingCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: fullCustomer.internal_id, - orgId: ctx.org.id, - env: ctx.env, withEntities: true, withSubs: true, expand: [CustomerExpand.Invoices], diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/applyResetResults.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/applyResetResults.ts new file mode 100644 index 000000000..670fa6c0c --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/applyResetResults.ts @@ -0,0 +1,76 @@ +import type { + FullCustomer, + FullCustomerEntitlement, + Rollover, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.js"; +import type { ProcessResetResult } from "./processReset.js"; + +/** Find a cusEnt on the FullCustomer by ID. */ +const findCusEnt = ({ + fullCus, + cusEntId, +}: { + fullCus: FullCustomer; + cusEntId: string; +}): FullCustomerEntitlement | null => { + for (const cusProduct of fullCus.customer_products) { + for (const cusEnt of cusProduct.customer_entitlements) { + if (cusEnt.id === cusEntId) return cusEnt; + } + } + for (const cusEnt of fullCus.extra_customer_entitlements || []) { + if (cusEnt.id === cusEntId) return cusEnt; + } + return null; +}; + +/** + * Applies computed reset values to in-memory FullCustomer for all cusEnts, + * and runs rollover max-clearing only for DB-applied (non-skipped) ones. + */ +export const applyResetResults = async ({ + ctx, + fullCus, + computed, + skipped, +}: { + ctx: AutumnContext; + fullCus: FullCustomer; + computed: Array<{ cusEntId: string; result: ProcessResetResult }>; + skipped: string[]; +}): Promise => { + const { db } = ctx; + const skippedSet = new Set(skipped); + const clearingPromises: Promise[] = []; + + for (const { cusEntId, result } of computed) { + const original = findCusEnt({ fullCus, cusEntId }); + if (!original) continue; + + const { updates } = result; + if (updates.balance !== null) original.balance = updates.balance; + if (updates.additional_balance !== null) + original.additional_balance = updates.additional_balance; + original.adjustment = updates.adjustment; + if (updates.entities !== null) original.entities = updates.entities; + original.next_reset_at = updates.next_reset_at; + + // Only run rollover clearing for DB-applied entries. + // Skipped entries were already cleared by the winning request. + if (!skippedSet.has(cusEntId) && result.rolloverInsert) { + clearingPromises.push( + RolloverService.clearExcessRollovers({ + db, + newRows: result.rolloverInsert.rows, + fullCusEnt: original, + }), + ); + } + } + + if (clearingPromises.length > 0) { + await Promise.all(clearingPromises); + } +}; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts new file mode 100644 index 000000000..2a4b663c2 --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.ts @@ -0,0 +1,36 @@ +import { CusProductStatus } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import type { BatchResetCusEntsPayload } from "@/queue/workflows.js"; +import { CusService } from "../../CusService.js"; + +/** + * SQS worker handler: fetches each FullCustomer via CusService.getFull, + * which triggers the lazy reset internally. + */ +export const batchResetCustomerEntitlements = async ({ + ctx, + payload, +}: { + ctx: AutumnContext; + payload: BatchResetCusEntsPayload; +}): Promise => { + const { resets } = payload; + + if (resets.length === 0) return; + + const BATCH_SIZE = 100; + + for (let i = 0; i < resets.length; i += BATCH_SIZE) { + const batch = resets.slice(i, i + BATCH_SIZE); + + await Promise.all( + batch.map((reset) => + CusService.getFull({ + ctx, + idOrInternalId: reset.internalCustomerId, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + }), + ), + ); + } +}; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts new file mode 100644 index 000000000..fbb9f1395 --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/executeResetCache.ts @@ -0,0 +1,34 @@ +import { redis } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import type { ResetCusEntParam } from "@/internal/balances/utils/sql/client.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; +import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js"; + +/** + * Atomically resets cusEnt fields in the cached FullCustomer blob. + * Skips gracefully if the cache doesn't exist or the cusEnt was already reset. + * Fire-and-forget — failures are logged but don't propagate. + */ +export const executeResetCache = async ({ + ctx, + customerId, + resets, +}: { + ctx: AutumnContext; + customerId: string; + resets: ResetCusEntParam[]; +}): Promise => { + if (resets.length === 0) return; + + const { org, env, logger } = ctx; + + const cacheKey = buildFullCustomerCacheKey({ + orgId: org.id, + env, + customerId, + }); + + await tryRedisWrite(() => + redis.resetCustomerEntitlements(cacheKey, JSON.stringify({ resets })), + ); +}; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/getCusEntsNeedingReset.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/getCusEntsNeedingReset.ts new file mode 100644 index 000000000..158da96c6 --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/getCusEntsNeedingReset.ts @@ -0,0 +1,37 @@ +import { + CusProductStatus, + cusEntToCusPrice, + type FullCusEntWithFullCusProduct, + type FullCustomer, + fullCustomerToCustomerEntitlements, +} from "@autumn/shared"; + +/** Collects cusEnts from a FullCustomer that need resetting (next_reset_at < now). */ +export const getCusEntsNeedingReset = ({ + fullCus, + now, +}: { + fullCus: FullCustomer; + now: number; +}): FullCusEntWithFullCusProduct[] => { + const result: FullCusEntWithFullCusProduct[] = []; + + const cusEnts = fullCustomerToCustomerEntitlements({ + fullCustomer: fullCus, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + }); + + for (const cusEnt of cusEnts) { + if (!cusEnt.next_reset_at || cusEnt.next_reset_at >= now) continue; + + const cusPrice = cusEntToCusPrice({ cusEnt }); + if (cusPrice) continue; + + result.push({ + ...cusEnt, + customer_product: cusEnt.customer_product, + }); + } + + return result; +}; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/getResetAtUpdate.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/getResetAtUpdate.ts new file mode 100644 index 000000000..743c66b6f --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/getResetAtUpdate.ts @@ -0,0 +1,69 @@ +import type { + AppEnv, + EntInterval, + FullCusProduct, + Organization, +} from "@autumn/shared"; +import { UTCDate } from "@date-fns/utc"; +import { getDate, getMonth, setDate } from "date-fns"; +import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import { getNextResetAt } from "@/utils/timeUtils.js"; + +/** Computes next reset timestamp, adjusting for Stripe billing anchor on edge dates. */ +export const getResetAtUpdate = async ({ + curResetAt, + interval, + intervalCount, + cusProduct, + org, + env, +}: { + curResetAt: number; + interval: EntInterval; + intervalCount: number; + cusProduct: FullCusProduct | null; + org: Organization; + env: AppEnv; +}): Promise => { + const nextResetAt = getNextResetAt({ + curReset: new UTCDate(curResetAt), + interval, + intervalCount, + }); + + if (!cusProduct) return nextResetAt; + + // Only check Stripe anchor on edge dates (28th Feb, 30th of month) + const nextResetAtDate = new UTCDate(nextResetAt); + const nextResetAtDay = getDate(nextResetAtDate); + const nextResetAtMonth = getMonth(nextResetAtDate); + + const shouldCheck = + nextResetAtDay === 30 || (nextResetAtDay === 28 && nextResetAtMonth === 2); + + if (!shouldCheck) return nextResetAt; + + if ( + !cusProduct.subscription_ids || + cusProduct.subscription_ids.length === 0 + ) { + return nextResetAt; + } + + try { + const stripeCli = createStripeCli({ org, env }); + const subId = cusProduct.subscription_ids[0]; + const sub = await stripeCli.subscriptions.retrieve(subId); + + const billingCycleAnchor = sub.billing_cycle_anchor * 1000; + const billingCycleDay = getDate(new UTCDate(billingCycleAnchor)); + + if (billingCycleDay > nextResetAtDay) { + return setDate(nextResetAtDate, billingCycleDay).getTime(); + } + } catch (error) { + console.log(`[Lazy Reset] WARNING: Failed to check sub anchor: ${error}`); + } + + return nextResetAt; +}; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/processReset.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/processReset.ts new file mode 100644 index 000000000..390524e75 --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/processReset.ts @@ -0,0 +1,119 @@ +import { + cusEntToOptions, + type EntInterval, + type EntityBalance, + type FullCusEntWithFullCusProduct, + type FullCustomerEntitlement, + getStartingBalance, + isLifetimeEntitlement, + isUnlimitedEntitlement, + type Rollover, +} from "@autumn/shared"; +import { logger } from "better-auth"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils.js"; +import { getResetBalancesUpdate } from "@/internal/customers/cusProducts/cusEnts/groupByUtils.js"; +import { getResetAtUpdate } from "./getResetAtUpdate.js"; + +export type ResetUpdates = { + balance: number | null; + additional_balance: number | null; + adjustment: number; + entities: Record | null; + next_reset_at: number; +}; + +export type ProcessResetResult = { + updates: ResetUpdates; + rolloverInsert?: { rows: Rollover[]; fullCusEnt: FullCustomerEntitlement }; +}; + +/** Processes a single cusEnt reset. Returns updates + optional rollover insert, or null if skipped. */ +export const processReset = async ({ + cusEnt, + ctx, +}: { + cusEnt: FullCusEntWithFullCusProduct; + ctx: AutumnContext; +}): Promise => { + const ent = cusEnt.entitlement; + const cusProduct = cusEnt.customer_product; + + // Unlimited / lifetime cusEnts should never reach here + // (getCusEntsNeedingReset filters them out), but guard defensively + if ( + isUnlimitedEntitlement({ entitlement: ent }) || + isLifetimeEntitlement({ entitlement: ent }) + ) { + return null; + } + + const options = cusEntToOptions({ cusEnt }); + + const resetBalance = getStartingBalance({ + entitlement: cusEnt.entitlement, + options, + productQuantity: cusProduct?.quantity ?? 1, + }); + + if (!cusEnt.next_reset_at) { + logger.error( + `[customerEntitlement processReset] next_reset_at is null, cusEntId: ${cusEnt.id}`, + ); + return null; + } + + const { org, env } = ctx; + + // Compute next reset time (with Stripe anchor adjustment on edge dates) + const nextResetAt = await getResetAtUpdate({ + curResetAt: cusEnt.next_reset_at, + interval: ent.interval as EntInterval, + intervalCount: ent.interval_count, + cusProduct, + org, + env, + }); + + // Compute rollover before resetting balance + const rolloverUpdate = getRolloverUpdates({ + cusEnt, + nextResetAt: cusEnt.next_reset_at, + }); + + // Compute reset balance update + const resetBalanceUpdate = getResetBalancesUpdate({ + cusEnt, + allowance: resetBalance, + }); + + const updates: ResetUpdates = + "entities" in resetBalanceUpdate + ? { + balance: null, + additional_balance: null, + adjustment: 0, + entities: resetBalanceUpdate.entities, + next_reset_at: nextResetAt, + } + : { + balance: resetBalanceUpdate.balance, + additional_balance: resetBalanceUpdate.additional_balance, + adjustment: 0, + entities: null, + next_reset_at: nextResetAt, + }; + + let rolloverInsert: + | { rows: Rollover[]; fullCusEnt: FullCustomerEntitlement } + | undefined; + + if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) { + rolloverInsert = { + rows: rolloverUpdate.toInsert, + fullCusEnt: cusEnt, + }; + } + + return { updates, rolloverInsert }; +}; diff --git a/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts b/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts new file mode 100644 index 000000000..73067679d --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/resetCustomerEntitlements.ts @@ -0,0 +1,116 @@ +import type { FullCustomer } from "@autumn/shared"; +import * as Sentry from "@sentry/bun"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; +import { + type ResetCusEntParam, + resetCusEnts, +} from "@/internal/balances/utils/sql/client.js"; +import { applyResetResults } from "./applyResetResults.js"; +import { executeResetCache } from "./executeResetCache.js"; +import { getCusEntsNeedingReset } from "./getCusEntsNeedingReset.js"; +import { type ProcessResetResult, processReset } from "./processReset.js"; + +/** Maps a processReset result into the JSONB shape for the SQL function. */ +const toResetParam = ({ + cusEntId, + result, +}: { + cusEntId: string; + result: ProcessResetResult; +}): ResetCusEntParam => { + const { updates } = result; + const firstRollover = result.rolloverInsert?.rows[0] ?? null; + + return { + cus_ent_id: cusEntId, + balance: updates.balance, + additional_balance: updates.additional_balance, + adjustment: updates.adjustment, + entities: updates.entities, + next_reset_at: updates.next_reset_at, + rollover_insert: firstRollover, + }; +}; + +/** + * Lazily resets customer entitlements that have passed their next_reset_at. + * Uses an atomic Postgres function with per-row locking to prevent double-resets. + * Mutates the FullCustomer in-memory using the latest DB state from applied resets. + * Returns true if any entitlements were reset. + */ +export const resetCustomerEntitlements = async ({ + ctx, + fullCus, +}: { + ctx: AutumnContext; + fullCus: FullCustomer; +}): Promise => { + const now = Date.now(); + + const { logger } = ctx; + const customerId = fullCus.id || fullCus.internal_id; + + const cusEntsNeedingReset = getCusEntsNeedingReset({ fullCus, now }); + + if (cusEntsNeedingReset.length === 0) return false; + + 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; + }> = []; + + 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; + + // 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, + }); + + 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 new file mode 100644 index 000000000..19a88df42 --- /dev/null +++ b/server/src/internal/customers/actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.ts @@ -0,0 +1,42 @@ +import type { FullCustomer } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { type BatchResetCusEntsPayload, workflows } from "@/queue/workflows.js"; +import { getCusEntsNeedingReset } from "./getCusEntsNeedingReset"; + +/** + * Checks a list of FullCustomers for entitlements needing reset, + * and queues an SQS job with the cusEnt IDs if any are found. + */ +export const triggerBatchResetCustomerEntitlements = async ({ + ctx, + fullCustomers, +}: { + ctx: AutumnContext; + fullCustomers: FullCustomer[]; +}): Promise => { + const now = Date.now(); + + const resets: BatchResetCusEntsPayload["resets"] = []; + for (const fullCus of fullCustomers) { + const cusEntsNeedingReset = getCusEntsNeedingReset({ + fullCus, + now, + }); + + if (cusEntsNeedingReset.length === 0) continue; + + resets.push({ + internalCustomerId: fullCus.internal_id, + customerId: fullCus.id ?? "", + cusEntIds: cusEntsNeedingReset.map((cusEnt) => cusEnt.id), + }); + } + + if (resets.length === 0) return; + + await workflows.triggerBatchResetCusEnts({ + orgId: ctx.org.id, + env: ctx.env, + resets, + }); +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index a15de1310..652be9112 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -1,3 +1,4 @@ +import type { UpdateCustomerEntitlement } from "@autumn/shared"; import { type AppEnv, type CusProduct, @@ -15,11 +16,10 @@ import { type InsertCustomerEntitlement, type ResetCusEnt, } from "@autumn/shared"; -import { and, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; +import { and, eq, gt, inArray, isNull, lt, or, sql } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; -import type { UpdateCustomerEntitlement } from "@autumn/shared"; import RecaseError from "@/utils/errorUtils.js"; export class CusEntService { @@ -44,6 +44,17 @@ export class CusEntService { }); } + static async getByIds({ db, ids }: { db: DrizzleCli; ids: string[] }) { + if (ids.length === 0) return []; + + const data = await db + .select() + .from(customerEntitlements) + .where(inArray(customerEntitlements.id, ids)); + + return data as CustomerEntitlement[]; + } + static async getByFeature({ db, internalFeatureId, diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getCusEntByFeature.ts b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getCusEntByFeature.ts index b18dc589d..54c93e113 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getCusEntByFeature.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusEntUtils/getCusEntByFeature.ts @@ -1,29 +1,19 @@ -import type { - AppEnv, - FullCustomerEntitlement, - Organization, -} from "@autumn/shared"; -import type { DrizzleCli } from "../../../../../db/initDrizzle.js"; +import type { FullCustomerEntitlement } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "../../../CusService.js"; export const getCusEntByFeature = async ({ - db, - org, - env, + ctx, customerId, featureId, }: { - db: DrizzleCli; - org: Organization; - env: AppEnv; + ctx: AutumnContext; customerId: string; featureId: string; }) => { const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, }); const cusEnts = fullCus?.customer_products diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts index 632b9cb01..baebd26f9 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService.ts @@ -88,7 +88,24 @@ export class RolloverService { await db.insert(rollovers).values(rows).returning(); - let curRollovers = [...fullCusEnt.rollovers, ...rows]; + return RolloverService.clearExcessRollovers({ + db, + newRows: rows, + fullCusEnt, + }); + } + + /** Enforces the rollover max cap after new rollovers have been inserted into the DB. */ + static async clearExcessRollovers({ + db, + newRows, + fullCusEnt, + }: { + db: DrizzleCli; + newRows: Rollover[]; + fullCusEnt: FullCustomerEntitlement; + }): Promise { + const curRollovers = [...fullCusEnt.rollovers, ...newRows]; const { toDelete, toUpdate } = performMaximumClearing({ rows: curRollovers as Rollover[], @@ -103,17 +120,9 @@ export class RolloverService { await RolloverService.upsert({ db, rows: toUpdate }); } - // Return latest rollovers...? - curRollovers = curRollovers.filter((r) => toDelete.includes(r.id)); - curRollovers = curRollovers.map((r) => { - const updatedRow = toUpdate.find((u) => u.id === r.id); - if (updatedRow) { - return updatedRow; - } - return r; - }); - - return curRollovers; + return curRollovers + .filter((r) => !toDelete.includes(r.id)) + .map((r) => toUpdate.find((u) => u.id === r.id) ?? r); } static async delete({ db, ids }: { db: DrizzleCli; ids: string[] }) { diff --git a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts index 1f32300e1..8b90c7de7 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/groupByUtils.ts @@ -1,14 +1,17 @@ -import type { FullCustomerEntitlement } from "@autumn/shared"; +import type { EntityBalance, FullCustomerEntitlement } from "@autumn/shared"; import { notNullish } from "@/utils/genUtils.js"; +export type ResetBalancesUpdate = + | { entities: Record } + | { balance: number; additional_balance: number; adjustment: number }; + export const getResetBalancesUpdate = ({ cusEnt, allowance, }: { cusEnt: FullCustomerEntitlement; allowance?: number; -}) => { - let update = {}; +}): ResetBalancesUpdate => { const newBalance = notNullish(allowance) ? allowance! : cusEnt.entitlement.allowance || 0; @@ -21,14 +24,12 @@ export const getResetBalancesUpdate = ({ newEntities[entityId].balance = newBalance; newEntities[entityId].adjustment = 0; } - update = { entities: newEntities }; - } else { - update = { - balance: newBalance, - additional_balance: 0, - adjustment: 0, - }; + return { entities: newEntities }; } - return update; + return { + balance: newBalance, + additional_balance: 0, + adjustment: 0, + }; }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 115c830dc..4c5cf1e54 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -37,10 +37,8 @@ export const getApiCustomerExpand = async ({ if (!fullCus) { fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId || "", - orgId: org.id, - env, expand: expand as CustomerExpand[], withEntities: expand.includes(CustomerExpand.Entities), withSubs: true, diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts index 4d6d0dc85..975894008 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts @@ -2,7 +2,9 @@ import type { FullCustomer } from "@autumn/shared"; import { Decimal } from "decimal.js"; import type { Redis } from "ioredis"; import { redis } from "@/external/redis/initRedis.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { tryRedisRead } from "@/utils/cacheUtils/cacheUtils.js"; +import { resetCustomerEntitlements } from "../../actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; import { buildFullCustomerCacheKey } from "./fullCustomerCacheConfig.js"; /** @@ -80,23 +82,26 @@ const roundFullCustomerBalances = ( }; /** - * Get FullCustomer from Redis cache + * Get FullCustomer from Redis cache. Lazily resets stale entitlements. * @returns FullCustomer if found, null if not in cache */ export const getCachedFullCustomer = async ({ - orgId, - env, + ctx, customerId, entityId, redisInstance, }: { - orgId: string; - env: string; + ctx: AutumnContext; customerId: string; entityId?: string; redisInstance?: Redis; }): Promise => { - const cacheKey = buildFullCustomerCacheKey({ orgId, env, customerId }); + const { org, env } = ctx; + const cacheKey = buildFullCustomerCacheKey({ + orgId: org.id, + env, + customerId, + }); const redisClient = redisInstance || redis; const cached = await tryRedisRead( @@ -123,6 +128,9 @@ export const getCachedFullCustomer = async ({ fullCustomer.send_email_receipts = false; } + // Lazy reset stale entitlements (DB + in-memory + cache via Lua) + await resetCustomerEntitlements({ ctx, fullCus: fullCustomer }); + // Round balance fields to handle floating-point precision from JSON.NUMINCRBY return roundFullCustomerBalances(fullCustomer); }; diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts index 004c17f72..732788f08 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts @@ -1,5 +1,4 @@ import { - type AppEnv, type CheckParams, CustomerExpand, type Entity, @@ -29,7 +28,7 @@ export const getOrCreateCachedFullCustomer = async ({ }; source?: string; }): Promise => { - const { org, env, db, skipCache, logger } = ctx; + const { skipCache, logger } = ctx; const { customer_id: customerId, customer_data: customerData, @@ -40,13 +39,12 @@ export const getOrCreateCachedFullCustomer = async ({ let fullCustomer: FullCustomer | undefined; const fetchTimeMs = Date.now(); - // 1. Try cache first + // 1. Try cache first (getCachedFullCustomer handles lazy reset internally) let setCache = true; if (customerId && !skipCache) { fullCustomer = (await getCachedFullCustomer({ - orgId: org.id, - env, + ctx, customerId, entityId, })) ?? undefined; @@ -57,13 +55,11 @@ export const getOrCreateCachedFullCustomer = async ({ } } - // 2. Try DB if not in cache + // 2. Try DB if not in cache (CusService.getFull handles lazy reset internally) if (!fullCustomer && customerId) { fullCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env: env as AppEnv, withEntities: true, withSubs: true, expand: [CustomerExpand.Invoices], @@ -91,10 +87,8 @@ export const getOrCreateCachedFullCustomer = async ({ setCache = true; fullCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: fullCustomer.id || fullCustomer.internal_id, - orgId: org.id, - env: env as AppEnv, withEntities: true, withSubs: true, entityId, diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts index 37e11126c..47611e915 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.ts @@ -1,5 +1,4 @@ import { - type AppEnv, CustomerExpand, CustomerNotFoundError, EntityNotFoundError, @@ -25,13 +24,12 @@ export const getOrSetCachedFullCustomer = async ({ entityId?: string; source?: string; }): Promise => { - const { org, env, db, skipCache, logger } = ctx; + const { skipCache, logger } = ctx; - // 1. Try cache first + // 1. Try cache first (getCachedFullCustomer handles lazy reset internally) if (!skipCache) { const cached = await getCachedFullCustomer({ - orgId: org.id, - env, + ctx, customerId, }); @@ -63,10 +61,8 @@ export const getOrSetCachedFullCustomer = async ({ const fetchTimeMs = Date.now(); const fullCustomer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env: env as AppEnv, withEntities: true, withSubs: true, expand: [CustomerExpand.Invoices], diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index eb4a5edf1..e3b6b0ece 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -54,10 +54,8 @@ export const getOrCreateCustomer = async ({ if (!skipGet && customerId) { customer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, inStatuses, withEntities, entityId, @@ -88,10 +86,8 @@ export const getOrCreateCustomer = async ({ if (updated) { customer = await CusService.getFull({ - db, + ctx, idOrInternalId: customer.id || customer.internal_id, - orgId: org.id, - env, inStatuses, withEntities, entityId, diff --git a/server/src/internal/customers/handlers/handleTransferProductV2.ts b/server/src/internal/customers/handlers/handleTransferProductV2.ts index 15bf9844c..decd0e210 100644 --- a/server/src/internal/customers/handlers/handleTransferProductV2.ts +++ b/server/src/internal/customers/handlers/handleTransferProductV2.ts @@ -40,10 +40,8 @@ export const handleTransferProductV2 = createRoute({ } const customer = await CusService.getFull({ + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, - db, withEntities: true, }); diff --git a/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts b/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts index 81302f0bc..0d79e5d94 100644 --- a/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts +++ b/server/src/internal/customers/handlers/handleUpdateBalancesV2.ts @@ -12,15 +12,13 @@ export const handleUpdateBalancesV2 = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); - const { org, env, db, features } = ctx; + const { features } = ctx; const { customer_id } = c.req.param(); const { balances, entity_id } = c.req.valid("json"); const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, entityId: entity_id, }); diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomer.ts b/server/src/internal/customers/internalHandlers/handleGetCustomer.ts index 943c73d34..b72da7cc5 100644 --- a/server/src/internal/customers/internalHandlers/handleGetCustomer.ts +++ b/server/src/internal/customers/internalHandlers/handleGetCustomer.ts @@ -7,13 +7,11 @@ import { CusService } from "@/internal/customers/CusService"; */ export const handleGetCustomer = createRoute({ handler: async (c) => { - const { db, org, env } = c.get("ctx"); + const ctx = c.get("ctx"); const { customer_id } = c.req.param(); const fullCus = await CusService.getFull({ - db, - orgId: org.id, - env, + ctx, idOrInternalId: customer_id, withEntities: true, expand: [CustomerExpand.Invoices], diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts b/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts index 802adafe1..89ae36de3 100644 --- a/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts +++ b/server/src/internal/customers/internalHandlers/handleGetCustomerEvents.ts @@ -19,13 +19,11 @@ export const handleGetCustomerEvents = createRoute({ query: QuerySchema, handler: async (c) => { const ctx = c.get("ctx"); - const { db, org, env } = ctx; const { customer_id } = c.req.param(); const { interval, limit } = c.req.valid("query"); const customer = await getCachedFullCustomer({ - orgId: org.id, - env, + ctx, customerId: customer_id, }); diff --git a/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts b/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts index 1471a9959..248b3a6cf 100644 --- a/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts +++ b/server/src/internal/customers/internalHandlers/handleGetCustomerProduct.ts @@ -22,14 +22,13 @@ export const handleGetCustomerProduct = createRoute({ entity_id: z.string().optional(), }), handler: async (c) => { - const { db, org, env, features } = c.get("ctx"); + const ctx = c.get("ctx"); + const { db, org, env, features } = ctx; const { customer_id, product_id } = c.req.param(); const { version, customer_product_id, entity_id } = c.req.valid("query"); const customer = await CusService.getFull({ - db, - orgId: org.id, - env, + ctx, idOrInternalId: customer_id, withEntities: true, entityId: entity_id, diff --git a/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts b/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts index 54c8fc8ef..3b57e7bd1 100644 --- a/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts +++ b/server/src/internal/customers/internalHandlers/handleGetFullCustomers.ts @@ -19,9 +19,11 @@ export const handleGetFullCustomers = createRoute({ filters: z.any().optional(), }), handler: async (c) => { - const { db, org, env } = c.get("ctx"); + const ctx = c.get("ctx"); const { search, page_size, page, last_item, filters } = c.req.valid("json"); + const { org, env, db } = ctx; + const { data: customers } = await CusSearchService.search({ db, orgId: org.id, @@ -34,9 +36,7 @@ export const handleGetFullCustomers = createRoute({ }); const fullCustomers = await CusBatchService.getByInternalIds({ - db, - org, - env, + ctx, internalCustomerIds: customers.map( (customer: Customer) => customer.internal_id, ), diff --git a/server/src/internal/entities/actions/deleteEntity.ts b/server/src/internal/entities/actions/deleteEntity.ts index d4d4b7c9e..9fc4a3513 100644 --- a/server/src/internal/entities/actions/deleteEntity.ts +++ b/server/src/internal/entities/actions/deleteEntity.ts @@ -32,10 +32,8 @@ export const deleteEntity = async ({ const { db, org, env, features, logger } = ctx; const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env, withEntities: true, }); diff --git a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts index 443d2d573..0a47e6e36 100644 --- a/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts +++ b/server/src/internal/entities/entityUtils/apiEntityUtils/getApiEntityExpand.ts @@ -27,10 +27,8 @@ export const getApiEntityExpand = async ({ if (!fullCus) { fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId || "", - orgId: org.id, - env, entityId, }); } diff --git a/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts b/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts index f4ffce7b5..5f1aa5858 100644 --- a/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts +++ b/server/src/internal/entities/handlers/handleCreateEntity/autoCreateEntity.ts @@ -49,10 +49,8 @@ export const autoCreateEntity = async ({ if (!fullCus) { fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, withEntities: true, entityId, }); diff --git a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts index 242ce55d7..aa50df461 100644 --- a/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts +++ b/server/src/internal/entities/handlers/handleDeleteEntity/handleDeleteEntity.ts @@ -25,10 +25,8 @@ export const handleDeleteEntity = createRoute({ const { db, org, env, features, logger } = ctx; const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, withEntities: true, }); diff --git a/server/src/internal/entities/handlers/handleListEntities.ts b/server/src/internal/entities/handlers/handleListEntities.ts index b432253ff..06debdcfa 100644 --- a/server/src/internal/entities/handlers/handleListEntities.ts +++ b/server/src/internal/entities/handlers/handleListEntities.ts @@ -6,13 +6,9 @@ export const handleListEntities = createRoute({ const { customer_id } = c.req.param(); const ctx = c.get("ctx"); - const { db, org, env } = ctx; - const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, }); return c.json({ diff --git a/server/src/internal/events/handlers/handleExternalAggregateEvents.ts b/server/src/internal/events/handlers/handleExternalAggregateEvents.ts index 534e080fa..de4f39db2 100644 --- a/server/src/internal/events/handlers/handleExternalAggregateEvents.ts +++ b/server/src/internal/events/handlers/handleExternalAggregateEvents.ts @@ -36,10 +36,8 @@ export const handleExternalAggregateEvents = createRoute({ }); const customer = await CusService.getFull({ - db, - orgId: org.id, + ctx, idOrInternalId: customer_id, - env, withSubs: true, }); diff --git a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts index 6114e5282..0c8ba4f41 100644 --- a/server/src/internal/migrations/migrationSteps/migrateCustomer.ts +++ b/server/src/internal/migrations/migrationSteps/migrateCustomer.ts @@ -26,8 +26,7 @@ export const migrateCustomer = async ({ toProduct: FullProduct; migrationJob?: MigrationJob; }) => { - const { db, org, env } = ctx; - const orgId = org.id; + const { db } = ctx; // Create customer-specific logger const customerLogger = createMigrationCustomerLogger({ @@ -39,10 +38,8 @@ export const migrateCustomer = async ({ try { const fullCus = await CusService.getFull({ - db, + ctx: customerCtx, idOrInternalId: customerId, - orgId, - env, withEntities: true, inStatuses: ACTIVE_STATUSES, }); diff --git a/server/src/internal/misc/components/handlers/handleGetPricingTable.ts b/server/src/internal/misc/components/handlers/handleGetPricingTable.ts index fa50d115e..50ea45071 100644 --- a/server/src/internal/misc/components/handlers/handleGetPricingTable.ts +++ b/server/src/internal/misc/components/handlers/handleGetPricingTable.ts @@ -29,9 +29,7 @@ export const handleGetPricingTable = createRoute({ return undefined; } return await CusService.getFull({ - db, - orgId: org.id, - env, + ctx, idOrInternalId: customerId, }); })(), diff --git a/server/src/internal/products/handlers/handleListPlans.ts b/server/src/internal/products/handlers/handleListPlans.ts index 787a45c46..481482ae2 100644 --- a/server/src/internal/products/handlers/handleListPlans.ts +++ b/server/src/internal/products/handlers/handleListPlans.ts @@ -29,10 +29,8 @@ export const handleListPlans = createRoute({ }), customer_id ? CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, entityId: entity_id, withEntities: true, withSubs: true, diff --git a/server/src/internal/products/handlers/handleListPlans/handleListPlansV2.ts b/server/src/internal/products/handlers/handleListPlans/handleListPlansV2.ts index 848069216..de3c40ca6 100644 --- a/server/src/internal/products/handlers/handleListPlans/handleListPlansV2.ts +++ b/server/src/internal/products/handlers/handleListPlans/handleListPlansV2.ts @@ -29,10 +29,8 @@ export const handleListPlansV2 = createRoute({ }), customer_id ? CusService.getFull({ - db, + ctx, idOrInternalId: customer_id, - orgId: org.id, - env, entityId: entity_id, withEntities: true, withSubs: true, diff --git a/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts index 529a71b41..e96f5ec9f 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreePaidProduct.ts @@ -62,18 +62,14 @@ export const triggerFreePaidProduct = async ({ const [fullReferrer, fullRedeemer] = await Promise.all([ CusService.getFull({ - db, + ctx, idOrInternalId: referralCode.internal_customer_id, - orgId: org.id, - env, withEntities: true, withSubs: true, }), CusService.getFull({ - db, + ctx, idOrInternalId: redeemer.id!, - orgId: org.id, - env, withEntities: true, withSubs: true, }), diff --git a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts index 1d4d3f7a5..f1a324714 100644 --- a/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts +++ b/server/src/internal/rewards/referralUtils/triggerFreeProduct.ts @@ -77,17 +77,13 @@ export const triggerFreeProduct = async ({ const [fullReferrer, fullRedeemer] = await Promise.all([ CusService.getFull({ - db, + ctx, idOrInternalId: referralCode.internal_customer_id, - orgId: org.id, - env, allowNotFound: true, }), CusService.getFull({ - db, + ctx, idOrInternalId: redeemer.id!, - orgId: org.id, - env, }), ]); diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index a756c4719..23ba5a233 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -22,6 +22,8 @@ export enum JobName { ClearCreditSystemCustomerCache = "clear-credit-system-customer-cache", + BatchResetCusEnts = "batch-reset-cus-ents", + // Hatchet workflows VerifyCacheConsistency = "verify-cache-consistency", } diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index 041afc9be..9e606b603 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -8,6 +8,7 @@ import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBa import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; +import { batchResetCustomerEntitlements } from "@/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.js"; import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js"; import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js"; @@ -183,6 +184,19 @@ export const processMessage = async ({ ctx, payload: job.data, }); + return; + } + + if (job.name === JobName.BatchResetCusEnts) { + if (!ctx) { + workerLogger.error("No context found for batch reset cus ents job"); + return; + } + await batchResetCustomerEntitlements({ + ctx, + payload: job.data, + }); + return; } } catch (error) { Sentry.captureException(error); diff --git a/server/src/queue/queueUtils.ts b/server/src/queue/queueUtils.ts index 6cfbbe21f..e859af8e1 100644 --- a/server/src/queue/queueUtils.ts +++ b/server/src/queue/queueUtils.ts @@ -9,7 +9,10 @@ import { import type { ClearCreditSystemCachePayload } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js"; import type { GenerateFeatureDisplayPayload } from "@/internal/features/workflows/generateFeatureDisplay.js"; import { JobName } from "./JobName.js"; -import type { SendProductsUpdatedPayload } from "./workflows.js"; +import type { + BatchResetCusEntsPayload, + SendProductsUpdatedPayload, +} from "./workflows.js"; export interface Payloads { [JobName.RewardMigration]: { @@ -45,6 +48,7 @@ export interface Payloads { [JobName.ClearCreditSystemCustomerCache]: ClearCreditSystemCachePayload; [JobName.GenerateFeatureDisplay]: GenerateFeatureDisplayPayload; [JobName.SendProductsUpdated]: SendProductsUpdatedPayload; + [JobName.BatchResetCusEnts]: BatchResetCusEntsPayload; [JobName.VerifyCacheConsistency]: { customerId: string; orgId: string; diff --git a/server/src/queue/workflows.ts b/server/src/queue/workflows.ts index 257bef33b..be2309ce2 100644 --- a/server/src/queue/workflows.ts +++ b/server/src/queue/workflows.ts @@ -35,6 +35,16 @@ export type GrantCheckoutRewardPayload = { stripeSubscriptionId?: string; }; +export type BatchResetCusEntsPayload = { + orgId: string; + env: string; + resets: { + internalCustomerId: string; + customerId: string; + cusEntIds: string[]; + }[]; +}; + // ============ Workflow Registry ============ type WorkflowRunner = "sqs" | "hatchet"; @@ -65,6 +75,11 @@ const workflowRegistry = { jobName: JobName.GrantCheckoutReward, runner: "sqs", } as WorkflowConfig, + + batchResetCusEnts: { + jobName: JobName.BatchResetCusEnts, + runner: "sqs", + } as WorkflowConfig, } as const; // ============ Type Utilities ============ @@ -131,4 +146,9 @@ export const workflows = { payload: GrantCheckoutRewardPayload, options?: TriggerOptions, ) => triggerWorkflow({ name: "grantCheckoutReward", payload, options }), + + triggerBatchResetCusEnts: ( + payload: BatchResetCusEntsPayload, + options?: TriggerOptions, + ) => triggerWorkflow({ name: "batchResetCusEnts", payload, options }), }; diff --git a/server/src/utils/scriptUtils/initCustomer.ts b/server/src/utils/scriptUtils/initCustomer.ts index be322ee1e..f9960d2e7 100644 --- a/server/src/utils/scriptUtils/initCustomer.ts +++ b/server/src/utils/scriptUtils/initCustomer.ts @@ -1,7 +1,6 @@ import { type AppEnv, type Customer, - type CustomerData, type Organization, ProcessorType, } from "@autumn/shared"; @@ -206,80 +205,3 @@ export const attachPaymentMethod = async ({ console.log("failed to attach payment method", error); } }; - -// V2 initializes the customer in Stripe, then creates the customer in Autumn -export const initCustomerV2 = async ({ - autumn, - customerId, - customerData, - org, - env, - db, - attachPm, - withTestClock = true, -}: { - autumn: AutumnInt; - customerId: string; - customerData?: CustomerData; - org: Organization; - env: AppEnv; - db: DrizzleCli; - attachPm?: "success" | "fail"; - withTestClock?: boolean; -}) => { - const name = customerId; - const email = `${customerId}@example.com`; - const fingerprint_ = ""; - const stripeCli = createStripeCli({ org, env }); - - let testClockId: string | undefined; - - if (withTestClock) { - const testClock = await stripeCli.testHelpers.testClocks.create({ - frozen_time: Math.floor(Date.now() / 1000), - }); - testClockId = testClock.id; - } - - // 1. Create stripe customer - const stripeCus = await stripeCli.customers.create({ - email, - name, - test_clock: testClockId, - }); - - // 2. Create customer - try { - await autumn.customers.delete(customerId); - } catch (_error) {} - - await autumn.customers.create({ - id: customerId, - name, - email, - fingerprint: customerData?.fingerprint || undefined, - stripe_id: stripeCus.id, - metadata: {}, - }); - - // 3. Attach payment method - if (attachPm) { - await attachPaymentMethod({ - stripeCli, - stripeCusId: stripeCus.id, - type: attachPm, - }); - } - - const customer = await CusService.getFull({ - db, - idOrInternalId: customerId, - orgId: org.id, - env: env, - }); - - return { - testClockId: testClockId || "", - customer, - }; -}; diff --git a/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts b/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts index 3e4748431..e01c57ba7 100644 --- a/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts +++ b/server/src/utils/scriptUtils/testUtils/cusTestUtils.ts @@ -1,28 +1,24 @@ -import type { Organization } from "@autumn/shared"; import { AppEnv } from "@autumn/shared"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; import { cusProductToSub } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; export const getCusSub = async ({ - db, - org, + ctx, customerId, productId, }: { - db: DrizzleCli; - org: Organization; + ctx: AutumnContext; customerId: string; productId: string; }) => { + const { org } = ctx; const env = AppEnv.Sandbox; const stripeCli = createStripeCli({ org, env }); const fullCus = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - env, - orgId: org.id, }); const cusProduct = fullCus.customer_products.find( diff --git a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts index 346542baf..584619349 100644 --- a/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts +++ b/server/src/utils/scriptUtils/testUtils/initCustomerV3.ts @@ -92,12 +92,9 @@ export const initCustomerV3 = async ({ }); } - const { db, org, env } = ctx; const customer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId: org.id, - env: env, }); return { diff --git a/server/tests/advanced/multiFeature/multiFeature1.test.ts b/server/tests/advanced/multiFeature/multiFeature1.test.ts index 4728e0d9c..e1842e085 100644 --- a/server/tests/advanced/multiFeature/multiFeature1.test.ts +++ b/server/tests/advanced/multiFeature/multiFeature1.test.ts @@ -6,7 +6,7 @@ import { getUsageCusEnt, } from "@tests/utils/cusProductUtils/cusEntSearchUtils.js"; import { getMainCusProduct } from "@tests/utils/cusProductUtils/cusProductUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import ctx, { type TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; @@ -64,23 +64,17 @@ const premium = constructProduct({ }); export const getPrepaidAndUsageCusEnts = async ({ + ctx, customerId, - db, - orgId, - env, featureId, }: { + ctx: TestContext; customerId: string; - db: DrizzleCli; - orgId: string; - env: AppEnv; featureId: string; }) => { const mainCusProduct = await getMainCusProduct({ + ctx, customerId, - db, - orgId, - env, }); const prepaidCusEnt = getPrepaidCusEnt({ @@ -147,10 +141,8 @@ describe(`${chalk.yellowBright( }); const { prepaidCusEnt, usageCusEnt } = await getPrepaidAndUsageCusEnts({ + ctx, customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -172,10 +164,8 @@ describe(`${chalk.yellowBright( await timeout(3000); const { prepaidCusEnt, usageCusEnt } = await getPrepaidAndUsageCusEnts({ + ctx, customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -196,10 +186,8 @@ describe(`${chalk.yellowBright( await timeout(2500); const { usageCusEnt } = await getPrepaidAndUsageCusEnts({ + ctx, customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -211,10 +199,8 @@ describe(`${chalk.yellowBright( const { prepaidCusEnt, usageCusEnt: newUsageCusEnt } = await getPrepaidAndUsageCusEnts({ + ctx, customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); diff --git a/server/tests/advanced/multiFeature/multiFeature2.test.ts b/server/tests/advanced/multiFeature/multiFeature2.test.ts index 342cfcced..8ee18aec2 100644 --- a/server/tests/advanced/multiFeature/multiFeature2.test.ts +++ b/server/tests/advanced/multiFeature/multiFeature2.test.ts @@ -56,22 +56,14 @@ const premium = constructProduct({ export const getLifetimeAndUsageCusEnts = async ({ customerId, - db, - orgId, - env, featureId, }: { customerId: string; - db: DrizzleCli; - orgId: string; - env: AppEnv; featureId: string; }) => { const mainCusProduct = await getMainCusProduct({ + ctx, customerId: customerId, - db, - orgId, - env, }); const lifetimeCusEnt = getLifetimeFreeCusEnt({ @@ -127,9 +119,6 @@ describe(`${chalk.yellowBright( const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -153,9 +142,6 @@ describe(`${chalk.yellowBright( const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -181,9 +167,6 @@ describe(`${chalk.yellowBright( const { lifetimeCusEnt, usageCusEnt: newUsageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); diff --git a/server/tests/advanced/multiFeature/multiFeature3.test.ts b/server/tests/advanced/multiFeature/multiFeature3.test.ts index 60297e901..ebb72410a 100644 --- a/server/tests/advanced/multiFeature/multiFeature3.test.ts +++ b/server/tests/advanced/multiFeature/multiFeature3.test.ts @@ -44,22 +44,14 @@ const pro = constructProduct({ export const getLifetimeAndUsageCusEnts = async ({ customerId, - db, - orgId, - env, featureId, }: { customerId: string; - db: DrizzleCli; - orgId: string; - env: AppEnv; featureId: string; }) => { const mainCusProduct = await getMainCusProduct({ + ctx, customerId, - db, - orgId, - env, }); const lifetimeCusEnt = getLifetimeFreeCusEnt({ @@ -116,9 +108,6 @@ describe(`${chalk.yellowBright( const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -144,9 +133,6 @@ describe(`${chalk.yellowBright( const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); @@ -165,9 +151,6 @@ describe(`${chalk.yellowBright( const { lifetimeCusEnt, usageCusEnt } = await getLifetimeAndUsageCusEnts({ customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, featureId: TestFeature.Messages, }); diff --git a/server/tests/advanced/referrals/paid/referrals13.test.ts b/server/tests/advanced/referrals/paid/referrals13.test.ts index ea2c90cc1..f6fb312ce 100644 --- a/server/tests/advanced/referrals/paid/referrals13.test.ts +++ b/server/tests/advanced/referrals/paid/referrals13.test.ts @@ -274,21 +274,19 @@ describe(`${chalk.yellowBright( expect(actualTotal!).toBeLessThanOrEqual(expectedTotal / 2); } - const dbCustomers = await Promise.all( - [mainCustomerId, redeemer].map((x) => - CusService.getFull({ - db, - idOrInternalId: x, - orgId: org.id, - env, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Expired, - ], - }), - ), - ); + const dbCustomers = await Promise.all( + [mainCustomerId, redeemer].map((x) => + CusService.getFull({ + ctx, + idOrInternalId: x, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Expired, + ], + }), + ), + ); const expectedProducts = [ [ diff --git a/server/tests/advanced/referrals/paid/referrals14.test.ts b/server/tests/advanced/referrals/paid/referrals14.test.ts index 632b34ffc..ef72d0da4 100644 --- a/server/tests/advanced/referrals/paid/referrals14.test.ts +++ b/server/tests/advanced/referrals/paid/referrals14.test.ts @@ -306,21 +306,19 @@ describe(`${chalk.yellowBright( expect(premiumInvoice.total).toBeLessThan(premiumPrice); } - const dbCustomers = await Promise.all( - [mainCustomerId, redeemer].map((x) => - CusService.getFull({ - db, - idOrInternalId: x, - orgId: org.id, - env, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Expired, - ], - }), - ), - ); + const dbCustomers = await Promise.all( + [mainCustomerId, redeemer].map((x) => + CusService.getFull({ + ctx, + idOrInternalId: x, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Expired, + ], + }), + ), + ); const expectedProducts = [ [ diff --git a/server/tests/advanced/referrals/paid/referrals15.test.ts b/server/tests/advanced/referrals/paid/referrals15.test.ts index 31be4f88a..58c5a457a 100644 --- a/server/tests/advanced/referrals/paid/referrals15.test.ts +++ b/server/tests/advanced/referrals/paid/referrals15.test.ts @@ -289,21 +289,19 @@ describe(`${chalk.yellowBright( expect(redeemerProInvoice.total).toBe(expectedTotal); } - const dbCustomers = await Promise.all( - [mainCustomerId, redeemer].map((x) => - CusService.getFull({ - db, - idOrInternalId: x, - orgId: org.id, - env, - inStatuses: [ - CusProductStatus.Active, - CusProductStatus.PastDue, - CusProductStatus.Expired, - ], - }), - ), - ); + const dbCustomers = await Promise.all( + [mainCustomerId, redeemer].map((x) => + CusService.getFull({ + ctx, + idOrInternalId: x, + inStatuses: [ + CusProductStatus.Active, + CusProductStatus.PastDue, + CusProductStatus.Expired, + ], + }), + ), + ); const expectedProducts = [ [ 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/balances/cron/loose-reset.test.ts b/server/tests/balances/cron/loose-reset.test.ts index 8e15b1fbd..a2badb8d1 100644 --- a/server/tests/balances/cron/loose-reset.test.ts +++ b/server/tests/balances/cron/loose-reset.test.ts @@ -84,12 +84,10 @@ describe(`${chalk.yellowBright("loose-reset: test getActiveResetPassed for loose // Wait for sync await new Promise((resolve) => setTimeout(resolve, 2000)); - const fullCustomer = await CusService.getFull({ - db: ctx.db, - idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, - }); + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); const cusEnt = await findCustomerEntitlement({ ctx, diff --git a/server/tests/balances/utils/findCustomerEntitlement.ts b/server/tests/balances/utils/findCustomerEntitlement.ts index 9f65458f7..b2ac18a0a 100644 --- a/server/tests/balances/utils/findCustomerEntitlement.ts +++ b/server/tests/balances/utils/findCustomerEntitlement.ts @@ -20,10 +20,8 @@ export const findCustomerEntitlement = async ({ fullCustomer = fullCustomer || (await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, })); const cusEnts = fullCustomerToCustomerEntitlements({ diff --git a/server/tests/balances/utils/getCustomerEntitlement.ts b/server/tests/balances/utils/getCustomerEntitlement.ts index a6e460074..c3148eda0 100644 --- a/server/tests/balances/utils/getCustomerEntitlement.ts +++ b/server/tests/balances/utils/getCustomerEntitlement.ts @@ -12,10 +12,8 @@ export const findCustomerEntitlement = async ({ featureId?: string; }) => { const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const cusEnts = fullCustomerToCustomerEntitlements({ diff --git a/server/tests/balances/utils/getCustomerEntitlements.ts b/server/tests/balances/utils/getCustomerEntitlements.ts index 73e9c6820..58fd25cf8 100644 --- a/server/tests/balances/utils/getCustomerEntitlements.ts +++ b/server/tests/balances/utils/getCustomerEntitlements.ts @@ -12,10 +12,8 @@ export const getCustomerEntitlement = async ({ featureId?: string; }) => { const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const cusEnts = fullCustomerToCustomerEntitlements({ 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 diff --git a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v2-attach-v1-update-quantity.test.ts b/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v2-attach-v1-update-quantity.test.ts index 5464325b8..c077d61a3 100644 --- a/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v2-attach-v1-update-quantity.test.ts +++ b/server/tests/integration/billing/attach/edge-cases/v1-v2-compatibility/prepaid/v2-attach-v1-update-quantity.test.ts @@ -51,10 +51,8 @@ const getStripePrepaidSubscriptionItem = async ({ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/legacy/attach/attach-misc.test.ts b/server/tests/integration/billing/legacy/attach/attach-misc.test.ts index d13eae3d0..70cf51281 100644 --- a/server/tests/integration/billing/legacy/attach/attach-misc.test.ts +++ b/server/tests/integration/billing/legacy/attach/attach-misc.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { type AppEnv, ErrCode } from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import { getMainCusProduct } from "@tests/utils/cusProductUtils/cusProductUtils.js"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; @@ -87,7 +87,7 @@ test.concurrent(`${chalk.yellowBright("attach-misc: convert collection method fr actions: [], }); - const { db, org, env, stripeCli } = ctx; + const { stripeCli } = ctx; // Attach with invoice option const res = await autumnV1.attach({ @@ -113,10 +113,8 @@ test.concurrent(`${chalk.yellowBright("attach-misc: convert collection method fr await timeout(10000); const cusProduct = await getMainCusProduct({ - db, + ctx, customerId, - orgId: org.id, - env: env as AppEnv, productGroup: pro.group ?? undefined, }); diff --git a/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts b/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts index 4fed1ea61..d3a57dc7e 100644 --- a/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts +++ b/server/tests/integration/billing/legacy/attach/checkout/legacy-checkout-advanced.test.ts @@ -326,10 +326,8 @@ test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 4: separate subs due // Verify separate subscriptions const fullCus = await CusService.getFull({ + ctx, idOrInternalId: customerId, - db, - orgId: org.id, - env, }); const cusProducts = fullCus.customer_products; @@ -392,10 +390,8 @@ test.concurrent(`${chalk.yellowBright("legacy-checkout-adv 4: separate subs due // Verify add-on is on entity 2's subscription const fullCusAfterAddOn = await CusService.getFull({ + ctx, idOrInternalId: customerId, - db, - orgId: org.id, - env, }); const addOnProd = fullCusAfterAddOn.customer_products.find( diff --git a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts index c7aca3f09..bf7050f11 100644 --- a/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts +++ b/server/tests/integration/billing/legacy/attach/invoice/legacy-attach-invoice-mode-advanced.test.ts @@ -304,10 +304,8 @@ test.concurrent(`${chalk.yellowBright("legacy-inv-mode-adv 4: separate subs due // Verify separate subscriptions const fullCus = await CusService.getFull({ + ctx, idOrInternalId: customerId, - db, - orgId: org.id, - env, }); const cusProducts = fullCus.customer_products; diff --git a/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts b/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts index d03e707e6..aee31caef 100644 --- a/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts +++ b/server/tests/integration/billing/legacy/attach/new/legacy-merge-interval.test.ts @@ -80,8 +80,7 @@ test.concurrent(`${chalk.yellowBright("legacy-merge-interval 1: entity attach mi // Verify Stripe subscription period_end matches const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, + ctx, customerId, productId: pro.id, }); @@ -154,8 +153,7 @@ test.concurrent(`${chalk.yellowBright("legacy-merge-interval 2: entity attach an // Verify Stripe subscription period_end matches const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, + ctx, customerId, productId: proAnnual.id, }); @@ -230,8 +228,7 @@ test.concurrent(`${chalk.yellowBright("legacy-merge-interval 3: entity attach an // Verify at least one subscription item has matching period_end const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, + ctx, customerId, productId: proAnnual.id, }); diff --git a/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts b/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts index 7553cce3f..2159ebb39 100644 --- a/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts +++ b/server/tests/integration/billing/legacy/attach/separate/legacy-separate.test.ts @@ -99,10 +99,8 @@ test.concurrent(`${chalk.yellowBright("legacy-separate 1: separate subs via invo // Verify different subscription IDs per entity const fullCus = await CusService.getFull({ + ctx, idOrInternalId: customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, }); const cusProducts = fullCus.customer_products; const entity1Prod = cusProducts.find((cp) => cp.entity_id === entities[0].id); @@ -241,10 +239,8 @@ test.concurrent(`${chalk.yellowBright("legacy-separate 2: separate subs via forc // Verify different subscription IDs per entity let fullCus = await CusService.getFull({ + ctx, idOrInternalId: customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, }); let cusProducts = fullCus.customer_products; const entity1Prod = cusProducts.find((cp) => cp.entity_id === entities[0].id); @@ -305,10 +301,8 @@ test.concurrent(`${chalk.yellowBright("legacy-separate 2: separate subs via forc // Verify add-on's sub ID matches entity 2's sub ID fullCus = await CusService.getFull({ + ctx, idOrInternalId: customerId, - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, }); cusProducts = fullCus.customer_products; const addOnProd = cusProducts.find((cp) => cp.product.id === addOn.id); diff --git a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts index 8fc5f393e..43e5fd593 100644 --- a/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts +++ b/server/tests/integration/billing/legacy/attach/upgrade/legacy-upgrade.test.ts @@ -226,8 +226,7 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade 2: upgrade monthly to annu // Verify Stripe subscription period_end matches checkout preview const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, + ctx, customerId, productId: proAnnual.id, }); @@ -297,8 +296,7 @@ test.concurrent(`${chalk.yellowBright("legacy-upgrade 3: upgrade monthly to annu // Verify Stripe subscription period_end matches checkout preview const sub = await getCusSub({ - db: ctx.db, - org: ctx.org, + ctx, customerId, productId: proAnnual.id, }); diff --git a/server/tests/integration/billing/migrations/migrate-addons.test.ts b/server/tests/integration/billing/migrations/migrate-addons.test.ts index 68da58962..5178146a6 100644 --- a/server/tests/integration/billing/migrations/migrate-addons.test.ts +++ b/server/tests/integration/billing/migrations/migrate-addons.test.ts @@ -332,9 +332,7 @@ test.concurrent(`${chalk.yellowBright("migrate-addons-3: same add-on attached tw // Verify migrated state const fullCustomer = await CusService.getFull({ - db: ctx.db, - orgId: ctx.org.id, - env: ctx.env, + ctx, idOrInternalId: customerId, }); diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-discounts.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-discounts.test.ts index bf7984ec5..f0dfc5847 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-discounts.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-discounts.test.ts @@ -25,10 +25,8 @@ const getStripeInfo = async ({ customerId }: { customerId: string }) => { const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts b/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts index 4a52c1184..785e8b7a1 100644 --- a/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/invoice-paid/send-email-receipt.test.ts @@ -64,10 +64,8 @@ test(`${chalk.yellowBright("invoice.paid: sends email receipt when send_email_re // Verify the update was applied (both Autumn and Stripe) const updatedCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); expect(updatedCustomer.send_email_receipts).toBe(true); expect(updatedCustomer.email).toBe(testEmail); @@ -255,10 +253,8 @@ test(`${chalk.yellowBright("invoice.paid: does NOT send email receipt when custo // Verify send_email_receipts was enabled const updatedCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); expect(updatedCustomer.send_email_receipts).toBe(true); diff --git a/server/tests/integration/billing/update-subscription/discounts/addon-discount.test.ts b/server/tests/integration/billing/update-subscription/discounts/addon-discount.test.ts index 498e03a00..2cae1095b 100644 --- a/server/tests/integration/billing/update-subscription/discounts/addon-discount.test.ts +++ b/server/tests/integration/billing/update-subscription/discounts/addon-discount.test.ts @@ -234,10 +234,8 @@ test.concurrent(`${chalk.yellowBright("addon: separate subscription with own dis // Verify we have 2 separate subscriptions const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const subCount = getCusStripeSubCount({ fullCus: fullCustomer }); diff --git a/server/tests/integration/billing/update-subscription/discounts/customer-discount.test.ts b/server/tests/integration/billing/update-subscription/discounts/customer-discount.test.ts index cef98ccf6..e72811049 100644 --- a/server/tests/integration/billing/update-subscription/discounts/customer-discount.test.ts +++ b/server/tests/integration/billing/update-subscription/discounts/customer-discount.test.ts @@ -31,10 +31,8 @@ const getStripeInfo = async ({ customerId }: { customerId: string }) => { const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts b/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts index e76e6a62d..ff54c0d50 100644 --- a/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts +++ b/server/tests/integration/billing/update-subscription/discounts/subscription-discounts.test.ts @@ -29,10 +29,8 @@ const getStripeSubscription = async ({ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts b/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts index a3525943c..93fcf2e54 100644 --- a/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice/update-quantity-invoice-mode.test.ts @@ -52,10 +52,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: default invoice mode (dr }); const beforeUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = beforeUpdate.customer_products.find( @@ -78,10 +76,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: default invoice mode (dr }); const afterUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, expand: [CustomerExpand.Invoices], }); @@ -133,10 +129,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: draft invoice with immed }); const beforeUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = beforeUpdate.customer_products.find( @@ -160,10 +154,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: draft invoice with immed // Entitlements should be updated immediately const afterUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const afterCustomerProduct = afterUpdate.customer_products.find( @@ -216,10 +208,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: finalized invoice with i }); const beforeUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = beforeUpdate.customer_products.find( @@ -243,10 +233,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: finalized invoice with i // Entitlements should be updated immediately const afterUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const afterCustomerProduct = afterUpdate.customer_products.find( @@ -299,10 +287,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: entitlements after payme }); const beforeUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = beforeUpdate.customer_products.find( @@ -326,10 +312,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: entitlements after payme // Entitlements should NOT be updated yet (waiting for payment) const afterUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const afterCustomerProduct = afterUpdate.customer_products.find( @@ -362,10 +346,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: entitlements after payme // Entitlements should now be updated after payment const afterPayment = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const paidCustomerProduct = afterPayment.customer_products.find( diff --git a/server/tests/integration/billing/update-subscription/update-quantity/entitlements-balance.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/entitlements-balance.test.ts index 953a9104b..163be51e9 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/entitlements-balance.test.ts +++ b/server/tests/integration/billing/update-subscription/update-quantity/entitlements-balance.test.ts @@ -48,10 +48,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: increment entitlement ba }); const beforeUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = beforeUpdate.customer_products.find( @@ -71,10 +69,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: increment entitlement ba }); const afterUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const afterCustomerProduct = afterUpdate.customer_products.find( @@ -119,10 +115,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: decrement entitlement ba }); const beforeUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = beforeUpdate.customer_products.find( @@ -142,10 +136,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: decrement entitlement ba }); const afterUpdate = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const afterCustomerProduct = afterUpdate.customer_products.find( diff --git a/server/tests/integration/billing/update-subscription/update-quantity/quantity-stripe-sync.test.ts b/server/tests/integration/billing/update-subscription/update-quantity/quantity-stripe-sync.test.ts index 4309506bb..0a931fd0a 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/quantity-stripe-sync.test.ts +++ b/server/tests/integration/billing/update-subscription/update-quantity/quantity-stripe-sync.test.ts @@ -66,10 +66,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: stripe sync upgrade quan // Get Stripe subscription item quantity before update const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = @@ -166,10 +164,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity: stripe sync downgrade qu // Get Stripe subscription before downgrade const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/utils/discounts/discountTestUtils.ts b/server/tests/integration/billing/utils/discounts/discountTestUtils.ts index 806c771de..4610d9720 100644 --- a/server/tests/integration/billing/utils/discounts/discountTestUtils.ts +++ b/server/tests/integration/billing/utils/discounts/discountTestUtils.ts @@ -18,10 +18,8 @@ export const getStripeSubscription = async ({ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/utils/proration/getBillingPeriod.ts b/server/tests/integration/billing/utils/proration/getBillingPeriod.ts index a61d88ec0..900e8afe7 100644 --- a/server/tests/integration/billing/utils/proration/getBillingPeriod.ts +++ b/server/tests/integration/billing/utils/proration/getBillingPeriod.ts @@ -48,10 +48,8 @@ export const getBillingPeriod = async ({ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/billing/utils/stripe/expectStripeInvoiceLineItemPeriodCorrect.ts b/server/tests/integration/billing/utils/stripe/expectStripeInvoiceLineItemPeriodCorrect.ts index 5f160dd1a..e0aad6006 100644 --- a/server/tests/integration/billing/utils/stripe/expectStripeInvoiceLineItemPeriodCorrect.ts +++ b/server/tests/integration/billing/utils/stripe/expectStripeInvoiceLineItemPeriodCorrect.ts @@ -42,10 +42,8 @@ export const expectStripeInvoiceLineItemPeriodCorrect = async ({ ); const customer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, expand: [CustomerExpand.Invoices], }); diff --git a/server/tests/integration/billing/utils/stripe/getSubscriptionId.ts b/server/tests/integration/billing/utils/stripe/getSubscriptionId.ts index f76b17b31..9d6ac6c7b 100644 --- a/server/tests/integration/billing/utils/stripe/getSubscriptionId.ts +++ b/server/tests/integration/billing/utils/stripe/getSubscriptionId.ts @@ -14,10 +14,8 @@ export const getSubscriptionId = async ({ productId: string; }): Promise => { const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = fullCustomer.customer_products.find( @@ -46,10 +44,8 @@ export const getEntitySubscriptionId = async ({ productId: string; }): Promise => { const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const customerProduct = fullCustomer.customer_products.find( diff --git a/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts b/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts index a8d196557..45572f899 100644 --- a/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts +++ b/server/tests/integration/billing/utils/stripeSubscriptionUtils.ts @@ -14,10 +14,8 @@ export const getStripeSubscription = async ({ const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); const stripeCustomerId = diff --git a/server/tests/integration/cron/one-off-cleanup/utils/oneOffCleanupTestUtils.ts b/server/tests/integration/cron/one-off-cleanup/utils/oneOffCleanupTestUtils.ts index 2f2a363e7..558040515 100644 --- a/server/tests/integration/cron/one-off-cleanup/utils/oneOffCleanupTestUtils.ts +++ b/server/tests/integration/cron/one-off-cleanup/utils/oneOffCleanupTestUtils.ts @@ -12,10 +12,8 @@ export const getFullCustomerWithExpired = async ( customerId: string, ): Promise => { return await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, inStatuses: ALL_STATUSES, }); }; diff --git a/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts b/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts index f6b081466..19e981329 100644 --- a/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts +++ b/server/tests/integration/crud/customers/create-customer-paid-defaults.test.ts @@ -127,10 +127,8 @@ test.concurrent(`${chalk.yellowBright("paid-defaults: trial prepaid messages")}` }); const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customer.id ?? "", - orgId: ctx.org.id, - env: ctx.env, }); expect(fullCustomer.customer_products.length).toBe(1); diff --git a/server/tests/integration/crud/customers/create-customer-race.test.ts b/server/tests/integration/crud/customers/create-customer-race.test.ts index 8a25bfd91..62a610f9d 100644 --- a/server/tests/integration/crud/customers/create-customer-race.test.ts +++ b/server/tests/integration/crud/customers/create-customer-race.test.ts @@ -94,10 +94,8 @@ test.concurrent(`${chalk.yellowBright("race: concurrent create same ID returns s // Verify no duplicate customer_products in DB const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); // Count products with the same product_id @@ -243,10 +241,8 @@ test.concurrent(`${chalk.yellowBright("race: concurrent create with default tria // Get the full customer to verify Stripe data const fullCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); // 2. Verify only 1 Stripe customer was created @@ -331,10 +327,8 @@ test.concurrent(`${chalk.yellowBright("race: concurrent create with name vs with // Get the final customer state from DB const finalCustomer = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); // Name should be preserved - not overwritten with empty string diff --git a/server/tests/integration/crud/customers/reset-customer-entitlements/get-customer-reset-concurrent.test.ts b/server/tests/integration/crud/customers/reset-customer-entitlements/get-customer-reset-concurrent.test.ts new file mode 100644 index 000000000..6469b8f25 --- /dev/null +++ b/server/tests/integration/crud/customers/reset-customer-entitlements/get-customer-reset-concurrent.test.ts @@ -0,0 +1,251 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer, CheckResponseV2 } from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.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"; + +// ───────────────────────────────────────────────────────────────── +// Concurrent GET /customers — multiple reads trigger reset exactly once +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("concurrent reset: multiple GET customers all return reset balance")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-concurrent-get", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 40, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Fire 5 concurrent GET requests — all should see reset balance + const results = await Promise.all( + Array.from({ length: 5 }, () => + autumnV2.customers.get(customerId), + ), + ); + + for (const customer of results) { + expect(customer.balances[TestFeature.Messages].current_balance).toBe(100); + expect(customer.balances[TestFeature.Messages].usage).toBe(0); + } + + // DB should also reflect the reset (only applied once) + const cusEntAfter = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(cusEntAfter).toBeDefined(); + expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now()); +}); + +// ───────────────────────────────────────────────────────────────── +// Concurrent checks — all return reset balance +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("concurrent reset: multiple checks all return reset balance")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-concurrent-check", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 70, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Fire 5 concurrent check requests + const results = await Promise.all( + Array.from({ length: 5 }, () => + autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }), + ), + ); + + for (const res of results) { + const check = res as unknown as CheckResponseV2; + expect(check.allowed).toBe(true); + expect(check.balance?.current_balance).toBe(100); + expect(check.balance?.usage).toBe(0); + } +}); + +// ───────────────────────────────────────────────────────────────── +// Concurrent tracks — reset once, all deductions applied atomically +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("concurrent reset: multiple tracks reset once then deduct atomically")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-concurrent-track", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Fire 5 concurrent tracks of 10 each — should reset to 100, then deduct 50 total + await Promise.all( + Array.from({ length: 5 }, () => + autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }), + ), + ); + + // Verify final balance: 100 (reset) - 50 (5 * 10) = 50 + const customer = await autumnV2.customers.get(customerId); + expect(customer.balances[TestFeature.Messages].current_balance).toBe(50); + expect(customer.balances[TestFeature.Messages].usage).toBe(50); + + // Wait for DB sync and verify DB agrees + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerDb.balances[TestFeature.Messages].current_balance).toBe(50); + expect(customerDb.balances[TestFeature.Messages].usage).toBe(50); +}); + +// ───────────────────────────────────────────────────────────────── +// Mixed concurrent: GET + check + track all hit a stale cusEnt +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("concurrent reset: mixed GET/check/track all handle stale cusEnt correctly")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-concurrent-mixed", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 80, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Fire mixed concurrent requests: 2 GETs, 2 checks, 1 track(15) + const [get1, get2, check1, check2, _trackRes] = await Promise.all([ + autumnV2.customers.get(customerId), + autumnV2.customers.get(customerId), + autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }) as unknown as Promise, + autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + }) as unknown as Promise, + autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 15, + }), + ]); + + // GETs should show reset balance (may or may not include the track deduction depending on ordering) + for (const customer of [get1, get2]) { + // Balance should be >= 85 (reset 100 minus at most 15 from track) + expect( + customer.balances[TestFeature.Messages].current_balance, + ).toBeGreaterThanOrEqual(85); + expect( + customer.balances[TestFeature.Messages].current_balance, + ).toBeLessThanOrEqual(100); + } + + // Checks should show reset balance + for (const check of [check1, check2]) { + expect(check.allowed).toBe(true); + expect(check.balance?.current_balance).toBeGreaterThanOrEqual(85); + expect(check.balance?.current_balance).toBeLessThanOrEqual(100); + } + + // Wait for dust to settle, verify final state + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const finalDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + // Final balance must be exactly 85: reset to 100, one track of 15 + expect(finalDb.balances[TestFeature.Messages].current_balance).toBe(85); + expect(finalDb.balances[TestFeature.Messages].usage).toBe(15); + + const cusEntAfter = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(cusEntAfter).toBeDefined(); + expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now()); +}); diff --git a/server/tests/integration/crud/customers/reset-customer-entitlements/get-customer-reset.test.ts b/server/tests/integration/crud/customers/reset-customer-entitlements/get-customer-reset.test.ts new file mode 100644 index 000000000..c4e954327 --- /dev/null +++ b/server/tests/integration/crud/customers/reset-customer-entitlements/get-customer-reset.test.ts @@ -0,0 +1,265 @@ +import { expect, test } from "bun:test"; +import type { + ApiCustomer, + CheckResponseV2, + TrackResponseV2, +} from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.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"; + +// ───────────────────────────────────────────────────────────────── +// GET /customers (skip_cache) — DB path lazy reset +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lazy reset (DB): GET customer resets balance after next_reset_at passes")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-get-db", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const before = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(before.balances[TestFeature.Messages].current_balance).toBe(70); + expect(before.balances[TestFeature.Messages].usage).toBe(30); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + const after = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(after.balances[TestFeature.Messages].current_balance).toBe(100); + expect(after.balances[TestFeature.Messages].usage).toBe(0); + + const cusEntAfter = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(cusEntAfter).toBeDefined(); + expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now()); +}); + +// ───────────────────────────────────────────────────────────────── +// GET /customers (cached) — cache path lazy reset +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lazy reset (cache): GET customer resets balance from cache after next_reset_at passes")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-get-cache", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Warm the cache + const before = await autumnV2.customers.get(customerId); + expect(before.balances[TestFeature.Messages].current_balance).toBe(70); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + const after = await autumnV2.customers.get(customerId); + expect(after.balances[TestFeature.Messages].current_balance).toBe(100); + expect(after.balances[TestFeature.Messages].usage).toBe(0); + + const cusEntAfter = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(cusEntAfter).toBeDefined(); + expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now()); +}); + +// ───────────────────────────────────────────────────────────────── +// POST /check — lazy reset before check +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lazy reset: check returns reset balance after next_reset_at passes")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-check", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 60, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Verify pre-reset state + const checkBefore = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + expect(checkBefore.allowed).toBe(true); + expect(checkBefore.balance?.current_balance).toBe(40); + expect(checkBefore.balance?.usage).toBe(60); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Check should trigger lazy reset and return reset balance + const checkAfter = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + expect(checkAfter.allowed).toBe(true); + expect(checkAfter.balance?.current_balance).toBe(100); + expect(checkAfter.balance?.usage).toBe(0); +}); + +// ───────────────────────────────────────────────────────────────── +// POST /track — lazy reset then deduction +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lazy reset: track resets balance then deducts correctly")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-track", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // Track 20 — should reset (100) then deduct (100 - 20 = 80) + const trackRes: TrackResponseV2 = await autumnV2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + expect(trackRes.balance?.current_balance).toBe(80); + expect(trackRes.balance?.usage).toBe(20); + + // Verify cache reflects reset + deduction + const customer = await autumnV2.customers.get(customerId); + expect(customer.balances[TestFeature.Messages].current_balance).toBe(80); + expect(customer.balances[TestFeature.Messages].usage).toBe(20); + + // Wait for DB sync and verify DB state + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customerDb = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(customerDb.balances[TestFeature.Messages].current_balance).toBe(80); + expect(customerDb.balances[TestFeature.Messages].usage).toBe(20); + + const cusEntAfter = await findCustomerEntitlement({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + expect(cusEntAfter).toBeDefined(); + expect(cusEntAfter!.next_reset_at).toBeGreaterThan(Date.now()); +}); + +// ───────────────────────────────────────────────────────────────── +// POST /customers (create-or-get) — lazy reset on existing customer +// ───────────────────────────────────────────────────────────────── + +test.concurrent(`${chalk.yellowBright("lazy reset: POST /check on existing customer triggers reset")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ items: [messagesItem] }); + + const { customerId, autumnV1, autumnV2, ctx } = await initScenario({ + customerId: "reset-post-check", + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 40, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + await expireCusEntForReset({ + ctx, + customerId, + featureId: TestFeature.Messages, + }); + + // POST /check fetches the customer (triggering reset) before checking + const checkRes = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + required_balance: 90, + })) as unknown as CheckResponseV2; + + // Should be allowed since balance was reset to 100 + expect(checkRes.allowed).toBe(true); + expect(checkRes.balance?.current_balance).toBe(100); + expect(checkRes.balance?.usage).toBe(0); +}); diff --git a/server/tests/integration/crud/customers/reset-customer-entitlements/list-customers-reset.test.ts b/server/tests/integration/crud/customers/reset-customer-entitlements/list-customers-reset.test.ts new file mode 100644 index 000000000..361bd54c8 --- /dev/null +++ b/server/tests/integration/crud/customers/reset-customer-entitlements/list-customers-reset.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomer } from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { expireCusEntForReset } from "@tests/utils/cusProductUtils/resetTestUtils.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"; + +// ───────────────────────────────────────────────────────────────── +// POST /customers/list — batch reset: only stale customers reset +// +// 6 customers with shared prefix. Customers 2, 4, 6 have their +// next_reset_at expired. After listV2 triggers the async SQS batch +// reset, only those 3 should be reset; the other 3 keep their usage. +// ───────────────────────────────────────────────────────────────── + +const PREFIX = "reset-list-cohort"; +const OTHER_IDS = Array.from({ length: 5 }, (_, i) => `${PREFIX}-${i + 2}`); + +// Indices 2, 4, 6 are stale (0-indexed: 1, 3, 5 in the all-customers array) +const STALE_IDS = [`${PREFIX}-2`, `${PREFIX}-4`, `${PREFIX}-6`]; +const FRESH_IDS = [`${PREFIX}-1`, `${PREFIX}-3`, `${PREFIX}-5`]; + +// Each customer tracks a different amount so we can verify individually +const USAGE: Record = { + [`${PREFIX}-1`]: 20, + [`${PREFIX}-2`]: 35, + [`${PREFIX}-3`]: 50, + [`${PREFIX}-4`]: 15, + [`${PREFIX}-5`]: 70, + [`${PREFIX}-6`]: 40, +}; + +test.concurrent(`${chalk.yellowBright("list customers reset: only stale customers are reset, fresh customers keep usage")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const freePlan = products.base({ + id: "free", + items: [messagesItem], + }); + + const primaryId = `${PREFIX}-1`; + + const { autumnV1, autumnV2, ctx } = await initScenario({ + customerId: primaryId, + setup: [ + s.customer({ paymentMethod: "success", testClock: false }), + s.otherCustomers( + OTHER_IDS.map((id) => ({ + id, + testClock: false, + })), + ), + s.products({ list: [freePlan] }), + ], + actions: [ + // Attach free plan to all 6 customers + s.attach({ productId: freePlan.id }), + ...OTHER_IDS.map((id) => + s.attach({ productId: freePlan.id, customerId: id }), + ), + ], + }); + + // 1. Track different usage on each customer + const allIds = [primaryId, ...OTHER_IDS]; + await Promise.all( + allIds.map((id) => + autumnV1.track({ + customer_id: id, + feature_id: TestFeature.Messages, + value: USAGE[id], + }), + ), + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // 2. Expire only the stale customers (2, 4, 6) + await Promise.all( + STALE_IDS.map((id) => + expireCusEntForReset({ + ctx, + customerId: id, + featureId: TestFeature.Messages, + }), + ), + ); + + // 3. Call listV2 with the shared prefix — triggers batch reset via SQS + const listRes = (await autumnV2.customers.listV2({ + search: PREFIX, + })) as { list: ApiCustomer[] }; + + // All 6 should appear in the list + for (const id of allIds) { + const found = listRes.list.find((c) => c.id === id); + expect(found).toBeDefined(); + } + + // 4. Wait for the SQS batch reset worker to process + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // 5. Verify stale customers (2, 4, 6) were reset to full balance + for (const id of STALE_IDS) { + const customer = await autumnV2.customers.get(id, { + skip_cache: "true", + }); + expect(customer.balances[TestFeature.Messages].current_balance).toBe(100); + expect(customer.balances[TestFeature.Messages].usage).toBe(0); + + const cusEnt = await findCustomerEntitlement({ + ctx, + customerId: id, + featureId: TestFeature.Messages, + }); + expect(cusEnt).toBeDefined(); + expect(cusEnt!.next_reset_at).toBeGreaterThan(Date.now()); + } + + // 6. Verify fresh customers (1, 3, 5) kept their usage — no reset + for (const id of FRESH_IDS) { + const customer = await autumnV2.customers.get(id, { + skip_cache: "true", + }); + expect(customer.balances[TestFeature.Messages].current_balance).toBe( + 100 - USAGE[id], + ); + expect(customer.balances[TestFeature.Messages].usage).toBe(USAGE[id]); + } +}); diff --git a/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts b/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts index 85e41a3f9..8e31cb77b 100644 --- a/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts +++ b/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts @@ -583,10 +583,8 @@ test.concurrent(`${chalk.yellowBright("create-entity-paid: entity5 - payment fai // Step 2: Attach a failing payment method const fullCus = await CusService.getFull({ - db: ctx.db, + ctx, idOrInternalId: customerId, - orgId: ctx.org.id, - env: ctx.env, }); await attachFailedPaymentMethod({ diff --git a/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts b/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts index 199ae7595..b426cca3d 100644 --- a/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts +++ b/server/tests/integration/crud/entities/create-entity/utils/expectEntityUtils.ts @@ -39,9 +39,7 @@ export const expectSubQuantityCorrect = async ({ numReplaceables?: number; }) => { const fullCus = await CusService.getFull({ - db, - orgId: org.id, - env, + ctx: { db, org, env } as any, idOrInternalId: customerId, }); diff --git a/server/tests/merged/mergeUtils/expectSubCorrect.ts b/server/tests/merged/mergeUtils/expectSubCorrect.ts index 933816812..b51d84930 100644 --- a/server/tests/merged/mergeUtils/expectSubCorrect.ts +++ b/server/tests/merged/mergeUtils/expectSubCorrect.ts @@ -179,10 +179,8 @@ export const expectSubToBeCorrect = async ({ }) => { const stripeCli = createStripeCli({ org, env }); const fullCus = await CusService.getFull({ - db, + ctx: { db, org, env } as any, idOrInternalId: customerId, - orgId: org.id, - env, withEntities: true, }); diff --git a/server/tests/utils/cusProductUtils/cusProductUtils.ts b/server/tests/utils/cusProductUtils/cusProductUtils.ts index 6be9fa4af..7fa515612 100644 --- a/server/tests/utils/cusProductUtils/cusProductUtils.ts +++ b/server/tests/utils/cusProductUtils/cusProductUtils.ts @@ -1,29 +1,19 @@ -import { - type AppEnv, - CusProductStatus, - type FullCusProduct, -} from "@autumn/shared"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusProductStatus, type FullCusProduct } from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { CusService } from "@/internal/customers/CusService.js"; export const getMainCusProduct = async ({ - db, + ctx, customerId, - orgId, - env, productGroup, }: { - db: DrizzleCli; + ctx: AutumnContext; customerId: string; - orgId: string; - env: AppEnv; productGroup?: string; }) => { const customer = await CusService.getFull({ - db, + ctx, idOrInternalId: customerId, - orgId, - env, withEntities: true, inStatuses: [CusProductStatus.Active], }); diff --git a/server/tests/utils/cusProductUtils/resetTestUtils.ts b/server/tests/utils/cusProductUtils/resetTestUtils.ts new file mode 100644 index 000000000..6c72380ac --- /dev/null +++ b/server/tests/utils/cusProductUtils/resetTestUtils.ts @@ -0,0 +1,110 @@ +import { customerEntitlements, type FullCustomer } from "@autumn/shared"; +import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js"; +import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js"; +import { eq } from "drizzle-orm"; +import { redis } from "@/external/redis/initRedis.js"; +import { buildFullCustomerCacheKey } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/fullCustomerCacheConfig.js"; + +/** + * Update next_reset_at for a specific cusEnt in the Redis FullCustomer cache. + * Reads the cached blob, finds the cusEnt by ID, then uses JSON.SET on the exact path. + */ +export const setCachedCusEntField = async ({ + orgId, + env, + customerId, + cusEntId, + field, + value, +}: { + orgId: string; + env: string; + customerId: string; + cusEntId: string; + field: string; + value: number | string | null; +}): Promise => { + const cacheKey = buildFullCustomerCacheKey({ orgId, env, customerId }); + + const raw = (await redis.call("JSON.GET", cacheKey)) as string | null; + if (!raw) return; + + const fullCustomer = JSON.parse(raw) as FullCustomer; + const serializedValue = value === null ? "null" : JSON.stringify(value); + + for (let cpIdx = 0; cpIdx < fullCustomer.customer_products.length; cpIdx++) { + const cusEnts = fullCustomer.customer_products[cpIdx].customer_entitlements; + for (let ceIdx = 0; ceIdx < cusEnts.length; ceIdx++) { + if (cusEnts[ceIdx].id === cusEntId) { + await redis.call( + "JSON.SET", + cacheKey, + `$.customer_products[${cpIdx}].customer_entitlements[${ceIdx}].${field}`, + serializedValue, + ); + return; + } + } + } + + const extras = fullCustomer.extra_customer_entitlements || []; + for (let eIdx = 0; eIdx < extras.length; eIdx++) { + if (extras[eIdx].id === cusEntId) { + await redis.call( + "JSON.SET", + cacheKey, + `$.extra_customer_entitlements[${eIdx}].${field}`, + serializedValue, + ); + return; + } + } +}; + +/** + * Expire a cusEnt's next_reset_at in both Postgres and Redis cache, + * so the next read triggers a lazy reset. Returns the cusEnt for assertions. + */ +export const expireCusEntForReset = async ({ + ctx, + customerId, + featureId, + pastTimeMs, +}: { + ctx: TestContext; + customerId: string; + featureId: string; + pastTimeMs?: number; +}) => { + const cusEnt = await findCustomerEntitlement({ + ctx, + customerId, + featureId, + }); + + if (!cusEnt) { + throw new Error( + `cusEnt not found for customer=${customerId} feature=${featureId}`, + ); + } + + const pastTime = pastTimeMs ?? Date.now() - 1000; + + // Update Postgres + await ctx.db + .update(customerEntitlements) + .set({ next_reset_at: pastTime }) + .where(eq(customerEntitlements.id, cusEnt.id)); + + // Update Redis cache + await setCachedCusEntField({ + orgId: ctx.org.id, + env: ctx.env, + customerId, + cusEntId: cusEnt.id, + field: "next_reset_at", + value: pastTime, + }); + + return cusEnt; +}; diff --git a/server/tests/utils/cusProductUtils/updateCusEntBalance.ts b/server/tests/utils/cusProductUtils/updateCusEntBalance.ts index fc3f179a0..0c603e2db 100644 --- a/server/tests/utils/cusProductUtils/updateCusEntBalance.ts +++ b/server/tests/utils/cusProductUtils/updateCusEntBalance.ts @@ -14,11 +14,8 @@ export const updateFeatureBalance = async ({ featureId: string; balance: number; }) => { - const { db, org, env } = ctx; const cusEnt = await getCusEntByFeature({ - db, - org, - env, + ctx, customerId, featureId, }); diff --git a/server/tests/utils/expectUtils/expectContUseUtils.ts b/server/tests/utils/expectUtils/expectContUseUtils.ts index 3e403d77f..9166195d8 100644 --- a/server/tests/utils/expectUtils/expectContUseUtils.ts +++ b/server/tests/utils/expectUtils/expectContUseUtils.ts @@ -39,9 +39,7 @@ export const expectSubQuantityCorrect = async ({ numReplaceables?: number; }) => { const fullCus = await CusService.getFull({ - db, - orgId: org.id, - env, + ctx: { db, org, env } as any, idOrInternalId: customerId, }); diff --git a/server/tests/utils/expectUtils/expectScheduleUtils.ts b/server/tests/utils/expectUtils/expectScheduleUtils.ts index 3a47e9852..b2c498bfd 100644 --- a/server/tests/utils/expectUtils/expectScheduleUtils.ts +++ b/server/tests/utils/expectUtils/expectScheduleUtils.ts @@ -175,10 +175,8 @@ export const expectSubScheduleCorrect = async ({ // 1. Check schedule if (!fullCus) { fullCus = await CusService.getFull({ - db, + ctx: { db, org, env } as any, idOrInternalId: customerId, - orgId: org.id, - env, }); } diff --git a/server/tests/utils/expectUtils/expectSubUtils.ts b/server/tests/utils/expectUtils/expectSubUtils.ts index b73009781..44b2dc679 100644 --- a/server/tests/utils/expectUtils/expectSubUtils.ts +++ b/server/tests/utils/expectUtils/expectSubUtils.ts @@ -41,10 +41,8 @@ export const getSubsFromCusId = async ({ withExpired?: boolean; }) => { const fullCus = await CusService.getFull({ - db, + ctx: { db, org, env } as any, idOrInternalId: customerId, - orgId: org.id, - env, inStatuses: withExpired ? [ CusProductStatus.Active, @@ -93,10 +91,8 @@ export const expectSubItemsCorrect = async ({ entityId?: string; }) => { const fullCus = await CusService.getFull({ - db, + ctx: { db, org, env } as any, idOrInternalId: customerId, - orgId: org.id, - env, withEntities: true, }); diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index 36cfbacbc..3e89f3eea 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -1114,7 +1114,7 @@ export async function initScenario({ customerData: otherCusConfig.data, attachPm: otherCusConfig.paymentMethod, withTestClock: false, // Don't create a new test clock - existingTestClockId: testClockId, // Reuse primary customer's test clock + ...(testClockId ? { existingTestClockId: testClockId } : {}), withDefault: false, defaultGroup: productPrefix, skipWebhooks: config.skipWebhooks,