refactoring track
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
54
server/src/internal/balances/track/handleTrack.ts
Normal file
54
server/src/internal/balances/track/handleTrack.ts
Normal file
@@ -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 });
|
||||
},
|
||||
});
|
||||
45
server/src/internal/balances/track/trackUtils/eventUtils.ts
Normal file
45
server/src/internal/balances/track/trackUtils/eventUtils.ts
Normal file
@@ -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<string, any>;
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
211
server/src/internal/balances/track/trackUtils/runDeductionTx.ts
Normal file
211
server/src/internal/balances/track/trackUtils/runDeductionTx.ts
Normal file
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export const deductFromApiCusRollovers = async ({
|
||||
deductParams: RolloverDeductParams;
|
||||
cusEnt: FullCusEntWithFullCusProduct;
|
||||
}) => {
|
||||
if (toDeduct == 0) {
|
||||
if (toDeduct === 0) {
|
||||
return toDeduct;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
102
shared/api/balances/trackModels.ts
Normal file
102
shared/api/balances/trackModels.ts
Normal file
@@ -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<typeof TrackParamsSchema>;
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof CancelBodySchema>;
|
||||
export type CancelResult = z.infer<typeof CancelResultSchema>;
|
||||
export type TrackParams = z.infer<typeof TrackParamsSchema>;
|
||||
export type TrackResult = z.infer<typeof TrackResultSchema>;
|
||||
export type QueryParams = z.infer<typeof QueryParamsSchema>;
|
||||
export type QueryResult = z.infer<typeof QueryResultSchema>;
|
||||
export type SetupPaymentParams = z.infer<typeof SetupPaymentParamsSchema>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user