From f1023105424dbe22d742765aa467af84362bff77 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 30 Oct 2025 07:11:42 -0700 Subject: [PATCH] refactoring track --- server/src/initHono.ts | 4 + server/src/internal/api/apiRouter.ts | 5 +- .../check/checkUtils/getV2CheckResponse.ts | 2 +- .../internal/balances/track/handleTrack.ts | 54 +++++ .../balances/track/trackUtils/eventUtils.ts | 45 ++++ .../track/trackUtils/getFeatureDeductions.ts | 86 +++++++ .../track/trackUtils/runDeductionTx.ts | 211 ++++++++++++++++++ .../trackUtils/validateDeductionPossible.ts | 184 +++++++++++++++ .../cusRollovers/rolloverDeductionUtils.ts | 2 +- .../internal/features/creditSystemUtils.ts | 3 + server/src/trigger/updateBalanceTask.ts | 5 +- server/src/trigger/updateUsageTask.ts | 35 +-- .../{ => balances}/check/basic/check1.test.ts | 0 .../{ => balances}/check/basic/check2.test.ts | 0 .../{ => balances}/check/basic/check3.test.ts | 0 .../{ => balances}/check/basic/check4.test.ts | 0 .../{ => balances}/check/basic/check5.test.ts | 0 .../{ => balances}/check/basic/check6.test.ts | 0 .../{ => balances}/check/basic/check7.test.ts | 0 .../{ => balances}/check/basic/check8.test.ts | 0 .../credit-systems/credit-systems1.test.ts | 0 .../credit-systems/credit-systems2.test.ts | 0 .../credit-systems/credit-systems3.test.ts | 0 .../credit-systems/credit-systems4.test.ts | 0 .../track/misc}/trackMisc1.test.ts | 0 .../track/misc}/trackMisc2.test.ts | 107 +++++---- .../track/misc}/trackMisc3.test.ts | 0 .../track/misc}/trackMisc4.test.ts | 0 .../track/misc}/trackMisc5.test.ts | 0 .../track/misc}/trackMisc6.test.ts | 0 .../track/misc}/trackMisc7.test.ts | 0 shared/api/balances/trackModels.ts | 102 +++++++++ shared/api/common/jsDocs.ts | 3 +- shared/api/core/coreOpModels.ts | 82 ------- shared/api/core/coreOpenApi.ts | 39 ++-- shared/api/models.ts | 1 + .../cusProductUtils/convertCusProduct.ts | 24 +- 37 files changed, 810 insertions(+), 184 deletions(-) create mode 100644 server/src/internal/balances/track/handleTrack.ts create mode 100644 server/src/internal/balances/track/trackUtils/eventUtils.ts create mode 100644 server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts create mode 100644 server/src/internal/balances/track/trackUtils/runDeductionTx.ts create mode 100644 server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts rename server/tests/{ => balances}/check/basic/check1.test.ts (100%) rename server/tests/{ => balances}/check/basic/check2.test.ts (100%) rename server/tests/{ => balances}/check/basic/check3.test.ts (100%) rename server/tests/{ => balances}/check/basic/check4.test.ts (100%) rename server/tests/{ => balances}/check/basic/check5.test.ts (100%) rename server/tests/{ => balances}/check/basic/check6.test.ts (100%) rename server/tests/{ => balances}/check/basic/check7.test.ts (100%) rename server/tests/{ => balances}/check/basic/check8.test.ts (100%) rename server/tests/{ => balances}/check/credit-systems/credit-systems1.test.ts (100%) rename server/tests/{ => balances}/check/credit-systems/credit-systems2.test.ts (100%) rename server/tests/{ => balances}/check/credit-systems/credit-systems3.test.ts (100%) rename server/tests/{ => balances}/check/credit-systems/credit-systems4.test.ts (100%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc1.test.ts (100%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc2.test.ts (61%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc3.test.ts (100%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc4.test.ts (100%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc5.test.ts (100%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc6.test.ts (100%) rename server/tests/{trackMisc => balances/track/misc}/trackMisc7.test.ts (100%) create mode 100644 shared/api/balances/trackModels.ts diff --git a/server/src/initHono.ts b/server/src/initHono.ts index 749333882..1a708f381 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -14,6 +14,7 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js"; import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js"; import type { HonoEnv } from "./honoUtils/HonoEnv.js"; import { handleCheck } from "./internal/api/check/handleCheck.js"; +import { handleTrack } from "./internal/balances/track/handleTrack.js"; import { cusRouter } from "./internal/customers/cusRouter.js"; import { internalCusRouter } from "./internal/customers/internalCusRouter.js"; import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js"; @@ -92,6 +93,9 @@ export const createHonoApp = () => { app.use("/v1/*", queryMiddleware()); // API Routes + app.post("/v1/events", ...handleTrack); + app.post("/v1/track", ...handleTrack); + app.post("/v1/entitled", ...handleCheck); app.post("/v1/check", ...handleCheck); app.route("v1/customers", cusRouter); diff --git a/server/src/internal/api/apiRouter.ts b/server/src/internal/api/apiRouter.ts index 60a4cf8b4..2aa54480f 100644 --- a/server/src/internal/api/apiRouter.ts +++ b/server/src/internal/api/apiRouter.ts @@ -19,7 +19,6 @@ import { productBetaRouter, productRouter } from "../products/productRouter.js"; import { componentRouter } from "./components/componentRouter.js"; import { entityRouter } from "./entities/entityRouter.js"; // import { checkRouter } from "./entitled/checkRouter.js"; -import { eventsRouter } from "./events/eventRouter.js"; import { usageRouter } from "./events/usageRouter.js"; import { invoiceRouter } from "./invoiceRouter.js"; import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js"; @@ -59,8 +58,8 @@ apiRouter.use("/cancel", cancelRouter); // apiRouter.use("/entitled", checkRouter); // apiRouter.use("/check", checkRouter); -apiRouter.use("/events", eventsRouter); -apiRouter.use("/track", eventsRouter); +// apiRouter.use("/events", eventsRouter); +// apiRouter.use("/track", eventsRouter); apiRouter.post("/setup_payment", handleSetupPayment); apiRouter.post("/billing_portal", handleCreateBillingPortal); diff --git a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts index 6d70d580a..bb3458474 100644 --- a/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts +++ b/server/src/internal/api/check/checkUtils/getV2CheckResponse.ts @@ -91,7 +91,7 @@ export const getV2CheckResponse = async ({ .plus(totalPaidUsageAllowance) .gte(requiredBalance) ) { - console.log("Balance + total paid usage allowance >= required balance"); + // console.log("Balance + total paid usage allowance >= required balance"); allowed = true; } diff --git a/server/src/internal/balances/track/handleTrack.ts b/server/src/internal/balances/track/handleTrack.ts new file mode 100644 index 000000000..95c8546f9 --- /dev/null +++ b/server/src/internal/balances/track/handleTrack.ts @@ -0,0 +1,54 @@ +import { TrackParamsSchema } from "@autumn/shared"; +import { createRoute } from "../../../honoMiddlewares/routeHandler.js"; +import { + getTrackEventNameDeductions, + getTrackFeatureDeductions, +} from "./trackUtils/getFeatureDeductions.js"; +import { runDeductionTx } from "./trackUtils/runDeductionTx.js"; + +export const handleTrack = createRoute({ + body: TrackParamsSchema, + handler: async (c) => { + // 1. Get feature deductions + const body = c.req.valid("json"); + const ctx = c.get("ctx"); + + // Legacy + if (body.properties?.value) { + body.value = body.properties.value; + } + + // Build feature deductions + const featureDeductions = body.feature_id + ? getTrackFeatureDeductions({ + ctx, + featureId: body.feature_id, + value: body.value, + }) + : getTrackEventNameDeductions({ + ctx, + eventName: body.event_name!, + value: body.value, + }); + + const start = Date.now(); + await runDeductionTx({ + ctx, + customerId: body.customer_id, + entityId: body.entity_id, + deductions: featureDeductions, + eventInfo: { + event_name: body.feature_id || body.event_name!, + value: body.value ?? 1, + properties: body.properties, + timestamp: body.timestamp, + idempotency_key: body.idempotency_key, + }, + }); + + const elapsed = Date.now() - start; + ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/balances/track/trackUtils/eventUtils.ts b/server/src/internal/balances/track/trackUtils/eventUtils.ts new file mode 100644 index 000000000..ace717bf9 --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/eventUtils.ts @@ -0,0 +1,45 @@ +import type { EventInsert, FullCustomer } from "@autumn/shared"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { generateId } from "../../../../utils/genUtils.js"; + +export type EventInfo = { + event_name: string; + value?: number; + properties?: Record; + timestamp?: number; + idempotency_key?: string; +}; + +export const constructEvent = async (params: { + ctx: AutumnContext; + eventInfo: EventInfo; + fullCus: FullCustomer; +}) => { + const { ctx, eventInfo, fullCus } = params; + const { db, org, env, logger } = ctx; + + const timestampDate = eventInfo.timestamp + ? new Date(eventInfo.timestamp) + : new Date(); + + const newEvent: EventInsert = { + id: generateId("evt"), + org_id: org.id, + org_slug: org.slug, + env: env, + + internal_customer_id: fullCus.internal_id, + customer_id: fullCus.id || "", + internal_entity_id: fullCus.entity?.internal_id, + entity_id: fullCus.entity?.id, + + event_name: eventInfo.event_name, + created_at: timestampDate.getTime(), + timestamp: timestampDate, + value: eventInfo.value ?? 1, + properties: eventInfo.properties ?? {}, + idempotency_key: eventInfo.idempotency_key ?? null, + } satisfies EventInsert; + + return newEvent; +}; diff --git a/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts b/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts new file mode 100644 index 000000000..d23e24df5 --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/getFeatureDeductions.ts @@ -0,0 +1,86 @@ +import { type Feature, FeatureNotFoundError } from "@autumn/shared"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { + getCreditCost, + getCreditSystemsFromFeature, +} from "../../../features/creditSystemUtils.js"; + +export type FeatureDeduction = { + feature: Feature; + deduction: number; +}; + +const DEFAULT_VALUE = 1; + +export const getTrackFeatureDeductions = ({ + ctx, + featureId, + value, +}: { + ctx: AutumnContext; + featureId: string; + value?: number; +}) => { + const featureDeductions: FeatureDeduction[] = []; + + const mainFeatureDeduction = value ?? DEFAULT_VALUE; + + // 1. If feature ID + const features = ctx.features; + const mainFeature = features.find((f) => f.id === featureId); + if (!mainFeature) { + throw new FeatureNotFoundError({ + featureId, + }); + } + const creditSystems = getCreditSystemsFromFeature({ + featureId: mainFeature.id, + features, + }); + + featureDeductions.push({ + feature: mainFeature, + deduction: mainFeatureDeduction, + }); + + for (const creditSystem of creditSystems) { + const creditSystemDeduction = getCreditCost({ + featureId: mainFeature.id, + creditSystem, + amount: mainFeatureDeduction, + }); + + featureDeductions.push({ + feature: creditSystem, + deduction: creditSystemDeduction, + }); + } + + return featureDeductions; +}; + +export const getTrackEventNameDeductions = ({ + ctx, + eventName, + value, +}: { + ctx: AutumnContext; + eventName: string; + value?: number; +}) => { + const features = ctx.features; + + const mainFeatures = features.filter((f) => + f.event_names?.includes(eventName), + ); + + const featureDeductions = mainFeatures.flatMap((f) => + getTrackFeatureDeductions({ + ctx, + featureId: f.id, + value, + }), + ); + + return featureDeductions; +}; diff --git a/server/src/internal/balances/track/trackUtils/runDeductionTx.ts b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts new file mode 100644 index 000000000..e73691bca --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/runDeductionTx.ts @@ -0,0 +1,211 @@ +import { + CusProductStatus, + cusProductsToCusEnts, + cusProductsToPrices, +} from "@autumn/shared"; +import { sql } from "drizzle-orm"; +import type { DrizzleCli } from "../../../../db/initDrizzle.js"; +import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js"; +import { handleThresholdReached } from "../../../../trigger/handleThresholdReached.js"; +import { + deductAllowanceFromCusEnt, + deductFromUsageBasedCusEnt, +} from "../../../../trigger/updateBalanceTask.js"; +import { EventService } from "../../../api/events/EventService.js"; +import { CusService } from "../../../customers/CusService.js"; +import { refreshCusCache } from "../../../customers/cusCache/updateCachedCus.js"; +import { deductFromApiCusRollovers } from "../../../customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js"; +import { constructEvent, type EventInfo } from "./eventUtils.js"; +import type { FeatureDeduction } from "./getFeatureDeductions.js"; +import { validateDeductionPossible } from "./validateDeductionPossible.js"; + +export type DeductionTxParams = { + ctx: AutumnContext; + customerId: string; + entityId?: string; + deductions: FeatureDeduction[]; + eventInfo: EventInfo; +}; + +// const { cusEnts, cusPrices } = await getCusEntsInFeatures({ +// customer, +// internalFeatureIds: features.map((f) => f.internal_id!), +// logger, +// reverseOrder: org.config?.reverse_deduction_order, +// }); + +const deductFromCusEnts = async ({ + ctx, + customerId, + entityId, + deductions, +}: DeductionTxParams) => { + const { db, org, env } = ctx; + + const customer = await CusService.getFull({ + db, + idOrInternalId: customerId, + orgId: org.id, + env, + inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue], + entityId, + withSubs: true, + }); + + const cusEnts = cusProductsToCusEnts({ + cusProducts: customer.customer_products, + featureIds: deductions.map((d) => d.feature.id), + reverseOrder: org.config?.reverse_deduction_order, + }); + + const cusPrices = cusProductsToPrices({ + cusProducts: cusEnts.map((cusEnt) => cusEnt.customer_product), + }); + + if (cusEnts.length === 0) return; + + validateDeductionPossible({ cusEnts, deductions, entityId }); + + const originalCusEnts = structuredClone(cusEnts); + for (const obj of deductions) { + const { feature, deduction } = obj; + let toDeduct = deduction; + + for (const cusEnt of cusEnts) { + if (cusEnt.entitlement.internal_feature_id !== feature.internal_id) { + continue; + } + + toDeduct = await deductFromApiCusRollovers({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + entity: customer.entity ? customer.entity : undefined, + }, + }); + + if (toDeduct === 0) continue; + + toDeduct = await deductAllowanceFromCusEnt({ + toDeduct, + cusEnt, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + entity: customer.entity, + }, + featureDeductions: deductions, + willDeductCredits: true, + setZeroAdjustment: true, + }); + } + + if (toDeduct !== 0) { + await deductFromUsageBasedCusEnt({ + toDeduct, + cusEnts, + deductParams: { + db, + feature, + env, + org, + cusPrices: cusPrices as any[], + customer, + entity: customer.entity, + }, + setZeroAdjustment: true, + }); + } + + handleThresholdReached({ + org, + env, + features: ctx.features, + db, + feature, + cusEnts: originalCusEnts, + newCusEnts: cusEnts, + fullCus: customer, + logger: ctx.logger, + }); + + // Insert event into database + return customer; + } +}; + +export const runDeductionTx = async (params: DeductionTxParams) => { + const ctx = params.ctx; + const { db, org, env, logger } = ctx; + + await db.transaction( + async (tx) => { + // Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests + // Include entity_id in lock key so different entities can update concurrently + const lockKeyStr = `${params.customerId}_${org.id}_${env}${params.entityId ? `_${params.entityId}` : ""}`; + + const hash = + lockKeyStr.split("").reduce((acc, char) => { + return (acc << 5) - acc + char.charCodeAt(0); + }, 0) | 0; // Convert to 32-bit integer + + logger.info(`Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`); + + // Time this + const start = Date.now(); + await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`); + const elapsed = Date.now() - start; + + logger.info(`Advisory lock acquired in ${elapsed}ms`); + + const customer = await deductFromCusEnts(params); + + if (!customer) return; + + if (params.eventInfo) { + const newEvent = await constructEvent({ + ctx, + eventInfo: params.eventInfo, + fullCus: customer, + }); + + await EventService.insert({ + db: tx as unknown as DrizzleCli, + event: newEvent, + }); + } + + // return await updateUsage({ + // db: tx as unknown as DrizzleCli, + // customerId, + // features, + // value, + // properties, + // org, + // env, + // setUsage: set_usage, + // logger, + // entityId, + // allFeatures, + // }); + }, + { + isolationLevel: "read committed", + }, + ); + + await refreshCusCache({ + db, + customerId: params.customerId, + entityId: params.entityId, + org, + env, + }); +}; diff --git a/server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts b/server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts new file mode 100644 index 000000000..9edda68e1 --- /dev/null +++ b/server/src/internal/balances/track/trackUtils/validateDeductionPossible.ts @@ -0,0 +1,184 @@ +import { + ErrCode, + type Feature, + FeatureType, + FeatureUsageType, + type FullCusEntWithFullCusProduct, + type FullCustomerEntitlement, + RecaseError, +} from "@autumn/shared"; +import { Decimal } from "decimal.js"; +import { StatusCodes } from "http-status-codes"; +import { getFeatureBalance } from "../../../customers/cusProducts/cusEnts/cusEntUtils.js"; +import type { FeatureDeduction } from "./getFeatureDeductions.js"; + +/** + * Calculate total available rollover balance for a feature + */ +const calculateAvailableRolloverBalance = ({ + cusEnts, + feature, + entityId, +}: { + cusEnts: FullCustomerEntitlement[]; + feature: Feature; + entityId?: string; +}) => { + const featureCusEnts = cusEnts.filter( + (cusEnt) => cusEnt.entitlement.internal_feature_id === feature.internal_id, + ); + + if (!entityId) { + // Non-entity: sum rollover.balance + return featureCusEnts.reduce((sum, cusEnt) => { + const rolloverSum = cusEnt.rollovers.reduce( + (rSum, rollover) => + new Decimal(rSum).add(rollover.balance || 0).toNumber(), + 0, + ); + return new Decimal(sum).add(rolloverSum).toNumber(); + }, 0); + } else { + // Entity: sum rollover.entities[entityId].balance + return featureCusEnts.reduce((sum, cusEnt) => { + const rolloverSum = cusEnt.rollovers.reduce((rSum, rollover) => { + const entityRollover = rollover.entities?.[entityId]; + if (entityRollover) { + return new Decimal(rSum).add(entityRollover.balance || 0).toNumber(); + } + return rSum; + }, 0); + return new Decimal(sum).add(rolloverSum).toNumber(); + }, 0); + } +}; + +export const validateDeductionPossible = ({ + cusEnts, + deductions, + entityId, +}: { + cusEnts: FullCusEntWithFullCusProduct[]; + deductions: FeatureDeduction[]; + entityId?: string; +}) => { + for (const { feature, deduction } of deductions) { + const featureCusEnts = cusEnts.filter( + (customerEntitlement) => + customerEntitlement.entitlement.internal_feature_id === + feature.internal_id, + ); + + // CONSTRAINT 1: Insufficient balance without usage_allowed + const cusEntBalance = getFeatureBalance({ + cusEnts: featureCusEnts, + internalFeatureId: feature.internal_id!, + entityId, + }); + + // If unlimited, skip validation + if (cusEntBalance === null) { + continue; + } + const rolloverBalance = calculateAvailableRolloverBalance({ + cusEnts, + feature, + entityId, + }); + const totalBalance = new Decimal(cusEntBalance) + .add(rolloverBalance) + .toNumber(); + + const hasUsageAllowed = featureCusEnts.some( + (customerEntitlement) => customerEntitlement.usage_allowed, + ); + + // Check if this is a "free" feature (single-use with included_usage but no pricing) + // Only apply to SingleUse features; ContinuousUse (allocated) features should reject + const isFreeFeature = + feature.type === FeatureType.Metered && + feature.config?.usage_type === FeatureUsageType.Single && + featureCusEnts.some( + (cusEnt) => + cusEnt.entitlement.allowance && cusEnt.entitlement.allowance > 0, + ) && + !hasUsageAllowed; + + // For free SingleUse features, allow tracking beyond balance (will cap at 0 in performDeduction) + // For prepaid/allocated/other features without usage_allowed, reject insufficient balance + if (totalBalance < deduction && !hasUsageAllowed && !isFreeFeature) { + throw new RecaseError({ + message: `Insufficient balance for feature ${feature.id}. Available: ${totalBalance} (${cusEntBalance} + ${rolloverBalance} rollover), Required: ${deduction}`, + code: ErrCode.InsufficientBalance, + statusCode: StatusCodes.BAD_REQUEST, + data: { + feature_id: feature.id, + available: totalBalance, + cus_ent_balance: cusEntBalance, + rollover_balance: rolloverBalance, + required: deduction, + }, + }); + } + + // CONSTRAINT 2: Usage limit exceeded for customer entitlements with usage_allowed + const entitlementDeduction = + new Decimal(deduction).sub(rolloverBalance).toNumber() > 0 + ? new Decimal(deduction).sub(rolloverBalance).toNumber() + : 0; + + if (entitlementDeduction > 0) { + const featureCusEntsWithUsageAllowed = featureCusEnts.filter( + (customerEntitlement) => customerEntitlement.usage_allowed, + ); + + const totalRemainingLimit = featureCusEntsWithUsageAllowed.reduce( + (sum, cusEnt) => { + const usageLimit = cusEnt.entitlement.usage_limit; + if (!usageLimit) { + return sum; + } + + const featureBalance = getFeatureBalance({ + cusEnts: [cusEnt], + internalFeatureId: feature.internal_id!, + entityId, + }); + + // Skip if unlimited + if (featureBalance === null) { + return sum; + } + + const allowance = new Decimal(cusEnt.entitlement.allowance || 0); + const currentBalance = new Decimal(featureBalance); + const currentUsed = allowance.sub(currentBalance); + const remainingLimit = new Decimal(usageLimit).sub(currentUsed); + + return new Decimal(sum) + .add(Decimal.max(0, remainingLimit)) + .toNumber(); + }, + 0, + ); + + if ( + featureCusEntsWithUsageAllowed.length > 0 && + entitlementDeduction > totalRemainingLimit + ) { + throw new RecaseError({ + message: `Usage limit exceeded for feature ${feature.id}. Total remaining capacity: ${totalRemainingLimit}, Requested from entitlement: ${entitlementDeduction} (${rolloverBalance} covered by rollovers)`, + code: ErrCode.InsufficientBalance, + statusCode: StatusCodes.BAD_REQUEST, + data: { + feature_id: feature.id, + total_remaining_capacity: totalRemainingLimit, + requested_from_entitlement: entitlementDeduction, + covered_by_rollovers: rolloverBalance, + total_requested: deduction, + }, + }); + } + } + } +}; diff --git a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts index 710e23b63..066f1bb4e 100644 --- a/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts +++ b/server/src/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.ts @@ -11,7 +11,7 @@ export const deductFromApiCusRollovers = async ({ deductParams: RolloverDeductParams; cusEnt: FullCusEntWithFullCusProduct; }) => { - if (toDeduct == 0) { + if (toDeduct === 0) { return toDeduct; } diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index 7fdbbc8d8..fab628837 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -12,6 +12,9 @@ export const creditSystemContainsFeature = ({ creditSystem: Feature; meteredFeatureId: string; }) => { + if (creditSystem.type !== FeatureType.CreditSystem) { + return false; + } const schema: CreditSchemaItem[] = creditSystem.config.schema; for (const schemaItem of schema) { diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index f78302719..8ad10ac24 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -54,7 +54,7 @@ export type DeductParams = { org: Organization; cusPrices: FullCustomerPrice[]; customer: Customer; - properties: any; + // properties: any; feature: Feature; entity?: Entity; }; @@ -348,9 +348,6 @@ export const deductAllowanceFromCusEnt = async ({ }) => { const { db, feature, env, org, cusPrices, customer, entity } = deductParams; - if (toDeduct == 0) { - } - if ( entity && entityFeatureIdExists({ cusEnt }) && diff --git a/server/src/trigger/updateUsageTask.ts b/server/src/trigger/updateUsageTask.ts index 55d5c5f7b..af36b1aee 100644 --- a/server/src/trigger/updateUsageTask.ts +++ b/server/src/trigger/updateUsageTask.ts @@ -204,11 +204,14 @@ const validateDeductionPossible = ({ // Check if this is a "free" feature (single-use with included_usage but no pricing) // Only apply to SingleUse features; ContinuousUse (allocated) features should reject - const isFreeFeature = feature.type === FeatureType.Metered && + const isFreeFeature = + feature.type === FeatureType.Metered && feature.config?.usage_type === FeatureUsageType.Single && featureCusEnts.some( - (cusEnt) => cusEnt.entitlement.allowance && cusEnt.entitlement.allowance > 0 - ) && !hasUsageAllowed; + (cusEnt) => + cusEnt.entitlement.allowance && cusEnt.entitlement.allowance > 0, + ) && + !hasUsageAllowed; // For free SingleUse features, allow tracking beyond balance (will cap at 0 in performDeduction) // For prepaid/allocated/other features without usage_allowed, reject insufficient balance @@ -248,7 +251,7 @@ const validateDeductionPossible = ({ const featureBalance = getFeatureBalance({ cusEnts: [cusEnt], internalFeatureId: feature.internal_id!, - entityId + entityId, }); // Skip if unlimited @@ -441,7 +444,6 @@ export const updateUsage = async ({ org, cusPrices: cusPrices as any[], customer, - properties, entity: customer.entity, }, featureDeductions, @@ -461,7 +463,6 @@ export const updateUsage = async ({ org, cusPrices: cusPrices as any[], customer, - properties, entity: customer.entity, }, setZeroAdjustment: true, @@ -522,14 +523,19 @@ export const runUpdateUsageTask = async ({ async (tx) => { // Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests // Include entity_id in lock key so different entities can update concurrently - const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ''}`; - const hash = lockKeyStr.split('').reduce((acc, char) => { - return ((acc << 5) - acc) + char.charCodeAt(0); - }, 0) | 0; // Convert to 32-bit integer + const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`; + const hash = + lockKeyStr.split("").reduce((acc, char) => { + return (acc << 5) - acc + char.charCodeAt(0); + }, 0) | 0; // Convert to 32-bit integer - console.log(` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`); + console.log( + ` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`, + ); await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`); - console.log(` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`); + console.log( + ` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`, + ); return await updateUsage({ db: tx as unknown as DrizzleCli, @@ -557,11 +563,6 @@ export const runUpdateUsageTask = async ({ org, env, }); - - if (!cusEnts || cusEnts.length === 0) { - return; - } - console.log(" ✅ Customer balance updated"); } catch (error) { logger.error(`ERROR UPDATING USAGE`); logger.error(error); diff --git a/server/tests/check/basic/check1.test.ts b/server/tests/balances/check/basic/check1.test.ts similarity index 100% rename from server/tests/check/basic/check1.test.ts rename to server/tests/balances/check/basic/check1.test.ts diff --git a/server/tests/check/basic/check2.test.ts b/server/tests/balances/check/basic/check2.test.ts similarity index 100% rename from server/tests/check/basic/check2.test.ts rename to server/tests/balances/check/basic/check2.test.ts diff --git a/server/tests/check/basic/check3.test.ts b/server/tests/balances/check/basic/check3.test.ts similarity index 100% rename from server/tests/check/basic/check3.test.ts rename to server/tests/balances/check/basic/check3.test.ts diff --git a/server/tests/check/basic/check4.test.ts b/server/tests/balances/check/basic/check4.test.ts similarity index 100% rename from server/tests/check/basic/check4.test.ts rename to server/tests/balances/check/basic/check4.test.ts diff --git a/server/tests/check/basic/check5.test.ts b/server/tests/balances/check/basic/check5.test.ts similarity index 100% rename from server/tests/check/basic/check5.test.ts rename to server/tests/balances/check/basic/check5.test.ts diff --git a/server/tests/check/basic/check6.test.ts b/server/tests/balances/check/basic/check6.test.ts similarity index 100% rename from server/tests/check/basic/check6.test.ts rename to server/tests/balances/check/basic/check6.test.ts diff --git a/server/tests/check/basic/check7.test.ts b/server/tests/balances/check/basic/check7.test.ts similarity index 100% rename from server/tests/check/basic/check7.test.ts rename to server/tests/balances/check/basic/check7.test.ts diff --git a/server/tests/check/basic/check8.test.ts b/server/tests/balances/check/basic/check8.test.ts similarity index 100% rename from server/tests/check/basic/check8.test.ts rename to server/tests/balances/check/basic/check8.test.ts diff --git a/server/tests/check/credit-systems/credit-systems1.test.ts b/server/tests/balances/check/credit-systems/credit-systems1.test.ts similarity index 100% rename from server/tests/check/credit-systems/credit-systems1.test.ts rename to server/tests/balances/check/credit-systems/credit-systems1.test.ts diff --git a/server/tests/check/credit-systems/credit-systems2.test.ts b/server/tests/balances/check/credit-systems/credit-systems2.test.ts similarity index 100% rename from server/tests/check/credit-systems/credit-systems2.test.ts rename to server/tests/balances/check/credit-systems/credit-systems2.test.ts diff --git a/server/tests/check/credit-systems/credit-systems3.test.ts b/server/tests/balances/check/credit-systems/credit-systems3.test.ts similarity index 100% rename from server/tests/check/credit-systems/credit-systems3.test.ts rename to server/tests/balances/check/credit-systems/credit-systems3.test.ts diff --git a/server/tests/check/credit-systems/credit-systems4.test.ts b/server/tests/balances/check/credit-systems/credit-systems4.test.ts similarity index 100% rename from server/tests/check/credit-systems/credit-systems4.test.ts rename to server/tests/balances/check/credit-systems/credit-systems4.test.ts diff --git a/server/tests/trackMisc/trackMisc1.test.ts b/server/tests/balances/track/misc/trackMisc1.test.ts similarity index 100% rename from server/tests/trackMisc/trackMisc1.test.ts rename to server/tests/balances/track/misc/trackMisc1.test.ts diff --git a/server/tests/trackMisc/trackMisc2.test.ts b/server/tests/balances/track/misc/trackMisc2.test.ts similarity index 61% rename from server/tests/trackMisc/trackMisc2.test.ts rename to server/tests/balances/track/misc/trackMisc2.test.ts index 64f50e729..e5b0edb3f 100644 --- a/server/tests/trackMisc/trackMisc2.test.ts +++ b/server/tests/balances/track/misc/trackMisc2.test.ts @@ -1,34 +1,44 @@ -import { ApiVersion, ProductItemFeatureType, type Organization } from "@autumn/shared"; +import { + ApiVersion, + type Organization, + ProductItemFeatureType, +} from "@autumn/shared"; import type { AppEnv, Autumn } from "autumn-js"; import { expect } from "chai"; import chalk from "chalk"; import type { Stripe } from "stripe"; +import { addPrefixToProducts } from "tests/attach/utils.js"; import { setupBefore } from "tests/before.js"; +import { TestFeature } from "tests/setup/v2Features.js"; +import { createProducts } from "tests/utils/productUtils.js"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; -import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { addPrefixToProducts } from "tests/attach/utils.js"; -import { createProducts } from "tests/utils/productUtils.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js"; -import { TestFeature } from "tests/setup/v2Features.js"; const testCase = "trackMisc2"; const customerId = `${testCase}_cus1`; const pro = constructProduct({ - id: "pro", - items: [constructFeatureItem({ featureId: TestFeature.Users, includedUsage: 1, featureType: ProductItemFeatureType.ContinuousUse })], - type: "pro", -}) + id: "pro", + items: [ + constructFeatureItem({ + featureId: TestFeature.Users, + includedUsage: 1, + featureType: ProductItemFeatureType.ContinuousUse, + }), + ], + type: "pro", +}); describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track allocated feature with concurrent requests`)}`, () => { let db: DrizzleCli; let org: Organization; let env: AppEnv; let stripeCli: Stripe; - let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); - let autumnJs: Autumn; + const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 }); + let autumnJs: Autumn; before(async function () { await setupBefore(this); @@ -36,50 +46,53 @@ describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track a org = this.org; env = this.env; stripeCli = this.stripeCli; - autumnJs = this.autumnJs; + autumnJs = this.autumnJs; try { await (autumnInt as AutumnInt).customers.delete(customerId); } catch (_) {} - await addPrefixToProducts({ - products: [pro], - prefix: testCase, - }) + await addPrefixToProducts({ + products: [pro], + prefix: testCase, + }); - await createProducts({ - autumn: autumnInt, - products: [pro], - customerId, - db, - orgId: org.id, - env, - }) + await createProducts({ + autumn: autumnInt, + products: [pro], + customerId, + db, + orgId: org.id, + env, + }); }); it("should create a customer and issue balances", async () => { - const { customer } = await initCustomerV2({ - autumn: autumnInt, - customerId, - org, - env, - db, - attachPm: "success", - }) + const { customer } = await initCustomerV2({ + autumn: autumnInt, + customerId, + org, + env, + db, + attachPm: "success", + }); expect(customer).to.exist; expect(customer.id).to.equal(customerId); expect(customer.name).to.equal(customerId); expect(customer.email).to.equal(`${customerId}@example.com`); - await autumnJs.attach({ - customer_id: customerId, - product_id: pro.id, - }) + await autumnJs.attach({ + customer_id: customerId, + product_id: pro.id, + }); }); it("should only allow one concurrent track with balance of 1", async () => { const customer = await autumnInt.customers.get(customerId); const balance = customer.features[TestFeature.Users].balance; - expect(balance).to.equal(1, `Balance should be 1, got ${balance} | Balances: ${JSON.stringify(customer.features)}`); + expect(balance).to.equal( + 1, + `Balance should be 1, got ${balance} | Balances: ${JSON.stringify(customer.features)}`, + ); const promises = [ autumnInt.track({ @@ -109,17 +122,21 @@ describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track a }), ]; - let results = await Promise.allSettled(promises); + const results = await Promise.allSettled(promises); - const successCount = results.filter(r => r.status === "fulfilled").length; - const rejectedCount = results.filter(r => r.status === "rejected").length; + const successCount = results.filter((r) => r.status === "fulfilled").length; + const rejectedCount = results.filter((r) => r.status === "rejected").length; - expect(successCount).to.equal(1, `Expected exactly 1 success, got ${successCount} | Results: ${results.map(r => r.status).join(", ")}`); - expect(rejectedCount).to.equal(4, `Expected exactly 4 rejections, got ${rejectedCount} | Results: ${results.map(r => r.status).join(", ")}`); - - const { data: balances, error } = await autumnJs.customers.get( - customerId, + expect(successCount).to.equal( + 1, + `Expected exactly 1 success, got ${successCount} | Results: ${results.map((r) => r.status).join(", ")}`, ); + expect(rejectedCount).to.equal( + 4, + `Expected exactly 4 rejections, got ${rejectedCount} | Results: ${results.map((r) => r.status).join(", ")}`, + ); + + const { data: balances, error } = await autumnJs.customers.get(customerId); expect(error).to.be.null; expect(balances?.features[TestFeature.Users]?.balance).to.equal( 0, diff --git a/server/tests/trackMisc/trackMisc3.test.ts b/server/tests/balances/track/misc/trackMisc3.test.ts similarity index 100% rename from server/tests/trackMisc/trackMisc3.test.ts rename to server/tests/balances/track/misc/trackMisc3.test.ts diff --git a/server/tests/trackMisc/trackMisc4.test.ts b/server/tests/balances/track/misc/trackMisc4.test.ts similarity index 100% rename from server/tests/trackMisc/trackMisc4.test.ts rename to server/tests/balances/track/misc/trackMisc4.test.ts diff --git a/server/tests/trackMisc/trackMisc5.test.ts b/server/tests/balances/track/misc/trackMisc5.test.ts similarity index 100% rename from server/tests/trackMisc/trackMisc5.test.ts rename to server/tests/balances/track/misc/trackMisc5.test.ts diff --git a/server/tests/trackMisc/trackMisc6.test.ts b/server/tests/balances/track/misc/trackMisc6.test.ts similarity index 100% rename from server/tests/trackMisc/trackMisc6.test.ts rename to server/tests/balances/track/misc/trackMisc6.test.ts diff --git a/server/tests/trackMisc/trackMisc7.test.ts b/server/tests/balances/track/misc/trackMisc7.test.ts similarity index 100% rename from server/tests/trackMisc/trackMisc7.test.ts rename to server/tests/balances/track/misc/trackMisc7.test.ts diff --git a/shared/api/balances/trackModels.ts b/shared/api/balances/trackModels.ts new file mode 100644 index 000000000..b47a7767a --- /dev/null +++ b/shared/api/balances/trackModels.ts @@ -0,0 +1,102 @@ +import { z } from "zod/v4"; +import { EntityDataSchema } from "../../models/cusModels/entityModels/entityModels.js"; +import { CustomerDataSchema } from "../common/customerData.js"; + +const trackDescriptions = { + customer_id: "The ID of the customer", + customer_data: + "Customer data to create or update the customer if they don't exist", + event_name: "The name of the event to track", + feature_id: + "The ID of the feature (alternative to event_name for usage events)", + properties: "Additional properties for the event", + timestamp: "Unix timestamp in milliseconds when the event occurred", + idempotency_key: "Idempotency key to prevent duplicate events", + value: "The value/count of the event", + set_usage: "Whether to set the usage to this value instead of increment", + entity_id: "The ID of the entity this event is associated with", + entity_data: "Data for creating the entity if it doesn't exist", +}; + +// Track Schemas +export const TrackParamsSchema = z + .object({ + customer_id: z.string().nonempty().meta({ + description: trackDescriptions.customer_id, + }), + customer_data: CustomerDataSchema.optional().meta({ + description: trackDescriptions.customer_data, + }), + + feature_id: z.string().optional().meta({ + description: trackDescriptions.feature_id, + }), + + event_name: z.string().nonempty().optional().meta({ + description: trackDescriptions.event_name, + }), + + value: z.number().optional().meta({ + description: trackDescriptions.value, + }), + + properties: z.record(z.string(), z.any()).optional().meta({ + description: "Additional properties for the event", + }), + timestamp: z.number().optional().meta({ + description: "Unix timestamp in milliseconds when the event occurred", + }), + idempotency_key: z.string().optional().meta({ + description: "Idempotency key to prevent duplicate events", + }), + + set_usage: z.boolean().nullish().meta({ + description: + "Whether to set the usage to this value instead of increment", + }), + entity_id: z.string().optional().meta({ + description: "The ID of the entity this event is associated with", + }), + entity_data: EntityDataSchema.optional().meta({ + description: "Data for creating the entity if it doesn't exist", + }), + }) + .refine( + (data) => { + if (data.feature_id && data.event_name) { + return false; + } + + if (!data.feature_id && !data.event_name) { + return false; + } + + return true; + }, + { + message: "Either feature_id or event_name must be provided", + }, + ); + +export const TrackResultSchema = z.object({ + id: z.string().meta({ + description: "The ID of the created event", + }), + code: z.string().meta({ + description: "Response code", + }), + customer_id: z.string().meta({ + description: "The ID of the customer", + }), + entity_id: z.string().optional().meta({ + description: "The ID of the entity (if provided)", + }), + event_name: z.string().optional().meta({ + description: "The name of the event", + }), + feature_id: z.string().optional().meta({ + description: "The ID of the feature (if provided)", + }), +}); + +export type TrackParams = z.infer; diff --git a/shared/api/common/jsDocs.ts b/shared/api/common/jsDocs.ts index cf4193f43..188af6ef1 100644 --- a/shared/api/common/jsDocs.ts +++ b/shared/api/common/jsDocs.ts @@ -4,6 +4,7 @@ import { docLink, example, } from "@api/utils/openApiHelpers.js"; +import { TrackParamsSchema } from "../balances/trackModels.js"; import { SetUsageParamsSchema } from "../balances/usageModels.js"; import { CheckParamsSchema } from "../core/checkModels.js"; import { @@ -11,9 +12,7 @@ import { CancelBodySchema, QueryParamsSchema, SetupPaymentParamsSchema, - TrackParamsSchema, } from "../core/coreOpModels.js"; - /** * Centralized JSDoc declarations for all core API methods. * These are used by the OpenAPI spec generator and propagate to SDK documentation. diff --git a/shared/api/core/coreOpModels.ts b/shared/api/core/coreOpModels.ts index af0f81419..247fcdabb 100644 --- a/shared/api/core/coreOpModels.ts +++ b/shared/api/core/coreOpModels.ts @@ -1,6 +1,4 @@ import { z } from "zod/v4"; -import { CustomerDataSchema } from "../common/customerData.js"; -import { EntityDataSchema } from "../common/entityData.js"; // Cancel Schemas export const CancelBodySchema = z.object({ @@ -42,84 +40,6 @@ export const CancelResultSchema = z.object({ }), }); -// Track Schemas -export const TrackParamsSchema = z.object({ - customer_id: z.string().nonempty().meta({ - description: "The ID of the customer", - example: "cus_123", - }), - customer_data: CustomerDataSchema.nullish().meta({ - description: - "Customer data to create or update the customer if they don't exist", - }), - event_name: z.string().nonempty().optional().meta({ - description: "The name of the event to track", - example: "api_call", - }), - feature_id: z.string().optional().meta({ - description: - "The ID of the feature (alternative to event_name for usage events)", - example: "api_calls", - }), - properties: z - .record(z.string(), z.any()) - .nullish() - .meta({ - description: "Additional properties for the event", - example: { endpoint: "/api/users" }, - }), - timestamp: z.number().nullish().meta({ - description: "Unix timestamp in milliseconds when the event occurred", - example: 1717000000000, - }), - idempotency_key: z.string().nullish().meta({ - description: "Idempotency key to prevent duplicate events", - example: "evt_abc123", - }), - value: z.number().nullish().meta({ - description: "The value/count of the event", - example: 1, - }), - set_usage: z.boolean().nullish().meta({ - description: "Whether to set the usage to this value instead of increment", - example: false, - }), - entity_id: z.string().nullish().meta({ - description: "The ID of the entity this event is associated with", - example: "entity_123", - }), - entity_data: EntityDataSchema.nullish().meta({ - description: "Data for creating the entity if it doesn't exist", - }), -}); - -export const TrackResultSchema = z.object({ - id: z.string().meta({ - description: "The ID of the created event", - example: "evt_123", - }), - code: z.string().meta({ - description: "Response code", - example: "event_received", - }), - customer_id: z.string().meta({ - description: "The ID of the customer", - example: "cus_123", - }), - entity_id: z.string().optional().meta({ - description: "The ID of the entity (if provided)", - example: "entity_123", - }), - event_name: z.string().optional().meta({ - description: "The name of the event", - example: "api_call", - }), - feature_id: z.string().optional().meta({ - description: "The ID of the feature (if provided)", - example: "api_calls", - }), -}); - // Query Schemas export const QueryParamsSchema = z .object({ @@ -217,8 +137,6 @@ export const BillingPortalResultSchema = z.object({ export type CancelBody = z.infer; export type CancelResult = z.infer; -export type TrackParams = z.infer; -export type TrackResult = z.infer; export type QueryParams = z.infer; export type QueryResult = z.infer; export type SetupPaymentParams = z.infer; diff --git a/shared/api/core/coreOpenApi.ts b/shared/api/core/coreOpenApi.ts index 296a36c82..0377c1c07 100644 --- a/shared/api/core/coreOpenApi.ts +++ b/shared/api/core/coreOpenApi.ts @@ -16,7 +16,6 @@ import { queryJsDoc, setUsageJsDoc, setupPaymentJsDoc, - trackJsDoc, } from "../common/jsDocs.js"; import { CheckParamsSchema, CheckResultSchema } from "./checkModels.js"; import { @@ -28,8 +27,6 @@ import { QueryResultSchema, SetupPaymentParamsSchema, SetupPaymentResultSchema, - TrackParamsSchema, - TrackResultSchema, } from "./coreOpModels.js"; export const coreOps: ZodOpenApiPathsObject = { @@ -103,24 +100,24 @@ export const coreOps: ZodOpenApiPathsObject = { }, }, }, - "/track": { - post: { - summary: "Track Event", - description: trackJsDoc, - tags: ["core"], - requestBody: { - content: { - "application/json": { schema: TrackParamsSchema }, - }, - }, - responses: { - "200": { - description: "200 OK", - content: { "application/json": { schema: TrackResultSchema } }, - }, - }, - }, - }, + // "/track": { + // post: { + // summary: "Track Event", + // description: trackJsDoc, + // tags: ["core"], + // requestBody: { + // content: { + // "application/json": { schema: TrackParamsSchema }, + // }, + // }, + // responses: { + // "200": { + // description: "200 OK", + // content: { "application/json": { schema: TrackResultSchema } }, + // }, + // }, + // }, + // }, "/query": { post: { diff --git a/shared/api/models.ts b/shared/api/models.ts index 2a415c6c9..114d190ee 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -60,6 +60,7 @@ export * from "./referrals/referralsOpenApi.js"; // Balances export * from "./balances/check/previousVersions/CheckResponseV0.js"; +export * from "./balances/trackModels.js"; // Errors export * from "./errors/index.js"; // Models diff --git a/shared/utils/cusProductUtils/convertCusProduct.ts b/shared/utils/cusProductUtils/convertCusProduct.ts index e88d5e100..014b7b621 100644 --- a/shared/utils/cusProductUtils/convertCusProduct.ts +++ b/shared/utils/cusProductUtils/convertCusProduct.ts @@ -1,12 +1,12 @@ -import { getBillingType } from "../productUtils/priceUtils.js"; -import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js"; -import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; +import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; +import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js"; import { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js"; -import { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; -import { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js"; -import { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js"; -import { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js"; -import { FullProduct } from "../../models/productModels/productModels.js"; +import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js"; +import type { BillingType } from "../../models/productModels/priceModels/priceEnums.js"; +import type { FullProduct } from "../../models/productModels/productModels.js"; +import { sortCusEntsForDeduction } from "../cusEntUtils/sortCusEntsForDeduction.js"; +import { getBillingType } from "../productUtils/priceUtils.js"; export const cusProductsToPrices = ({ cusProducts, @@ -50,11 +50,13 @@ export const cusProductsToCusEnts = ({ inStatuses = [CusProductStatus.Active], reverseOrder = false, featureId, + featureIds, }: { cusProducts: FullCusProduct[]; inStatuses?: CusProductStatus[]; reverseOrder?: boolean; featureId?: string; + featureIds?: string[]; }) => { let cusEnts: FullCustomerEntitlement[] = []; @@ -77,6 +79,12 @@ export const cusProductsToCusEnts = ({ ); } + if (featureIds) { + cusEnts = cusEnts.filter((cusEnt) => + featureIds.includes(cusEnt.entitlement.feature.id), + ); + } + sortCusEntsForDeduction(cusEnts, reverseOrder); return cusEnts as FullCusEntWithFullCusProduct[];