From b0920f5edad4c4e5be43db44f8ecd7ddaf4d4e5d Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:10:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20boom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../honoMiddlewares/refreshCacheMiddleware.ts | 10 +- .../prepareNewBalanceForInsertion.ts | 0 .../validateCreateBalance.ts} | 64 +-- .../balances/handlers/handleCreateBalance.ts | 35 +- .../cusEnts/CusEntitlementService.ts | 68 ++- .../getApiBalance/apiBalanceUtils.ts | 11 +- .../getApiBalance/getApiBalance.ts | 26 +- .../getApiBalance/getApiBalances.ts | 15 +- .../src/internal/customers/getFullCusQuery.ts | 424 +++++++++--------- .../loose-1.test.ts} | 0 .../loose-2.test.ts} | 0 .../loose-3.test.ts} | 2 +- .../loose-4.test.ts} | 6 +- .../balances/track/loose/loose-basic.test.ts | 53 +++ .../balances/track/loose/loose-exact.test.ts | 49 ++ .../track/loose/loose-incremental.test.ts | 64 +++ .../balances/track/loose/loose-mixed.test.ts | 65 +++ .../track/loose/loose-overage-reject.test.ts | 48 ++ .../track/loose/loose-product-first.test.ts | 110 +++++ .../track/loose/loose-unlimited.test.ts | 47 ++ .../balances/track/loose/loose-zero.test.ts | 66 +++ .../balances/create/createBalanceParams.ts | 30 +- .../cusEntModels/cusEntWithProduct.ts | 8 - .../cusEntModels/resetCusEnt.ts | 2 +- .../balanceUtils/cusEntToPurchasedBalance.ts | 8 +- .../balanceUtils/cusEntsToBalance.ts | 4 +- .../cusEntsToAdjustment.ts | 4 +- .../grantedBalanceUtils/cusEntsToAllowance.ts | 6 +- .../utils/cusEntUtils/convertCusEntUtils.ts | 13 +- .../cusEntsToMaxPurchase.ts | 3 +- shared/utils/cusEntUtils/cusEntUtils.ts | 7 +- shared/utils/productUtils/convertUtils.ts | 4 +- 32 files changed, 915 insertions(+), 337 deletions(-) rename server/src/internal/balances/{createNewBalance => createBalance}/prepareNewBalanceForInsertion.ts (100%) rename server/src/internal/balances/{createNewBalance/validationUtilsForNewBalances.ts => createBalance/validateCreateBalance.ts} (55%) rename server/tests/balances/check/{basic/check-loose1.test.ts => loose/loose-1.test.ts} (100%) rename server/tests/balances/check/{basic/check-loose2.test.ts => loose/loose-2.test.ts} (100%) rename server/tests/balances/check/{basic/check-loose3.test.ts => loose/loose-3.test.ts} (99%) rename server/tests/balances/check/{basic/check-loose4.test.ts => loose/loose-4.test.ts} (93%) create mode 100644 server/tests/balances/track/loose/loose-basic.test.ts create mode 100644 server/tests/balances/track/loose/loose-exact.test.ts create mode 100644 server/tests/balances/track/loose/loose-incremental.test.ts create mode 100644 server/tests/balances/track/loose/loose-mixed.test.ts create mode 100644 server/tests/balances/track/loose/loose-overage-reject.test.ts create mode 100644 server/tests/balances/track/loose/loose-product-first.test.ts create mode 100644 server/tests/balances/track/loose/loose-unlimited.test.ts create mode 100644 server/tests/balances/track/loose/loose-zero.test.ts diff --git a/server/src/honoMiddlewares/refreshCacheMiddleware.ts b/server/src/honoMiddlewares/refreshCacheMiddleware.ts index 4a4b5076c..9ef115425 100644 --- a/server/src/honoMiddlewares/refreshCacheMiddleware.ts +++ b/server/src/honoMiddlewares/refreshCacheMiddleware.ts @@ -42,7 +42,7 @@ const cusPrefixedUrls = [ * Note: /balances/update is NOT included because it updates Redis directly * to avoid race conditions with batched track syncs */ -const coreUrls = [ +const coreUrls: { method: string; url: string; source?: string }[] = [ { method: "POST", url: "/attach", @@ -51,6 +51,11 @@ const coreUrls = [ method: "POST", url: "/cancel", }, + { + method: "POST", + url: "/balances/create", + source: "handleCreateBalance", + } ]; /** @@ -115,8 +120,7 @@ export const refreshCacheMiddleware = async ( customerId: body.customer_id, orgId: org.id, env: env, - source: "refreshCacheMiddleware", - logger, + source: coreMatch.source || undefined, }); } } diff --git a/server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts similarity index 100% rename from server/src/internal/balances/createNewBalance/prepareNewBalanceForInsertion.ts rename to server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts diff --git a/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts similarity index 55% rename from server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts rename to server/src/internal/balances/createBalance/validateCreateBalance.ts index 2f989394c..49b639f43 100644 --- a/server/src/internal/balances/createNewBalance/validationUtilsForNewBalances.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -1,44 +1,48 @@ -import { CreateBalanceSchema } from "@autumn/shared"; import { ErrCode, type Feature, - FeatureSchema, FeatureType, + type FullCustomer, RecaseError, - ResetInterval, + ValidateCreateBalanceParamsSchema, } from "@shared/index"; import { StatusCodes } from "http-status-codes"; -import z from "zod/v4"; +import type { z } from "zod/v4"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; -export const CreateBalanceForValidation = CreateBalanceSchema.extend({ - feature: FeatureSchema, -}).refine((data) => { - if (!data.feature) { - return false; - } +export const validateCreateBalanceParams = async ({ + ctx, + feature, + internalCustomerId, + granted_balance, + unlimited, + reset, + fullCustomer, +}: { + ctx: AutumnContext; + feature: Feature; + internalCustomerId: string; + granted_balance: number | undefined; + unlimited: boolean | undefined; + reset: z.infer["reset"]; + fullCustomer: FullCustomer; +}) => { + ValidateCreateBalanceParamsSchema.parse({ + feature, + granted_balance, + unlimited, + reset, + customer_id: internalCustomerId, + feature_id: feature.id, + }); - if (data.feature.type === FeatureType.Boolean) { - if (data.granted_balance || data.unlimited || data.reset?.interval) { - return false; - } - } - - if (data.feature.type === FeatureType.Metered) { - if (!data.granted_balance && !data.unlimited) { - return false; - } - if (data.granted_balance && data.unlimited) { - return false; - } - if (data.unlimited && data.reset?.interval) { - return false; - } - } - - return true; -}); + await validateBooleanEntitlementConflict({ + ctx, + feature, + internalCustomerId: fullCustomer.internal_id, + }); +}; export const validateBooleanEntitlementConflict = async ({ ctx, diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index e259f0768..6cc66592e 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,15 +1,16 @@ -import { CreateBalanceSchema } from "@autumn/shared"; -import { CustomerNotFoundError, FeatureNotFoundError } from "@shared/index"; +import { + CreateBalanceSchema +} from "@autumn/shared"; +import { FeatureNotFoundError } from "@shared/index"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { CusService } from "@/internal/customers/CusService"; import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { deleteCachedApiCustomer } from "@/internal/customers/cusUtils/apiCusCacheUtils/deleteCachedApiCustomer"; import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; -import { prepareNewBalanceForInsertion } from "../createNewBalance/prepareNewBalanceForInsertion"; +import { prepareNewBalanceForInsertion } from "../createBalance/prepareNewBalanceForInsertion"; import { - CreateBalanceForValidation, - validateBooleanEntitlementConflict, -} from "../createNewBalance/validationUtilsForNewBalances"; + validateCreateBalanceParams +} from "../createBalance/validateCreateBalance"; export const handleCreateBalance = createRoute({ body: CreateBalanceSchema, @@ -22,7 +23,7 @@ export const handleCreateBalance = createRoute({ if (!feature) { throw new FeatureNotFoundError({ featureId: feature_id }); } - + 34; const fullCustomer = await CusService.getFull({ db: ctx.db, idOrInternalId: customer_id, @@ -30,24 +31,14 @@ export const handleCreateBalance = createRoute({ env: ctx.env, }); - if (!fullCustomer) { - throw new CustomerNotFoundError({ customerId: customer_id }); - } - - // This should throw an error if the data is invalid - CreateBalanceForValidation.parse({ - feature: feature, - granted_balance, - unlimited, - reset, - customer_id, - feature_id, - }); - - await validateBooleanEntitlementConflict({ + await validateCreateBalanceParams({ ctx, feature, internalCustomerId: fullCustomer.internal_id, + granted_balance, + unlimited, + reset, + fullCustomer, }); const { newEntitlement, newCustomerEntitlement } = diff --git a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts index 7afb5d38a..4fa060645 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/CusEntitlementService.ts @@ -14,7 +14,7 @@ import { features, type ResetCusEnt, } from "@autumn/shared"; -import { and, eq, lt, sql } from "drizzle-orm"; +import { and, eq, isNull, lt, sql } from "drizzle-orm"; import { StatusCodes } from "http-status-codes"; import { buildConflictUpdateColumns } from "@/db/dbUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; @@ -149,6 +149,72 @@ export class CusEntService { return allResults as ResetCusEnt[]; } + static async getLooseResetPassed({ + db, + customDateUnix, + batchSize = 1000, + }: { + db: DrizzleCli; + customDateUnix?: number; + batchSize?: number; + }) { + const allResults: ResetCusEnt[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const data = await db + .select() + .from(customerEntitlements) + .innerJoin( + entitlements, + eq(customerEntitlements.entitlement_id, entitlements.id), + ) + .innerJoin( + features, + eq(entitlements.internal_feature_id, features.internal_id), + ) + .innerJoin( + customers, + eq(customerEntitlements.internal_customer_id, customers.internal_id), + ) + .where( + and( + isNull(customerEntitlements.customer_product_id), + lt( + customerEntitlements.next_reset_at, + customDateUnix ?? Date.now(), + ), + ), + ) + .limit(batchSize) + .offset(offset); + + if (data.length === 0) { + hasMore = false; + } else { + const mappedData = data.map((item) => ({ + ...item.customer_entitlements, + entitlement: { + ...item.entitlements, + feature: item.features, + }, + customer_product: null, + customer: item.customers, + replaceables: [], + rollovers: [], + })) as ResetCusEnt[]; + + allResults.push(...mappedData); + offset += batchSize; + hasMore = data.length === batchSize; + console.log(`Fetched ${allResults.length} entitlements to reset`); + } + } + + return allResults; + } + static async update({ db, id, diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts index f8c9c4a2d..096ca8f47 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/apiBalanceUtils.ts @@ -7,7 +7,6 @@ import { entIntvToResetIntv, type Feature, type FullCusEntWithFullCusProduct, - type FullCusEntWithOptionalProduct, getRolloverFields, isContUseFeature, notNullish, @@ -17,7 +16,7 @@ import { export const cusEntsToNextResetAt = ({ cusEnts, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; }) => { const result = cusEnts.reduce((acc, curr) => { if (curr.next_reset_at && curr.next_reset_at < acc) { @@ -35,7 +34,7 @@ export const cusEntsToReset = ({ cusEnts, feature, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; feature: Feature; }): ApiBalanceReset | null => { // 1. If feature is allocated, null @@ -68,7 +67,7 @@ export const cusEntsToRollovers = ({ cusEnts, entityId, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; }): ApiBalanceRollover[] | undefined => { // If all cus ents no rollover, return undefined @@ -95,7 +94,7 @@ export const getBooleanApiBalance = ({ cusEnts, apiFeature, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; apiFeature?: ApiFeatureV1; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; @@ -141,7 +140,7 @@ export const getUnlimitedApiBalance = ({ cusEnts, }: { apiFeature?: ApiFeatureV1; - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; }): ApiBalance => { const feature = cusEnts[0].entitlement.feature; const planId = cusEntsToPlanId({ cusEnts }); diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 6dfd832b5..245ae8f59 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -1,7 +1,6 @@ import type { ApiBalance, FullCusEntWithFullCusProduct, - FullCusEntWithOptionalProduct, FullCustomer, } from "@autumn/shared"; import { @@ -42,19 +41,14 @@ const cusEntsToBreakdown = ({ cusEnts, }: { ctx: RequestContext; - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: (FullCusEntWithFullCusProduct)[]; fullCus: FullCustomer; -}): - | { - key: string; - breakdown: ApiBalanceBreakdown; - prepaidQuantity: number; - }[] - | undefined => { - const keyToCusEnts: Record< - string, - (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[] - > = {}; +}): { + key: string; + breakdown: ApiBalanceBreakdown; + prepaidQuantity: number; +}[] => { + const keyToCusEnts: Record = {}; for (const cusEnt of cusEnts) { const key = cusEntToKey({ cusEnt }); keyToCusEnts[key] = [...(keyToCusEnts[key] || []), cusEnt]; @@ -81,8 +75,8 @@ const cusEntsToBreakdown = ({ includeBreakdown: false, }); - const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts, feature }); - const planId = cusEnts[0].customer_product?.product.id ?? null; + const prepaidQuantity = cusEntsToPrepaidQuantity({ cusEnts }); + const planId = cusEntsToPlanId({ cusEnts }); breakdown.push({ key, @@ -119,7 +113,7 @@ export const getApiBalance = ({ }: { ctx: RequestContext; fullCus: FullCustomer; - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: (FullCusEntWithFullCusProduct)[]; feature: Feature; includeRollovers?: boolean; includeBreakdown?: boolean; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts index f881caf9d..a4224aa75 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalances.ts @@ -1,7 +1,7 @@ import { type ApiBalance, type CusFeatureLegacyData, - type FullCusEntWithOptionalProduct, + type FullCusEntWithFullCusProduct, type FullCustomer, fullCustomerToCustomerEntitlements, orgToInStatuses, @@ -19,14 +19,19 @@ export const getApiBalances = async ({ }) => { const { org } = ctx; - const cusEntsWithCusProduct = fullCustomerToCustomerEntitlements({ + const allCusEntsFromFullCustomer = fullCustomerToCustomerEntitlements({ fullCustomer: fullCus, inStatuses: orgToInStatuses({ org }), entity: fullCus.entity, }); + // Filter out loose entitlements (customer_product is null) - they come from extra_customer_entitlements + const cusEntsWithCusProduct = allCusEntsFromFullCustomer.filter( + (ent) => ent.customer_product !== null, + ); + // Add extra entitlements (loose entitlements not tied to a product) - const extraEnts: FullCusEntWithOptionalProduct[] = ( + const extraEnts: FullCusEntWithFullCusProduct[] = ( fullCus.extra_customer_entitlements || [] ).map((ent) => ({ ...ent, @@ -34,12 +39,12 @@ export const getApiBalances = async ({ })); // Combine both sources - const allCusEnts: FullCusEntWithOptionalProduct[] = [ + const allCusEnts: FullCusEntWithFullCusProduct[] = [ ...cusEntsWithCusProduct, ...extraEnts, ]; - const featureToCusEnt: Record = {}; + const featureToCusEnt: Record = {}; for (const cusEnt of allCusEnts) { const featureId = cusEnt.entitlement.feature.id; featureToCusEnt[featureId] = [ diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 4c7edf47a..1853b81aa 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -1,21 +1,21 @@ import type { - AppEnv, - CusProductStatus, - ListCustomersV2Params, + AppEnv, + CusProductStatus, + ListCustomersV2Params, } from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { - const withStatusFilter = () => { - return inStatuses - ? sql`AND cp.status = ANY(ARRAY[${sql.join( - inStatuses.map((status) => sql`${status}`), - sql`, `, - )}])` - : sql``; - }; + const withStatusFilter = () => { + return inStatuses + ? sql`AND cp.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}])` + : sql``; + }; - return sql` + return sql` customer_products_with_prices AS ( SELECT cp.*, @@ -83,11 +83,11 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { }; const buildEntitiesCTE = (withEntities: boolean) => { - if (!withEntities) { - return sql``; - } + if (!withEntities) { + return sql``; + } - return sql` + return sql` customer_entities AS ( SELECT COALESCE( @@ -105,11 +105,11 @@ const buildEntitiesCTE = (withEntities: boolean) => { }; const buildEntityCTE = (entityId?: string) => { - if (!entityId) { - return sql``; - } + if (!entityId) { + return sql``; + } - return sql` + return sql` entity_record AS ( SELECT * FROM entities e WHERE e.internal_customer_id = (SELECT internal_id FROM customer_record) @@ -122,15 +122,15 @@ const buildEntityCTE = (entityId?: string) => { }; const buildTrialsUsedCTE = ( - withTrialsUsed: boolean, - orgId: string, - env: AppEnv, + withTrialsUsed: boolean, + orgId: string, + env: AppEnv, ) => { - if (!withTrialsUsed) { - return sql``; - } + if (!withTrialsUsed) { + return sql``; + } - return sql` + return sql` customer_trials_used AS ( SELECT COALESCE( @@ -153,14 +153,14 @@ const buildTrialsUsedCTE = ( }; const buildSubscriptionsCTE = ( - withSubs: boolean, - inStatuses?: CusProductStatus[], + withSubs: boolean, + inStatuses?: CusProductStatus[], ) => { - if (!withSubs) { - return sql``; - } + if (!withSubs) { + return sql``; + } - return sql` + return sql` customer_subscriptions AS ( SELECT COALESCE( @@ -177,7 +177,7 @@ const buildSubscriptionsCTE = ( }; const buildExtraEntitlementsCTE = () => { - return sql` + return sql` extra_customer_entitlements AS ( SELECT COALESCE( @@ -221,15 +221,15 @@ const buildExtraEntitlementsCTE = () => { }; const buildInvoicesCTE = (hasEntityCTE: boolean) => { - const entityFilter = hasEntityCTE - ? sql`AND ( + const entityFilter = hasEntityCTE + ? sql`AND ( NOT EXISTS (SELECT 1 FROM entity_record) OR i.internal_entity_id = (SELECT internal_id FROM entity_record LIMIT 1) OR i.internal_entity_id IS NULL )` - : sql``; + : sql``; - return sql` + return sql` customer_invoices AS ( SELECT COALESCE( @@ -245,21 +245,21 @@ const buildInvoicesCTE = (hasEntityCTE: boolean) => { }; export const getFullCusQuery = ( - idOrInternalId: string, - orgId: string, - env: AppEnv, - inStatuses: CusProductStatus[], - includeInvoices: boolean, - withEntities: boolean, - withTrialsUsed: boolean, - withSubs: boolean, - withEvents: boolean, - entityId?: string, + idOrInternalId: string, + orgId: string, + env: AppEnv, + inStatuses: CusProductStatus[], + includeInvoices: boolean, + withEntities: boolean, + withTrialsUsed: boolean, + withSubs: boolean, + withEvents: boolean, + entityId?: string, ) => { - const sqlChunks: SQL[] = []; + const sqlChunks: SQL[] = []; - // Step 1: Get customer record - sqlChunks.push(sql` + // Step 1: Get customer record + sqlChunks.push(sql` WITH customer_record AS ( SELECT * FROM customers c WHERE ( @@ -272,49 +272,49 @@ export const getFullCusQuery = ( ) `); - // Step 2: Get entities - if (withEntities) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildEntitiesCTE(withEntities)); - } + // Step 2: Get entities + if (withEntities) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildEntitiesCTE(withEntities)); + } - // Step 3: Get entity - if (entityId) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildEntityCTE(entityId)); - } + // Step 3: Get entity + if (entityId) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildEntityCTE(entityId)); + } - // Add customer products CTE - sqlChunks.push(sql`, `); - // sqlChunks.push(buildCusProductsCTE(inStatuses)); - sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses)); + // Add customer products CTE + sqlChunks.push(sql`, `); + // sqlChunks.push(buildCusProductsCTE(inStatuses)); + sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses)); - // Conditionally add trials used CTE - if (withTrialsUsed) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env)); - } + // Conditionally add trials used CTE + if (withTrialsUsed) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildTrialsUsedCTE(withTrialsUsed, orgId, env)); + } - // Conditionally add subscriptions CTE - if (withSubs) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses)); - } + // Conditionally add subscriptions CTE + if (withSubs) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildSubscriptionsCTE(withSubs, inStatuses)); + } - // Conditionally add extra entitlements CTE - sqlChunks.push(sql`, `); - sqlChunks.push(buildExtraEntitlementsCTE()); + // Conditionally add extra entitlements CTE + sqlChunks.push(sql`, `); + sqlChunks.push(buildExtraEntitlementsCTE()); - // Conditionally add invoices CTE - if (includeInvoices) { - sqlChunks.push(sql`, `); - sqlChunks.push(buildInvoicesCTE(!!entityId)); - } + // Conditionally add invoices CTE + if (includeInvoices) { + sqlChunks.push(sql`, `); + sqlChunks.push(buildInvoicesCTE(!!entityId)); + } - // Conditionally add events CTE - if (withEvents) { - sqlChunks.push(sql`, `); - sqlChunks.push(sql` + // Conditionally add events CTE + if (withEvents) { + sqlChunks.push(sql`, `); + sqlChunks.push(sql` customer_events AS ( SELECT COALESCE( @@ -335,11 +335,11 @@ export const getFullCusQuery = ( AND e.set_usage = false ) `); - } + } - // Build final SELECT - const selectFieldsChunks: SQL[] = []; - selectFieldsChunks.push(sql` + // Build final SELECT + const selectFieldsChunks: SQL[] = []; + selectFieldsChunks.push(sql` cr.*, COALESCE( (SELECT json_agg(cpwp) FROM customer_products_with_prices cpwp), @@ -347,157 +347,155 @@ export const getFullCusQuery = ( ) AS customer_products `); - // Add entities to SELECT if withEntities is true - if (withEntities) { - selectFieldsChunks.push(sql`, + // Add entities to SELECT if withEntities is true + if (withEntities) { + selectFieldsChunks.push(sql`, (SELECT entities FROM customer_entities) AS entities`); - } + } - // Add entity to SELECT if entityId is provided - if (entityId) { - selectFieldsChunks.push(sql`, + // Add entity to SELECT if entityId is provided + if (entityId) { + selectFieldsChunks.push(sql`, (SELECT row_to_json(er) FROM entity_record er LIMIT 1) AS entity`); - } + } - // Add trials used to SELECT if withTrialsUsed is true - if (withTrialsUsed) { - selectFieldsChunks.push(sql`, + // Add trials used to SELECT if withTrialsUsed is true + if (withTrialsUsed) { + selectFieldsChunks.push(sql`, (SELECT trials_used FROM customer_trials_used) AS trials_used`); - } + } - // Add subscriptions to SELECT if withSubs is true - if (withSubs) { - selectFieldsChunks.push(sql`, + // Add subscriptions to SELECT if withSubs is true + if (withSubs) { + selectFieldsChunks.push(sql`, (SELECT subscriptions FROM customer_subscriptions) AS subscriptions`); - } + } - // Add extra entitlements to SELECT if withExtraEntitlements is true - selectFieldsChunks.push(sql`, + selectFieldsChunks.push(sql`, (SELECT extra_customer_entitlements FROM extra_customer_entitlements) AS extra_customer_entitlements`); - if (includeInvoices) { - selectFieldsChunks.push(sql`, + if (includeInvoices) { + selectFieldsChunks.push(sql`, (SELECT invoices FROM customer_invoices) AS invoices`); - } + } - if (withEvents) { - selectFieldsChunks.push(sql`, + if (withEvents) { + selectFieldsChunks.push(sql`, (SELECT events FROM customer_events) AS events`); - } + } - sqlChunks.push(sql` + sqlChunks.push(sql` SELECT ${sql.join(selectFieldsChunks, sql``)} FROM customer_record cr `); - return sql.join(sqlChunks, sql``); + return sql.join(sqlChunks, sql``); }; export const getPaginatedFullCusQuery = ({ - orgId, - env, - inStatuses, - includeInvoices, - withEntities, - withTrialsUsed, - withSubs, - limit = 10, - offset = 0, - withEvents = false, - entityId, - internalCustomerIds, - plans, - search, + orgId, + env, + inStatuses, + includeInvoices, + withEntities, + withTrialsUsed, + withSubs, + limit = 10, + offset = 0, + withEvents = false, + entityId, + internalCustomerIds, + plans, + search, }: { - orgId: string; - env: AppEnv; - inStatuses?: CusProductStatus[]; - includeInvoices: boolean; - withEntities: boolean; - withTrialsUsed: boolean; - withSubs: boolean; - limit: number; - offset: number; - withEvents?: boolean; - entityId?: string; - internalCustomerIds?: string[]; - plans?: ListCustomersV2Params["plans"]; - search?: string; + orgId: string; + env: AppEnv; + inStatuses?: CusProductStatus[]; + includeInvoices: boolean; + withEntities: boolean; + withTrialsUsed: boolean; + withSubs: boolean; + limit: number; + offset: number; + withEvents?: boolean; + entityId?: string; + internalCustomerIds?: string[]; + plans?: ListCustomersV2Params["plans"]; + search?: string; }) => { - const withStatusFilter = () => { - return inStatuses?.length - ? sql`AND cp.status = ANY(ARRAY[${sql.join( - inStatuses.map((status) => sql`${status}`), - sql`, `, - )}])` - : sql``; - }; + const withStatusFilter = () => { + return inStatuses?.length + ? sql`AND cp.status = ANY(ARRAY[${sql.join( + inStatuses.map((status) => sql`${status}`), + sql`, `, + )}])` + : sql``; + }; - const withCustomerProductFilter = () => { - const hasStatusFilter = inStatuses && inStatuses.length > 0; - const hasPlansFilter = plans && plans.length > 0; + const withCustomerProductFilter = () => { + const hasStatusFilter = inStatuses && inStatuses.length > 0; + const hasPlansFilter = plans && plans.length > 0; - if (!hasStatusFilter && !hasPlansFilter) return sql``; + if (!hasStatusFilter && !hasPlansFilter) return sql``; - const conditions: SQL[] = []; + const conditions: SQL[] = []; - if (hasStatusFilter) { - conditions.push( - sql`cp_filter.status = ANY(ARRAY[${sql.join( - inStatuses.map((s) => sql`${s}`), - sql`, `, - )}])`, - ); - } + if (hasStatusFilter) { + conditions.push( + sql`cp_filter.status = ANY(ARRAY[${sql.join( + inStatuses.map((s) => sql`${s}`), + sql`, `, + )}])`, + ); + } - if (hasPlansFilter) { - const planConditions = plans.map((plan) => { - if (plan.versions && plan.versions.length > 0) { - return sql`(p_filter.id = ${plan.id} AND p_filter.version IN (${sql.join( - plan.versions.map((v) => sql`${v}`), - sql`, `, - )}))`; - } - return sql`p_filter.id = ${plan.id}`; - }); + if (hasPlansFilter) { + const planConditions = plans.map((plan) => { + if (plan.versions && plan.versions.length > 0) { + return sql`(p_filter.id = ${plan.id} AND p_filter.version IN (${sql.join( + plan.versions.map((v) => sql`${v}`), + sql`, `, + )}))`; + } + return sql`p_filter.id = ${plan.id}`; + }); - conditions.push(sql`(${sql.join(planConditions, sql` OR `)})`); - } + conditions.push(sql`(${sql.join(planConditions, sql` OR `)})`); + } - const needsProductJoin = hasPlansFilter; + const needsProductJoin = hasPlansFilter; - return sql`AND EXISTS ( + return sql`AND EXISTS ( SELECT 1 FROM customer_products cp_filter ${needsProductJoin ? sql`JOIN products p_filter ON cp_filter.internal_product_id = p_filter.internal_id` : sql``} WHERE cp_filter.internal_customer_id = c.internal_id AND ${sql.join(conditions, sql` AND `)} )`; - }; + }; - const withSearchFilter = () => { - if (!search) return sql``; - const pattern = `%${search}%`; - return sql`AND ( + const withSearchFilter = () => { + if (!search) return sql``; + const pattern = `%${search}%`; + return sql`AND ( c.id ILIKE ${pattern} OR c.name ILIKE ${pattern} OR c.email ILIKE ${pattern} )`; - }; + }; - return sql` + return sql` WITH customer_records AS ( SELECT c.* FROM customers c WHERE c.org_id = ${orgId} AND c.env = ${env} - ${ - internalCustomerIds && internalCustomerIds.length > 0 - ? sql`AND c.internal_id IN (${sql.join( - internalCustomerIds.map((id) => sql`${id}`), - sql`, `, - )})` - : sql`` - } + ${internalCustomerIds && internalCustomerIds.length > 0 + ? sql`AND c.internal_id IN (${sql.join( + internalCustomerIds.map((id) => sql`${id}`), + sql`, `, + )})` + : sql`` + } ${withCustomerProductFilter()} ${withSearchFilter()} ORDER BY c.created_at DESC @@ -576,9 +574,8 @@ export const getPaginatedFullCusQuery = ({ GROUP BY cpwp.internal_customer_id ) - ${ - withSubs - ? sql`, customer_subscriptions AS ( + ${withSubs + ? sql`, customer_subscriptions AS ( SELECT cpwp.internal_customer_id, COALESCE( @@ -589,12 +586,11 @@ export const getPaginatedFullCusQuery = ({ JOIN subscriptions s ON s.stripe_id = ANY(cpwp.subscription_ids) GROUP BY cpwp.internal_customer_id )` - : sql`` - } + : sql`` + } - ${ - withEntities - ? sql`, customer_entities AS ( + ${withEntities + ? sql`, customer_entities AS ( SELECT e.internal_customer_id, COALESCE( @@ -605,12 +601,11 @@ export const getPaginatedFullCusQuery = ({ WHERE e.internal_customer_id IN (SELECT internal_id FROM customer_records) GROUP BY e.internal_customer_id )` - : sql`` - } + : sql`` + } - ${ - includeInvoices - ? sql`, customer_invoices AS ( + ${includeInvoices + ? sql`, customer_invoices AS ( SELECT i.internal_customer_id, COALESCE( @@ -621,12 +616,11 @@ export const getPaginatedFullCusQuery = ({ WHERE i.internal_customer_id IN (SELECT internal_id FROM customer_records) GROUP BY i.internal_customer_id )` - : sql`` - } + : sql`` + } - ${ - withTrialsUsed - ? sql`, customer_trials_used AS ( + ${withTrialsUsed + ? sql`, customer_trials_used AS ( SELECT cp.internal_customer_id, json_agg(json_build_object( @@ -641,8 +635,8 @@ export const getPaginatedFullCusQuery = ({ AND cp.free_trial_id IS NOT NULL GROUP BY cp.internal_customer_id )` - : sql`` - } + : sql`` + } SELECT cr.*, diff --git a/server/tests/balances/check/basic/check-loose1.test.ts b/server/tests/balances/check/loose/loose-1.test.ts similarity index 100% rename from server/tests/balances/check/basic/check-loose1.test.ts rename to server/tests/balances/check/loose/loose-1.test.ts diff --git a/server/tests/balances/check/basic/check-loose2.test.ts b/server/tests/balances/check/loose/loose-2.test.ts similarity index 100% rename from server/tests/balances/check/basic/check-loose2.test.ts rename to server/tests/balances/check/loose/loose-2.test.ts diff --git a/server/tests/balances/check/basic/check-loose3.test.ts b/server/tests/balances/check/loose/loose-3.test.ts similarity index 99% rename from server/tests/balances/check/basic/check-loose3.test.ts rename to server/tests/balances/check/loose/loose-3.test.ts index 0bfdc8d9c..d63618dc2 100644 --- a/server/tests/balances/check/basic/check-loose3.test.ts +++ b/server/tests/balances/check/loose/loose-3.test.ts @@ -50,7 +50,7 @@ describe(`${chalk.yellowBright("check-loose3: mixed product + loose entitlement" await autumnV1.balances.create({ customer_id: customerId, feature_id: TestFeature.Messages, - granted_balance: "500", + granted_balance: 500, }); }); diff --git a/server/tests/balances/check/basic/check-loose4.test.ts b/server/tests/balances/check/loose/loose-4.test.ts similarity index 93% rename from server/tests/balances/check/basic/check-loose4.test.ts rename to server/tests/balances/check/loose/loose-4.test.ts index 91ee7c0a2..a0b5e84a9 100644 --- a/server/tests/balances/check/basic/check-loose4.test.ts +++ b/server/tests/balances/check/loose/loose-4.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { ApiVersion, type CheckResponseV2, EntInterval, ResetInterval } from "@autumn/shared"; +import { ApiVersion, type CheckResponseV2, ResetInterval } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; @@ -44,9 +44,9 @@ describe(`${chalk.yellowBright("check-loose4: loose entitlement with reset inter await autumnV1.balances.create({ customer_id: customerId, feature_id: TestFeature.Action1, - granted_balance: "1000", + granted_balance: 1000, reset: { - interval: EntInterval.Month, + interval: ResetInterval.Month, interval_count: 1, }, }); diff --git a/server/tests/balances/track/loose/loose-basic.test.ts b/server/tests/balances/track/loose/loose-basic.test.ts new file mode 100644 index 000000000..242b25dd8 --- /dev/null +++ b/server/tests/balances/track/loose/loose-basic.test.ts @@ -0,0 +1,53 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-basic: basic track with loose entitlement")}`, () => { + const customerId = "loose-basic"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 100 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + }); + }); + + test("should deduct from loose entitlement", async () => { + // Track 10 usage + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + // Wait for sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check balance + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance).toBeDefined(); + expect(res.balance?.plan_id).toBeNull(); + expect(res.balance?.granted_balance).toBe(100); + expect(res.balance?.current_balance).toBe(90); + expect(res.balance?.usage).toBe(10); + }); +}); diff --git a/server/tests/balances/track/loose/loose-exact.test.ts b/server/tests/balances/track/loose/loose-exact.test.ts new file mode 100644 index 000000000..fb2918450 --- /dev/null +++ b/server/tests/balances/track/loose/loose-exact.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-exact: track exact balance amount")}`, () => { + const customerId = "loose-exact"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 50 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should deduct exact balance amount leaving 0", async () => { + // Track exactly 50 (all balance) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(false); // No balance left + expect(res.balance?.granted_balance).toBe(50); + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(50); + }); +}); diff --git a/server/tests/balances/track/loose/loose-incremental.test.ts b/server/tests/balances/track/loose/loose-incremental.test.ts new file mode 100644 index 000000000..48ad0936c --- /dev/null +++ b/server/tests/balances/track/loose/loose-incremental.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-incremental: multiple tracks accumulate")}`, () => { + const customerId = "loose-incremental"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 100 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + }); + }); + + test("should deduct with first track", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 20, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(80); // 100 - 20 + expect(res.balance?.usage).toBe(20); + }); + + test("should accumulate with second track", async () => { + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(50); // 80 - 30 + expect(res.balance?.usage).toBe(50); // 20 + 30 + }); +}); diff --git a/server/tests/balances/track/loose/loose-mixed.test.ts b/server/tests/balances/track/loose/loose-mixed.test.ts new file mode 100644 index 000000000..72d2842b3 --- /dev/null +++ b/server/tests/balances/track/loose/loose-mixed.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-mixed: multiple loose ents for same feature")}`, () => { + const customerId = "loose-mixed"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create first loose entitlement + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 100, + }); + + // Create second loose entitlement for same feature + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should combine multiple loose ents in balance", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.granted_balance).toBe(150); // 100 + 50 + expect(res.balance?.current_balance).toBe(150); + }); + + test("should deduct across multiple loose ents", async () => { + // Track 120 (needs both ents) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 120, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(30); // 150 - 120 + expect(res.balance?.usage).toBe(120); + }); +}); diff --git a/server/tests/balances/track/loose/loose-overage-reject.test.ts b/server/tests/balances/track/loose/loose-overage-reject.test.ts new file mode 100644 index 000000000..0c5d01b29 --- /dev/null +++ b/server/tests/balances/track/loose/loose-overage-reject.test.ts @@ -0,0 +1,48 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-overage: track more than balance caps at 0")}`, () => { + const customerId = "loose-overage"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 20 messages + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 20, + }); + }); + + test("should cap at 0 when tracking more than balance (no overage charge)", async () => { + // Track 50 (more than 20 balance) - should cap at 0, not go negative + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + // Loose entitlements cap at 0 - no negative balance, no overage charge + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(20); // Only deducted what was available + }); +}); diff --git a/server/tests/balances/track/loose/loose-product-first.test.ts b/server/tests/balances/track/loose/loose-product-first.test.ts new file mode 100644 index 000000000..2a5e89347 --- /dev/null +++ b/server/tests/balances/track/loose/loose-product-first.test.ts @@ -0,0 +1,110 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; +import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; + +const testCase = "loose-product-first"; + +const messagesFeature = constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 10, +}); + +const testProduct = constructProduct({ + type: "free", + isDefault: false, + items: [messagesFeature], +}); + +describe(`${chalk.yellowBright("loose-product-first: deducts from product before loose entitlement")}`, () => { + const customerId = testCase; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create product with 10 messages + await initProductsV0({ + ctx, + products: [testProduct], + prefix: testCase, + }); + + // Attach product to customer + await autumnV1.attach({ + customer_id: customerId, + product_id: testProduct.id, + }); + + // Wait for product attachment + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Create loose entitlement with 50 messages (created AFTER product, so deducted second) + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 50, + }); + }); + + test("should have combined balance of 60", async () => { + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.current_balance).toBe(60); // 10 from product + 50 from loose + }); + + test("should deduct from product first, then loose entitlement", async () => { + // Track 15 messages (should use all 10 from product, then 5 from loose) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 15, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.current_balance).toBe(45); // 60 - 15 + expect(res.balance?.usage).toBe(15); + }); + + test("should continue deducting from loose after product exhausted", async () => { + // Track 30 more messages (all from loose since product is exhausted) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.current_balance).toBe(15); // 45 - 30 + expect(res.balance?.usage).toBe(45); // 15 + 30 + }); +}); diff --git a/server/tests/balances/track/loose/loose-unlimited.test.ts b/server/tests/balances/track/loose/loose-unlimited.test.ts new file mode 100644 index 000000000..3489963e7 --- /dev/null +++ b/server/tests/balances/track/loose/loose-unlimited.test.ts @@ -0,0 +1,47 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-unlimited: unlimited loose entitlement")}`, () => { + const customerId = "loose-unlimited"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create unlimited loose entitlement + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + unlimited: true, + }); + }); + + test("should allow any track amount with unlimited", async () => { + // Track large amount + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 999999, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.allowed).toBe(true); + expect(res.balance?.unlimited).toBe(true); + }); +}); diff --git a/server/tests/balances/track/loose/loose-zero.test.ts b/server/tests/balances/track/loose/loose-zero.test.ts new file mode 100644 index 000000000..3f1e5e4af --- /dev/null +++ b/server/tests/balances/track/loose/loose-zero.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ApiVersion, type CheckResponseV2 } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; +import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js"; + +describe(`${chalk.yellowBright("loose-zero: track when balance is zero")}`, () => { + const customerId = "loose-zero"; + const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); + const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 }); + + beforeAll(async () => { + await initCustomerV3({ + ctx, + customerId, + withTestClock: false, + }); + + // Create loose entitlement with 10 balance, then use it all + await autumnV1.balances.create({ + customer_id: customerId, + feature_id: TestFeature.Messages, + granted_balance: 10, + }); + + // Use all balance + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + + test("should have no effect when balance is already zero", async () => { + // Verify balance is 0 + let res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(10); + + // Try to track more - should succeed but have no effect (already at 0) + await autumnV1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + res = (await autumnV2.check({ + customer_id: customerId, + feature_id: TestFeature.Messages, + })) as unknown as CheckResponseV2; + + // Still at 0, usage unchanged (capped) + expect(res.balance?.current_balance).toBe(0); + expect(res.balance?.usage).toBe(10); + }); +}); diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index a19907d0a..cf348f9e9 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -1,4 +1,4 @@ -import { ResetInterval } from "@autumn/shared"; +import { FeatureSchema, FeatureType, ResetInterval } from "@autumn/shared"; import { z } from "zod/v4"; export const CreateBalanceSchema = z.object({ @@ -14,4 +14,32 @@ export const CreateBalanceSchema = z.object({ customer_id: z.string(), }); +export const ValidateCreateBalanceParamsSchema = CreateBalanceSchema.extend({ + feature: FeatureSchema, +}).refine((data) => { + if (!data.feature) { + return false; + } + + if (data.feature.type === FeatureType.Boolean) { + if (data.granted_balance || data.unlimited || data.reset?.interval) { + return false; + } + } + + if (data.feature.type === FeatureType.Metered) { + if (!data.granted_balance && !data.unlimited) { + return false; + } + if (data.granted_balance && data.unlimited) { + return false; + } + if (data.unlimited && data.reset?.interval) { + return false; + } + } + + return true; +}); + export type CreateBalanceParams = z.infer; diff --git a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts index 736cfa160..3c8c61864 100644 --- a/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts +++ b/shared/models/cusProductModels/cusEntModels/cusEntWithProduct.ts @@ -13,15 +13,7 @@ export const FullCusEntWithFullCusProductSchema = customer_product: FullCusProductSchema.nullable(), }); -export const FullCusEntWithOptionalProductSchema = - FullCustomerEntitlementSchema.extend({ - customer_product: FullCusProductSchema.nullable(), - }); - export type FullCusEntWithProduct = z.infer; export type FullCusEntWithFullCusProduct = z.infer< typeof FullCusEntWithFullCusProductSchema >; -export type FullCusEntWithOptionalProduct = z.infer< - typeof FullCusEntWithOptionalProductSchema ->; diff --git a/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts b/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts index 61f52e9cb..7e55829c0 100644 --- a/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts +++ b/shared/models/cusProductModels/cusEntModels/resetCusEnt.ts @@ -7,5 +7,5 @@ import { export type ResetCusEnt = FullCustomerEntitlement & { customer: Customer; - customer_product: CusProduct; + customer_product: CusProduct | null; }; diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts index fe9bb0a79..412e9e397 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntToPurchasedBalance.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { BillingType } from "../../../models/productModels/priceModels/priceEnums.js"; import { cusEntToCusPrice, @@ -13,12 +13,12 @@ export const cusEntToPurchasedBalance = ({ cusEnt, entityId, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; entityId?: string; }) => { - // return 0; - // 1. If prepaid const cusPrice = cusEntToCusPrice({ cusEnt }); + + if (!cusEnt.customer_product) return 0; if (nullish(cusPrice)) { const { balance } = getCusEntBalance({ cusEnt, diff --git a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts index 5e67b7295..1e25bc117 100644 --- a/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/cusEntsToBalance.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../utils"; import { cusEntToBalance } from "../convertCusEntUtils"; @@ -7,7 +7,7 @@ export const cusEntsToBalance = ({ entityId, withRollovers = false, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; withRollovers?: boolean; }) => { diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts index f724373ed..931bd404e 100644 --- a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAdjustment.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../../utils"; import { getCusEntBalance } from "../../balanceUtils"; @@ -6,7 +6,7 @@ export const cusEntsToAdjustment = ({ cusEnts, entityId, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; }) => { return sumValues( diff --git a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts index af6a59de5..6aabf7c77 100644 --- a/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts +++ b/shared/utils/cusEntUtils/balanceUtils/grantedBalanceUtils/cusEntsToAllowance.ts @@ -1,5 +1,5 @@ import { Decimal } from "decimal.js"; -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; +import type { FullCusEntWithFullCusProduct } from "../../../../models/cusProductModels/cusEntModels/cusEntWithProduct"; import { sumValues } from "../../../utils"; import { getCusEntBalance } from "../../balanceUtils"; import { getRolloverFields } from "../../getRolloverFields"; @@ -10,7 +10,7 @@ export const cusEntsToAllowance = ({ entityId, withRollovers = false, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; withRollovers?: boolean; }) => { @@ -19,7 +19,7 @@ export const cusEntsToAllowance = ({ entityId, withRollovers = false, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; entityId?: string; withRollovers?: boolean; }) => { diff --git a/shared/utils/cusEntUtils/convertCusEntUtils.ts b/shared/utils/cusEntUtils/convertCusEntUtils.ts index 9c1cac68a..53c24be9c 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils.ts @@ -1,10 +1,7 @@ import { Decimal } from "decimal.js"; import type { ApiBalanceBreakdown } from "../../api/customers/cusFeatures/apiBalance.js"; import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import type { - FullCusEntWithFullCusProduct, - FullCusEntWithOptionalProduct, -} from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { resetIntvToEntIntv } from "../planFeatureUtils/planFeatureIntervals.js"; import { cusEntToCusPrice, @@ -17,7 +14,7 @@ import { getStartingBalance } from "./getStartingBalance.js"; export const cusEntToKey = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { // Interval const interval = `${cusEnt.entitlement.interval_count ?? 1}:${cusEnt.entitlement.interval}`; @@ -34,7 +31,7 @@ export const cusEntToKey = ({ export const cusEntsToPlanId = ({ cusEnts, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: (FullCusEntWithFullCusProduct)[]; }) => { // Get number of keys const uniquePlanIds = new Set(); @@ -82,10 +79,12 @@ export const cusEntToIncludedUsage = ({ entityId, withRollovers = false, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; entityId?: string; withRollovers?: boolean; }) => { + if (!cusEnt.customer_product) return 0; + const rollover = getRolloverFields({ cusEnt, entityId, diff --git a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts index 1e0f9f80f..55ebdd6e4 100644 --- a/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts +++ b/shared/utils/cusEntUtils/convertCusEntUtils/cusEntsToMaxPurchase.ts @@ -2,7 +2,6 @@ import { Decimal } from "decimal.js"; import { cusEntToIncludedUsage, type FullCusEntWithFullCusProduct, - type FullCusEntWithOptionalProduct, isPrepaidCusEnt, notNullish, nullish, @@ -12,7 +11,7 @@ export const cusEntsToMaxPurchase = ({ cusEnts, entityId, }: { - cusEnts: (FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct)[]; + cusEnts: FullCusEntWithFullCusProduct[]; entityId?: string; }): number | null => { // 1. If there's usage-based cus ent, return undefined diff --git a/shared/utils/cusEntUtils/cusEntUtils.ts b/shared/utils/cusEntUtils/cusEntUtils.ts index e1a552c42..8517f3351 100644 --- a/shared/utils/cusEntUtils/cusEntUtils.ts +++ b/shared/utils/cusEntUtils/cusEntUtils.ts @@ -1,7 +1,7 @@ import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js"; import type { PgDeductionUpdate } from "../../api/balances/track/trackTypes/pgDeductionUpdate.js"; import type { FullCustomer } from "../../models/cusModels/fullCusModel.js"; -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import { cusEntToCusPrice } from "../productUtils/convertUtils.js"; import { isPrepaidPrice } from "../productUtils/priceUtils.js"; @@ -104,12 +104,13 @@ export const updateCusEntInFullCus = ({ export const isPrepaidCusEnt = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { - // 2. If cus ent is not prepaid, skip const cusPrice = cusEntToCusPrice({ cusEnt }); if (!cusPrice || !isPrepaidPrice({ price: cusPrice.price })) return false; + if (!cusEnt.customer_product) return false; + // 3. Get quantity const options = cusEnt.customer_product?.options?.find( (option) => diff --git a/shared/utils/productUtils/convertUtils.ts b/shared/utils/productUtils/convertUtils.ts index 1ba7600c9..47d15c073 100644 --- a/shared/utils/productUtils/convertUtils.ts +++ b/shared/utils/productUtils/convertUtils.ts @@ -1,4 +1,4 @@ -import type { FullCusEntWithFullCusProduct, FullCusEntWithOptionalProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js"; import type { FullCustomerPrice } from "@models/cusProductModels/cusPriceModels/cusPriceModels.js"; import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js"; import type { @@ -74,7 +74,7 @@ export const entToOptions = ({ export const cusEntToCusPrice = ({ cusEnt, }: { - cusEnt: FullCusEntWithFullCusProduct | FullCusEntWithOptionalProduct; + cusEnt: FullCusEntWithFullCusProduct; }) => { const cusProduct = cusEnt.customer_product; const cusPrices = cusProduct?.customer_prices ?? [];