refactoring track

This commit is contained in:
John Yeo
2025-10-30 07:11:42 -07:00
parent 7102a8be8d
commit f102310542
37 changed files with 810 additions and 184 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View 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 });
},
});

View 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;
};

View File

@@ -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;
};

View 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,
});
};

View File

@@ -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,
},
});
}
}
}
};

View File

@@ -11,7 +11,7 @@ export const deductFromApiCusRollovers = async ({
deductParams: RolloverDeductParams;
cusEnt: FullCusEntWithFullCusProduct;
}) => {
if (toDeduct == 0) {
if (toDeduct === 0) {
return toDeduct;
}

View File

@@ -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) {

View File

@@ -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 }) &&

View File

@@ -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);

View File

@@ -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,

View 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>;

View File

@@ -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.

View File

@@ -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>;

View File

@@ -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: {

View File

@@ -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

View File

@@ -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[];