fix: rewrote track in psql functions
This commit is contained in:
@@ -8,6 +8,7 @@ if [[ "$1" == *"setup"* ]]; then
|
|||||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
$MOCHA_CMD 'tests/advanced/rollovers/*.ts'
|
||||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||||
# 'tests/advanced/coupons/*.ts' \
|
# 'tests/advanced/coupons/*.ts' \
|
||||||
# 'tests/attach/updateQuantity/*.ts' \
|
# 'tests/attach/updateQuantity/*.ts' \
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
import postgres from "postgres";
|
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
|
||||||
import { schemas as schema } from "@autumn/shared";
|
import { schemas as schema } from "@autumn/shared";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
export let client = postgres(process.env.DATABASE_URL!);
|
export const client = postgres(process.env.DATABASE_URL!);
|
||||||
export let db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|
||||||
export const initDrizzle = (params?: { maxConnections?: number }) => {
|
export const initDrizzle = (params?: { maxConnections?: number }) => {
|
||||||
let maxConnections = params?.maxConnections || 10;
|
const maxConnections = params?.maxConnections;
|
||||||
const client = postgres(process.env.DATABASE_URL!, {
|
const client = postgres(process.env.DATABASE_URL!, {
|
||||||
max: maxConnections,
|
max: maxConnections,
|
||||||
});
|
});
|
||||||
|
|||||||
4
server/src/external/autumn/autumnCli.ts
vendored
4
server/src/external/autumn/autumnCli.ts
vendored
@@ -13,6 +13,7 @@ import {
|
|||||||
type LegacyVersion,
|
type LegacyVersion,
|
||||||
type OrgConfig,
|
type OrgConfig,
|
||||||
type RewardRedemption,
|
type RewardRedemption,
|
||||||
|
type TrackParams,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type {
|
import type {
|
||||||
CancelParams,
|
CancelParams,
|
||||||
@@ -21,7 +22,6 @@ import type {
|
|||||||
CheckParams,
|
CheckParams,
|
||||||
CheckResult,
|
CheckResult,
|
||||||
Customer,
|
Customer,
|
||||||
TrackParams,
|
|
||||||
UsageParams,
|
UsageParams,
|
||||||
} from "autumn-js";
|
} from "autumn-js";
|
||||||
|
|
||||||
@@ -470,7 +470,7 @@ export class AutumnInt {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
track = async (params: TrackParams & { timestamp?: number }) => {
|
track = async (params: TrackParams) => {
|
||||||
const data = await this.post(`/track`, params);
|
const data = await this.post(`/track`, params);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ if (process.env.NODE_ENV !== "development") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import cluster from "node:cluster";
|
import cluster from "node:cluster";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { AppEnv } from "@autumn/shared";
|
import { AppEnv } from "@autumn/shared";
|
||||||
import { context, trace } from "@opentelemetry/api";
|
import { context, trace } from "@opentelemetry/api";
|
||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { client, db } from "./db/initDrizzle.js";
|
import { client, db } from "./db/initDrizzle.js";
|
||||||
import { CacheManager } from "./external/caching/CacheManager.js";
|
import { CacheManager } from "./external/caching/CacheManager.js";
|
||||||
@@ -38,11 +42,43 @@ import { auth } from "./utils/auth.js";
|
|||||||
import { generateId } from "./utils/genUtils.js";
|
import { generateId } from "./utils/genUtils.js";
|
||||||
import { checkEnvVars } from "./utils/initUtils.js";
|
import { checkEnvVars } from "./utils/initUtils.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const tracer = trace.getTracer("express");
|
const tracer = trace.getTracer("express");
|
||||||
|
|
||||||
checkEnvVars();
|
checkEnvVars();
|
||||||
// subscribeToOrgUpdates({ db });
|
// subscribeToOrgUpdates({ db });
|
||||||
|
|
||||||
|
const initializeDatabaseFunctions = async () => {
|
||||||
|
try {
|
||||||
|
console.log("Initializing database functions...");
|
||||||
|
|
||||||
|
const deductRpcPath = join(
|
||||||
|
__dirname,
|
||||||
|
"internal/balances/track/trackUtils/deductRpc",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load SQL files in order: helpers first, then main function
|
||||||
|
const sqlFiles = [
|
||||||
|
"deductFromSingleEntity.sql",
|
||||||
|
"deductFromAllEntities.sql",
|
||||||
|
"deductFromRollovers.sql",
|
||||||
|
"deductAllowance.sql",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of sqlFiles) {
|
||||||
|
const sqlContent = readFileSync(join(deductRpcPath, file), "utf-8");
|
||||||
|
await db.execute(sql.raw(sqlContent));
|
||||||
|
console.log(` ✓ Loaded ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Database functions initialized successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize database functions:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -136,6 +172,9 @@ const init = async () => {
|
|||||||
ClickHouseManager.getInstance(),
|
ClickHouseManager.getInstance(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Initialize database functions
|
||||||
|
await initializeDatabaseFunctions();
|
||||||
|
|
||||||
app.use(async (req: any, res: any, next: any) => {
|
app.use(async (req: any, res: any, next: any) => {
|
||||||
req.env = req.env = req.headers.app_env || AppEnv.Sandbox;
|
req.env = req.env = req.headers.app_env || AppEnv.Sandbox;
|
||||||
req.db = db;
|
req.db = db;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { secretKeyMiddleware } from "./honoMiddlewares/secretKeyMiddleware.js";
|
|||||||
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
|
import { traceMiddleware } from "./honoMiddlewares/traceMiddleware.js";
|
||||||
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
|
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
|
||||||
import { handleCheck } from "./internal/api/check/handleCheck.js";
|
import { handleCheck } from "./internal/api/check/handleCheck.js";
|
||||||
|
import { handleSetUsage } from "./internal/balances/setUsage/handleSetUsage.js";
|
||||||
import { handleTrack } from "./internal/balances/track/handleTrack.js";
|
import { handleTrack } from "./internal/balances/track/handleTrack.js";
|
||||||
import { cusRouter } from "./internal/customers/cusRouter.js";
|
import { cusRouter } from "./internal/customers/cusRouter.js";
|
||||||
import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
|
import { internalCusRouter } from "./internal/customers/internalCusRouter.js";
|
||||||
@@ -95,6 +96,7 @@ export const createHonoApp = () => {
|
|||||||
// API Routes
|
// API Routes
|
||||||
app.post("/v1/events", ...handleTrack);
|
app.post("/v1/events", ...handleTrack);
|
||||||
app.post("/v1/track", ...handleTrack);
|
app.post("/v1/track", ...handleTrack);
|
||||||
|
app.post("/v1/usage", ...handleSetUsage);
|
||||||
|
|
||||||
app.post("/v1/entitled", ...handleCheck);
|
app.post("/v1/entitled", ...handleCheck);
|
||||||
app.post("/v1/check", ...handleCheck);
|
app.post("/v1/check", ...handleCheck);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { productBetaRouter, productRouter } from "../products/productRouter.js";
|
|||||||
import { componentRouter } from "./components/componentRouter.js";
|
import { componentRouter } from "./components/componentRouter.js";
|
||||||
import { entityRouter } from "./entities/entityRouter.js";
|
import { entityRouter } from "./entities/entityRouter.js";
|
||||||
// import { checkRouter } from "./entitled/checkRouter.js";
|
// import { checkRouter } from "./entitled/checkRouter.js";
|
||||||
import { usageRouter } from "./events/usageRouter.js";
|
|
||||||
import { invoiceRouter } from "./invoiceRouter.js";
|
import { invoiceRouter } from "./invoiceRouter.js";
|
||||||
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
|
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
|
||||||
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
|
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";
|
||||||
@@ -42,7 +41,6 @@ apiRouter.use("/rewards", rewardRouter);
|
|||||||
apiRouter.use("/features", featureRouter);
|
apiRouter.use("/features", featureRouter);
|
||||||
apiRouter.use("/internal_features", internalFeatureRouter);
|
apiRouter.use("/internal_features", internalFeatureRouter);
|
||||||
|
|
||||||
apiRouter.use("/usage", usageRouter);
|
|
||||||
apiRouter.use("/entities", entityRouter);
|
apiRouter.use("/entities", entityRouter);
|
||||||
apiRouter.use("/migrations", migrationRouter);
|
apiRouter.use("/migrations", migrationRouter);
|
||||||
|
|
||||||
@@ -57,6 +55,7 @@ apiRouter.use("/cancel", cancelRouter);
|
|||||||
|
|
||||||
// apiRouter.use("/entitled", checkRouter);
|
// apiRouter.use("/entitled", checkRouter);
|
||||||
// apiRouter.use("/check", checkRouter);
|
// apiRouter.use("/check", checkRouter);
|
||||||
|
// apiRouter.use("/usage", usageRouter);
|
||||||
|
|
||||||
// apiRouter.use("/events", eventsRouter);
|
// apiRouter.use("/events", eventsRouter);
|
||||||
// apiRouter.use("/track", eventsRouter);
|
// apiRouter.use("/track", eventsRouter);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { ErrCode, EventInsert } from "@autumn/shared";
|
import { ErrCode, type EventInsert, events } from "@autumn/shared";
|
||||||
import RecaseError from "@/utils/errorUtils.js";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { events } from "@autumn/shared";
|
|
||||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||||
import { and, eq, desc } from "drizzle-orm";
|
import RecaseError from "@/utils/errorUtils.js";
|
||||||
|
|
||||||
export class EventService {
|
export class EventService {
|
||||||
static async insert({ db, event }: { db: DrizzleCli; event: EventInsert }) {
|
static async insert({ db, event }: { db: DrizzleCli; event: EventInsert }) {
|
||||||
@@ -24,7 +23,7 @@ export class EventService {
|
|||||||
|
|
||||||
return results[0];
|
return results[0];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code == "23505") {
|
if (error.code === "23505") {
|
||||||
throw new RecaseError({
|
throw new RecaseError({
|
||||||
message:
|
message:
|
||||||
"Event (event_name, customer_id, idempotency_key) already exists.",
|
"Event (event_name, customer_id, idempotency_key) already exists.",
|
||||||
@@ -49,7 +48,7 @@ export class EventService {
|
|||||||
env: string;
|
env: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}) {
|
}) {
|
||||||
let results = await db
|
const results = await db
|
||||||
.select({
|
.select({
|
||||||
id: events.id,
|
id: events.id,
|
||||||
event_name: events.event_name,
|
event_name: events.event_name,
|
||||||
|
|||||||
123
server/src/internal/balances/setUsage/getSetUsageDeductions.ts
Normal file
123
server/src/internal/balances/setUsage/getSetUsageDeductions.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
CusProductStatus,
|
||||||
|
cusEntToIncludedUsage,
|
||||||
|
cusProductsToCusEnts,
|
||||||
|
type Feature,
|
||||||
|
FeatureType,
|
||||||
|
getRelevantFeatures,
|
||||||
|
type SetUsageParams,
|
||||||
|
sumValues,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import type { AutumnContext } from "../../../honoUtils/HonoEnv.js";
|
||||||
|
import { CusService } from "../../customers/CusService.js";
|
||||||
|
import {
|
||||||
|
getFeatureBalance,
|
||||||
|
getUnlimitedAndUsageAllowed,
|
||||||
|
} from "../../customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||||
|
import { featureToCreditSystem } from "../../features/creditSystemUtils.js";
|
||||||
|
import type { FeatureDeduction } from "../track/trackUtils/getFeatureDeductions.js";
|
||||||
|
|
||||||
|
// 2. Get deductions for each feature
|
||||||
|
export const getSetUsageDeductions = async ({
|
||||||
|
ctx,
|
||||||
|
setUsageParams,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
setUsageParams: SetUsageParams;
|
||||||
|
}): Promise<FeatureDeduction[]> => {
|
||||||
|
const { db, org, env, features: allFeatures } = ctx;
|
||||||
|
const { value, entity_id } = setUsageParams;
|
||||||
|
|
||||||
|
const features = getRelevantFeatures({
|
||||||
|
features: allFeatures,
|
||||||
|
featureId: setUsageParams.feature_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullCus = await CusService.getFull({
|
||||||
|
db: ctx.db,
|
||||||
|
idOrInternalId: setUsageParams.customer_id,
|
||||||
|
orgId: ctx.org.id,
|
||||||
|
env: ctx.env,
|
||||||
|
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
|
||||||
|
entityId: setUsageParams.entity_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cusEnts = cusProductsToCusEnts({
|
||||||
|
cusProducts: fullCus.customer_products,
|
||||||
|
reverseOrder: org.config?.reverse_deduction_order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meteredFeature =
|
||||||
|
features.find((f: Feature) => f.type === FeatureType.Metered) ||
|
||||||
|
features[0];
|
||||||
|
|
||||||
|
const featureDeductions = [];
|
||||||
|
for (const feature of features) {
|
||||||
|
let newValue = value;
|
||||||
|
|
||||||
|
const { unlimited } = getUnlimitedAndUsageAllowed({
|
||||||
|
cusEnts,
|
||||||
|
internalFeatureId: feature.internal_id!,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unlimited) continue;
|
||||||
|
|
||||||
|
if (feature.type === FeatureType.CreditSystem) {
|
||||||
|
newValue = featureToCreditSystem({
|
||||||
|
featureId: meteredFeature.id,
|
||||||
|
creditSystem: feature,
|
||||||
|
amount: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's set
|
||||||
|
let deduction = newValue;
|
||||||
|
|
||||||
|
const totalAllowance = sumValues(
|
||||||
|
cusEnts.map((cusEnt) =>
|
||||||
|
cusEntToIncludedUsage({ cusEnt, entityId: setUsageParams.entity_id }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetBalance = new Decimal(totalAllowance).sub(value).toNumber();
|
||||||
|
|
||||||
|
const totalBalance = getFeatureBalance({
|
||||||
|
cusEnts,
|
||||||
|
internalFeatureId: feature.internal_id!,
|
||||||
|
entityId: entity_id,
|
||||||
|
})!;
|
||||||
|
|
||||||
|
deduction = new Decimal(totalBalance).sub(targetBalance).toNumber();
|
||||||
|
|
||||||
|
if (deduction === 0) {
|
||||||
|
console.log(` - Skipping feature ${feature.id} -- deduction is 0`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
featureDeductions.push({
|
||||||
|
feature,
|
||||||
|
deduction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
featureDeductions.sort((a, b) => {
|
||||||
|
if (
|
||||||
|
a.feature.type === FeatureType.CreditSystem &&
|
||||||
|
b.feature.type !== FeatureType.CreditSystem
|
||||||
|
) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
a.feature.type !== FeatureType.CreditSystem &&
|
||||||
|
b.feature.type === FeatureType.CreditSystem
|
||||||
|
) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.feature.id.localeCompare(b.feature.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return featureDeductions;
|
||||||
|
};
|
||||||
39
server/src/internal/balances/setUsage/handleSetUsage.ts
Normal file
39
server/src/internal/balances/setUsage/handleSetUsage.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { SetUsageParamsSchema } from "@autumn/shared";
|
||||||
|
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
|
||||||
|
import { runDeductionTx } from "../track/trackUtils/runDeductionTx.js";
|
||||||
|
import { getSetUsageDeductions } from "./getSetUsageDeductions.js";
|
||||||
|
|
||||||
|
export const handleSetUsage = createRoute({
|
||||||
|
body: SetUsageParamsSchema,
|
||||||
|
handler: async (c) => {
|
||||||
|
// 1. Get feature deductions
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const ctx = c.get("ctx");
|
||||||
|
|
||||||
|
// Build feature deductions
|
||||||
|
const featureDeductions = await getSetUsageDeductions({
|
||||||
|
ctx,
|
||||||
|
setUsageParams: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { TrackParamsSchema } from "@autumn/shared";
|
import {
|
||||||
|
InsufficientBalanceError,
|
||||||
|
SuccessCode,
|
||||||
|
TrackParamsSchema,
|
||||||
|
} from "@autumn/shared";
|
||||||
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
|
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
|
||||||
import {
|
import {
|
||||||
getTrackEventNameDeductions,
|
getTrackEventNameDeductions,
|
||||||
@@ -31,12 +35,14 @@ export const handleTrack = createRoute({
|
|||||||
value: body.value,
|
value: body.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await runDeductionTx({
|
const { fullCus, event } = await runDeductionTx({
|
||||||
ctx,
|
ctx,
|
||||||
customerId: body.customer_id,
|
customerId: body.customer_id,
|
||||||
entityId: body.entity_id,
|
entityId: body.entity_id,
|
||||||
deductions: featureDeductions,
|
deductions: featureDeductions,
|
||||||
|
overageBehaviour: body.overage_behaviour,
|
||||||
eventInfo: {
|
eventInfo: {
|
||||||
event_name: body.feature_id || body.event_name!,
|
event_name: body.feature_id || body.event_name!,
|
||||||
value: body.value ?? 1,
|
value: body.value ?? 1,
|
||||||
@@ -49,6 +55,28 @@ export const handleTrack = createRoute({
|
|||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`);
|
ctx.logger.info(`[handleTrack] runDeductionTx ms: ${elapsed}`);
|
||||||
|
|
||||||
return c.json({ success: true });
|
const response: any = {
|
||||||
|
id: event?.id || "",
|
||||||
|
code: SuccessCode.EventReceived,
|
||||||
|
customer_id: body.customer_id,
|
||||||
|
entity_id: body.entity_id,
|
||||||
|
feature_id: body.feature_id,
|
||||||
|
event_name: body.event_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InsufficientBalanceError) {
|
||||||
|
return c.json({
|
||||||
|
id: "",
|
||||||
|
code: "insufficient_balance",
|
||||||
|
customer_id: body.customer_id,
|
||||||
|
entity_id: body.entity_id,
|
||||||
|
feature_id: body.feature_id,
|
||||||
|
event_name: body.event_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
146
server/src/internal/balances/track/trackUtils/DEDUCTION_GUIDE.md
Normal file
146
server/src/internal/balances/track/trackUtils/DEDUCTION_GUIDE.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Balance Deduction System Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The balance deduction system handles iterative deduction from customer entitlements using PostgreSQL stored functions for atomicity and performance.
|
||||||
|
|
||||||
|
## Core Deduction Logic
|
||||||
|
|
||||||
|
### Where Deductions Happen
|
||||||
|
- **Balance Level**: Direct deduction from `customer_entitlements.balance`
|
||||||
|
- **Entity Balance Level**: Deduction from `customer_entitlements.entities` JSONB field
|
||||||
|
|
||||||
|
### Sorting Customer Entitlements
|
||||||
|
Customer entitlements are sorted **before** deduction to ensure consistent deduction order. Sorting logic is in `sortCusEntsForDeduction.ts` and considers:
|
||||||
|
- Boolean flags (unlimited, active status)
|
||||||
|
- Feature types (metered vs license)
|
||||||
|
- Allowance types (quota vs time-based)
|
||||||
|
- Dates (expiration, next reset)
|
||||||
|
- Intervals
|
||||||
|
- Product types
|
||||||
|
- Creation dates
|
||||||
|
|
||||||
|
**Why?** Ensures predictable deduction order (e.g., expiring credits first, then active subscriptions).
|
||||||
|
|
||||||
|
## Key Parameters
|
||||||
|
|
||||||
|
### Input Structure
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
customer_entitlement_id: string;
|
||||||
|
credit_cost: number; // Multiplier for credit system features
|
||||||
|
entity_feature_id: string | null; // If present, deduct from entities
|
||||||
|
usage_allowed: boolean; // Can balance go negative?
|
||||||
|
min_balance: number; // Minimum balance limit (e.g., -50)
|
||||||
|
add_to_adjustment: boolean; // Track adjustment for billing
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deduction Behavior
|
||||||
|
|
||||||
|
#### 1. **usage_allowed**
|
||||||
|
- `false`: Balance stops at 0 (default)
|
||||||
|
- `true`: Balance can go negative (usage-based billing)
|
||||||
|
|
||||||
|
#### 2. **min_balance**
|
||||||
|
- Works with `usage_allowed = true`
|
||||||
|
- Prevents balance from going below this threshold
|
||||||
|
- Example: `balance = 100, min_balance = -50` → can deduct up to 150
|
||||||
|
|
||||||
|
#### 3. **credit_cost**
|
||||||
|
- Multiplies deduction amount for credit system features
|
||||||
|
- Example: Deducting 10 units with `credit_cost = 2` → deducts 20 from balance
|
||||||
|
|
||||||
|
#### 4. **add_to_adjustment**
|
||||||
|
- When `true`, updates `customer_entitlements.adjustment` field
|
||||||
|
- Tracks cumulative adjustments: `adjustment = adjustment + deducted`
|
||||||
|
- Used for billing reconciliation (see `handleUpdateBalances.ts`)
|
||||||
|
|
||||||
|
## Entity-Scoped Deductions
|
||||||
|
|
||||||
|
### Single Entity (entity_id provided)
|
||||||
|
- Deducts from specific entity in `entities` JSONB
|
||||||
|
- Example: `entities = { "org1": { "balance": 100 } }`
|
||||||
|
- Deducts from `entities.org1.balance`
|
||||||
|
|
||||||
|
### All Entities (entity_id = null)
|
||||||
|
- Iterates through each entity key in `entities`
|
||||||
|
- Deducts sequentially until amount satisfied or all entities exhausted
|
||||||
|
- Example: Deduct 150 from `{ "org1": { "balance": 100 }, "org2": { "balance": 100 } }`
|
||||||
|
- Result: `{ "org1": { "balance": 0 }, "org2": { "balance": 50 } }`
|
||||||
|
|
||||||
|
## Return Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
updates: {
|
||||||
|
[cusEntId]: {
|
||||||
|
balance: number;
|
||||||
|
entities: JSONB;
|
||||||
|
adjustment: number;
|
||||||
|
deducted: number;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remaining: number // Amount that couldn't be deducted
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overage Behavior
|
||||||
|
|
||||||
|
### reject (default)
|
||||||
|
- If `remaining > 0`, throws error
|
||||||
|
- Use when strict balance enforcement required
|
||||||
|
|
||||||
|
### cap
|
||||||
|
- Allows partial deduction
|
||||||
|
- Returns successfully with `remaining` amount
|
||||||
|
|
||||||
|
## Billing Integration
|
||||||
|
|
||||||
|
After deduction, system automatically:
|
||||||
|
1. Calculates negative balance changes
|
||||||
|
2. Calls `adjustAllowance` for each updated entitlement
|
||||||
|
3. Bills customer on Stripe if overage increased
|
||||||
|
4. Rolls back transaction on any error
|
||||||
|
|
||||||
|
## Transaction Safety
|
||||||
|
|
||||||
|
- All deductions run in `read committed` transaction
|
||||||
|
- Automatic rollback on any error
|
||||||
|
- Cache refresh only after successful transaction
|
||||||
|
- Ensures consistency between DB and Stripe
|
||||||
|
|
||||||
|
## SQL Helper Functions
|
||||||
|
|
||||||
|
### `deduct_from_single_entity(entities, entity_id, amount, allow_negative, min_balance)`
|
||||||
|
Deducts from a specific entity's balance in JSONB.
|
||||||
|
|
||||||
|
### `deduct_from_all_entities(entities, amount, allow_negative, min_balance)`
|
||||||
|
Iteratively deducts from all entities in JSONB.
|
||||||
|
|
||||||
|
### `deduct_allowance_from_entitlements(sorted_entitlements, amount, target_entity_id)`
|
||||||
|
Main function that orchestrates the entire deduction process.
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await runDeductionTx({
|
||||||
|
ctx,
|
||||||
|
customerId: "cus_123",
|
||||||
|
entityId: "org_456", // Optional
|
||||||
|
deductions: [
|
||||||
|
{ feature: feature1, deduction: 100 },
|
||||||
|
{ feature: feature2, deduction: 50 }
|
||||||
|
],
|
||||||
|
overageBehaviour: "reject", // or "cap"
|
||||||
|
addToAdjustment: false, // true for billing adjustments
|
||||||
|
eventInfo: { ... } // Optional event tracking
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- PostgreSQL function handles all deduction logic → minimal round trips
|
||||||
|
- Transaction ensures atomic updates
|
||||||
|
- Sorting happens in TypeScript (typically <10 entitlements)
|
||||||
|
- Connection pooling prevents exhaustion under high concurrency
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Deduction RPC Functions
|
||||||
|
|
||||||
|
This directory contains PostgreSQL stored functions for balance deduction operations.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `deductFromSingleEntity.sql` - Helper function to deduct from a specific entity balance
|
||||||
|
- `deductFromAllEntities.sql` - Helper function to iteratively deduct from all entities
|
||||||
|
- `deductAllowance.sql` - Main function that orchestrates the deduction process
|
||||||
|
|
||||||
|
## Versioning Strategy
|
||||||
|
|
||||||
|
To ensure safe deployments when changing function signatures:
|
||||||
|
|
||||||
|
### Adding a Version Suffix
|
||||||
|
|
||||||
|
When modifying a function's parameters or return type:
|
||||||
|
|
||||||
|
1. **Increment the version number** in the function name:
|
||||||
|
```sql
|
||||||
|
-- Old
|
||||||
|
CREATE FUNCTION deduct_from_single_entity(...)
|
||||||
|
|
||||||
|
-- New
|
||||||
|
CREATE FUNCTION deduct_from_single_entity_v2(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update all callers** to use the new version:
|
||||||
|
```sql
|
||||||
|
-- In deductAllowance.sql
|
||||||
|
FROM deduct_from_single_entity_v2(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Keep the old version** during deployment to prevent breaking existing instances
|
||||||
|
|
||||||
|
4. **Clean up after deployment**:
|
||||||
|
```sql
|
||||||
|
-- After confirming new version works in production
|
||||||
|
DROP FUNCTION IF EXISTS deduct_from_single_entity;
|
||||||
|
DROP FUNCTION IF EXISTS deduct_from_single_entity_v1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Version Suffixes?
|
||||||
|
|
||||||
|
PostgreSQL identifies functions by their signature (name + parameter types). When you:
|
||||||
|
- Change parameter types (e.g., `numeric` → `bigint`)
|
||||||
|
- Add/remove parameters
|
||||||
|
- Change return types
|
||||||
|
|
||||||
|
...the `DROP FUNCTION IF EXISTS` with explicit signatures won't match the old function, leading to orphaned functions in the database.
|
||||||
|
|
||||||
|
**Versioning solves this by:**
|
||||||
|
- Creating a new function alongside the old one
|
||||||
|
- Allowing gradual rollout without breaking existing instances
|
||||||
|
- Giving you time to verify the new version works before cleanup
|
||||||
|
|
||||||
|
### Example Migration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- deployment-v1.sql
|
||||||
|
CREATE FUNCTION process_data_v2(
|
||||||
|
input jsonb,
|
||||||
|
new_param text -- Added new parameter
|
||||||
|
) RETURNS jsonb AS $$
|
||||||
|
-- new implementation
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- After deployment and verification
|
||||||
|
-- cleanup.sql
|
||||||
|
DROP FUNCTION IF EXISTS process_data;
|
||||||
|
DROP FUNCTION IF EXISTS process_data_v1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading Order
|
||||||
|
|
||||||
|
Functions are loaded in this order during server startup (see `server/src/index.ts`):
|
||||||
|
1. Helper functions (dependencies)
|
||||||
|
2. Main function (depends on helpers)
|
||||||
|
|
||||||
|
This ensures all dependencies exist before they're called.
|
||||||
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
-- Main function: Deduct allowance from customer entitlements
|
||||||
|
DROP FUNCTION IF EXISTS deduct_allowance_from_entitlements(jsonb, numeric, text, text[]);
|
||||||
|
|
||||||
|
CREATE FUNCTION deduct_allowance_from_entitlements(
|
||||||
|
sorted_entitlements jsonb,
|
||||||
|
amount_to_deduct numeric,
|
||||||
|
target_entity_id text DEFAULT NULL,
|
||||||
|
rollover_ids text[] DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
remaining_amount numeric := amount_to_deduct;
|
||||||
|
rollover_deducted numeric := 0;
|
||||||
|
ent_id text;
|
||||||
|
credit_cost numeric;
|
||||||
|
usage_allowed boolean;
|
||||||
|
min_balance numeric;
|
||||||
|
add_to_adjustment boolean;
|
||||||
|
ent_obj jsonb;
|
||||||
|
|
||||||
|
current_balance numeric;
|
||||||
|
current_adjustment numeric;
|
||||||
|
current_entities jsonb;
|
||||||
|
has_entity_scope boolean;
|
||||||
|
|
||||||
|
new_entities jsonb;
|
||||||
|
new_balance numeric;
|
||||||
|
new_adjustment numeric;
|
||||||
|
deducted numeric;
|
||||||
|
|
||||||
|
updates_json jsonb := '{}'::jsonb;
|
||||||
|
result_json jsonb;
|
||||||
|
BEGIN
|
||||||
|
-- Then deduct from entitlements
|
||||||
|
FOR ent_obj IN SELECT * FROM jsonb_array_elements(sorted_entitlements)
|
||||||
|
LOOP
|
||||||
|
EXIT WHEN remaining_amount <= 0;
|
||||||
|
|
||||||
|
-- Extract entitlement info
|
||||||
|
ent_id := ent_obj->>'customer_entitlement_id';
|
||||||
|
credit_cost := (ent_obj->>'credit_cost')::numeric;
|
||||||
|
usage_allowed := COALESCE((ent_obj->>'usage_allowed')::boolean, false);
|
||||||
|
min_balance := (ent_obj->>'min_balance')::numeric;
|
||||||
|
add_to_adjustment := COALESCE((ent_obj->>'add_to_adjustment')::boolean, false);
|
||||||
|
has_entity_scope := (ent_obj->>'entity_feature_id') IS NOT NULL;
|
||||||
|
|
||||||
|
-- First, deduct from rollovers if this is the first entitlement
|
||||||
|
IF rollover_ids IS NOT NULL AND array_length(rollover_ids, 1) > 0 AND rollover_deducted = 0 THEN
|
||||||
|
SELECT * INTO rollover_deducted
|
||||||
|
FROM deduct_from_rollovers(rollover_ids, remaining_amount, target_entity_id, has_entity_scope);
|
||||||
|
|
||||||
|
remaining_amount := remaining_amount - rollover_deducted;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Fetch entitlement data with row lock
|
||||||
|
SELECT ce.balance, COALESCE(ce.adjustment, 0), COALESCE(ce.entities, '{}'::jsonb)
|
||||||
|
INTO current_balance, current_adjustment, current_entities
|
||||||
|
FROM customer_entitlements ce
|
||||||
|
WHERE ce.id = ent_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
-- Handle entity-scoped entitlements
|
||||||
|
IF has_entity_scope THEN
|
||||||
|
IF target_entity_id IS NOT NULL THEN
|
||||||
|
-- Deduct from specific entity
|
||||||
|
SELECT * INTO new_entities, deducted
|
||||||
|
FROM deduct_from_single_entity(
|
||||||
|
current_entities,
|
||||||
|
target_entity_id,
|
||||||
|
remaining_amount * credit_cost,
|
||||||
|
usage_allowed,
|
||||||
|
min_balance,
|
||||||
|
add_to_adjustment
|
||||||
|
);
|
||||||
|
ELSE
|
||||||
|
-- Deduct from all entities
|
||||||
|
SELECT * INTO new_entities, deducted
|
||||||
|
FROM deduct_from_all_entities(
|
||||||
|
current_entities,
|
||||||
|
remaining_amount * credit_cost,
|
||||||
|
usage_allowed,
|
||||||
|
min_balance,
|
||||||
|
add_to_adjustment
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update entities and optionally adjustment
|
||||||
|
IF deducted != 0 THEN
|
||||||
|
IF add_to_adjustment THEN
|
||||||
|
UPDATE customer_entitlements ce
|
||||||
|
SET entities = new_entities, adjustment = adjustment + deducted
|
||||||
|
WHERE ce.id = ent_id
|
||||||
|
RETURNING ce.adjustment INTO new_adjustment;
|
||||||
|
ELSE
|
||||||
|
UPDATE customer_entitlements ce
|
||||||
|
SET entities = new_entities
|
||||||
|
WHERE ce.id = ent_id
|
||||||
|
RETURNING ce.adjustment INTO new_adjustment;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Add to updates
|
||||||
|
updates_json := jsonb_set(
|
||||||
|
updates_json,
|
||||||
|
ARRAY[ent_id],
|
||||||
|
jsonb_build_object(
|
||||||
|
'balance', current_balance,
|
||||||
|
'entities', new_entities,
|
||||||
|
'adjustment', new_adjustment,
|
||||||
|
'deducted', deducted
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining_amount := remaining_amount - (deducted / credit_cost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Handle regular balance
|
||||||
|
ELSE
|
||||||
|
-- Calculate deduction respecting min_balance
|
||||||
|
IF usage_allowed THEN
|
||||||
|
-- If min_balance is null, allow unlimited deduction
|
||||||
|
IF min_balance IS NULL THEN
|
||||||
|
deducted := remaining_amount * credit_cost;
|
||||||
|
ELSE
|
||||||
|
deducted := LEAST(remaining_amount * credit_cost, current_balance - min_balance);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
deducted := LEAST(current_balance, remaining_amount * credit_cost);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF deducted != 0 THEN
|
||||||
|
IF add_to_adjustment THEN
|
||||||
|
UPDATE customer_entitlements ce
|
||||||
|
SET balance = balance - deducted, adjustment = adjustment + deducted
|
||||||
|
WHERE ce.id = ent_id
|
||||||
|
RETURNING ce.balance, ce.adjustment INTO new_balance, new_adjustment;
|
||||||
|
ELSE
|
||||||
|
UPDATE customer_entitlements ce
|
||||||
|
SET balance = balance - deducted
|
||||||
|
WHERE ce.id = ent_id
|
||||||
|
RETURNING ce.balance, ce.adjustment INTO new_balance, new_adjustment;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Add to updates
|
||||||
|
updates_json := jsonb_set(
|
||||||
|
updates_json,
|
||||||
|
ARRAY[ent_id],
|
||||||
|
jsonb_build_object(
|
||||||
|
'balance', new_balance,
|
||||||
|
'entities', current_entities,
|
||||||
|
'adjustment', new_adjustment,
|
||||||
|
'deducted', deducted
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining_amount := remaining_amount - (deducted / credit_cost);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Build final result
|
||||||
|
result_json := jsonb_build_object(
|
||||||
|
'updates', updates_json,
|
||||||
|
'remaining', remaining_amount
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN result_json;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Helper: Deduct from all entities iteratively
|
||||||
|
DROP FUNCTION IF EXISTS deduct_from_all_entities(jsonb, numeric, boolean, numeric, boolean);
|
||||||
|
|
||||||
|
CREATE FUNCTION deduct_from_all_entities(
|
||||||
|
entities_json jsonb,
|
||||||
|
amount numeric,
|
||||||
|
allow_negative boolean DEFAULT false,
|
||||||
|
min_balance numeric DEFAULT 0,
|
||||||
|
track_adjustment boolean DEFAULT false
|
||||||
|
)
|
||||||
|
RETURNS TABLE(updated_entities jsonb, total_deducted numeric)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
remaining numeric := amount;
|
||||||
|
entity_key text;
|
||||||
|
entity_balance numeric;
|
||||||
|
entity_adjustment numeric;
|
||||||
|
deduct_amount numeric;
|
||||||
|
new_balance numeric;
|
||||||
|
new_adjustment numeric;
|
||||||
|
new_entities jsonb := entities_json;
|
||||||
|
total_deducted numeric := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR entity_key IN SELECT jsonb_object_keys(entities_json)
|
||||||
|
LOOP
|
||||||
|
EXIT WHEN remaining <= 0;
|
||||||
|
|
||||||
|
entity_balance := COALESCE((new_entities->entity_key->>'balance')::numeric, 0);
|
||||||
|
entity_adjustment := COALESCE((new_entities->entity_key->>'adjustment')::numeric, 0);
|
||||||
|
|
||||||
|
-- Calculate deduction respecting min_balance
|
||||||
|
IF allow_negative THEN
|
||||||
|
-- If min_balance is null, allow unlimited deduction
|
||||||
|
IF min_balance IS NULL THEN
|
||||||
|
deduct_amount := remaining;
|
||||||
|
ELSE
|
||||||
|
-- Can go negative, but not below min_balance
|
||||||
|
deduct_amount := LEAST(remaining, entity_balance - min_balance);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
-- Cap at current balance (min 0)
|
||||||
|
deduct_amount := LEAST(entity_balance, remaining);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF deduct_amount != 0 THEN
|
||||||
|
new_balance := entity_balance - deduct_amount;
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
new_entities,
|
||||||
|
ARRAY[entity_key, 'balance'],
|
||||||
|
to_jsonb(new_balance)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update adjustment if tracking
|
||||||
|
IF track_adjustment THEN
|
||||||
|
new_adjustment := entity_adjustment + deduct_amount;
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
new_entities,
|
||||||
|
ARRAY[entity_key, 'adjustment'],
|
||||||
|
to_jsonb(new_adjustment)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
remaining := remaining - deduct_amount;
|
||||||
|
total_deducted := total_deducted + deduct_amount;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT new_entities, total_deducted;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
-- Helper: Deduct from rollovers before deducting from main entitlements
|
||||||
|
DROP FUNCTION IF EXISTS deduct_from_rollovers(text[], numeric, text);
|
||||||
|
DROP FUNCTION IF EXISTS deduct_from_rollovers(text[], numeric, text, boolean);
|
||||||
|
|
||||||
|
CREATE FUNCTION deduct_from_rollovers(
|
||||||
|
rollover_ids text[],
|
||||||
|
amount_to_deduct numeric,
|
||||||
|
target_entity_id text DEFAULT NULL,
|
||||||
|
has_entity_scope boolean DEFAULT false
|
||||||
|
)
|
||||||
|
RETURNS TABLE(total_deducted numeric)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
remaining_amount numeric := amount_to_deduct;
|
||||||
|
rollover_id text;
|
||||||
|
current_balance numeric;
|
||||||
|
current_usage numeric;
|
||||||
|
current_entities jsonb;
|
||||||
|
|
||||||
|
entity_key text;
|
||||||
|
entity_balance numeric;
|
||||||
|
entity_usage numeric;
|
||||||
|
deduct_amount numeric;
|
||||||
|
new_balance numeric;
|
||||||
|
new_usage numeric;
|
||||||
|
new_entities jsonb;
|
||||||
|
rollover_total_deducted numeric := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Loop through rollover IDs in order
|
||||||
|
FOREACH rollover_id IN ARRAY rollover_ids
|
||||||
|
LOOP
|
||||||
|
EXIT WHEN remaining_amount <= 0;
|
||||||
|
|
||||||
|
-- Lock and fetch rollover data
|
||||||
|
SELECT r.balance, COALESCE(r.usage, 0), r.entities
|
||||||
|
INTO current_balance, current_usage, current_entities
|
||||||
|
FROM rollovers r
|
||||||
|
WHERE r.id = rollover_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
-- Handle entity-scoped rollovers (specific entity)
|
||||||
|
IF has_entity_scope AND target_entity_id IS NOT NULL THEN
|
||||||
|
entity_balance := COALESCE((current_entities->target_entity_id->>'balance')::numeric, 0);
|
||||||
|
entity_usage := COALESCE((current_entities->target_entity_id->>'usage')::numeric, 0);
|
||||||
|
|
||||||
|
-- Calculate deduction (always cap at 0)
|
||||||
|
deduct_amount := LEAST(entity_balance, remaining_amount);
|
||||||
|
|
||||||
|
IF deduct_amount > 0 THEN
|
||||||
|
new_balance := entity_balance - deduct_amount;
|
||||||
|
new_usage := entity_usage + deduct_amount;
|
||||||
|
|
||||||
|
-- Update entity in JSONB
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
current_entities,
|
||||||
|
ARRAY[target_entity_id, 'balance'],
|
||||||
|
to_jsonb(new_balance)
|
||||||
|
);
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
new_entities,
|
||||||
|
ARRAY[target_entity_id, 'usage'],
|
||||||
|
to_jsonb(new_usage)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update rollover
|
||||||
|
UPDATE rollovers r
|
||||||
|
SET entities = new_entities
|
||||||
|
WHERE r.id = rollover_id;
|
||||||
|
|
||||||
|
remaining_amount := remaining_amount - deduct_amount;
|
||||||
|
rollover_total_deducted := rollover_total_deducted + deduct_amount;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Handle entity-scoped rollovers (deduct from all entities)
|
||||||
|
ELSIF has_entity_scope AND target_entity_id IS NULL THEN
|
||||||
|
new_entities := current_entities;
|
||||||
|
deduct_amount := 0;
|
||||||
|
|
||||||
|
FOR entity_key IN SELECT jsonb_object_keys(current_entities)
|
||||||
|
LOOP
|
||||||
|
EXIT WHEN remaining_amount <= 0;
|
||||||
|
|
||||||
|
entity_balance := COALESCE((new_entities->entity_key->>'balance')::numeric, 0);
|
||||||
|
entity_usage := COALESCE((new_entities->entity_key->>'usage')::numeric, 0);
|
||||||
|
|
||||||
|
-- Calculate deduction for this entity (always cap at 0)
|
||||||
|
deduct_amount := LEAST(entity_balance, remaining_amount);
|
||||||
|
|
||||||
|
IF deduct_amount > 0 THEN
|
||||||
|
new_balance := entity_balance - deduct_amount;
|
||||||
|
new_usage := entity_usage + deduct_amount;
|
||||||
|
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
new_entities,
|
||||||
|
ARRAY[entity_key, 'balance'],
|
||||||
|
to_jsonb(new_balance)
|
||||||
|
);
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
new_entities,
|
||||||
|
ARRAY[entity_key, 'usage'],
|
||||||
|
to_jsonb(new_usage)
|
||||||
|
);
|
||||||
|
|
||||||
|
remaining_amount := remaining_amount - deduct_amount;
|
||||||
|
rollover_total_deducted := rollover_total_deducted + deduct_amount;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Update rollover with all entity changes if any deductions occurred
|
||||||
|
IF new_entities IS DISTINCT FROM current_entities THEN
|
||||||
|
UPDATE rollovers r
|
||||||
|
SET entities = new_entities
|
||||||
|
WHERE r.id = rollover_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Handle regular balance rollovers
|
||||||
|
ELSE
|
||||||
|
-- Calculate deduction (always cap at 0)
|
||||||
|
deduct_amount := LEAST(current_balance, remaining_amount);
|
||||||
|
|
||||||
|
IF deduct_amount > 0 THEN
|
||||||
|
-- Update balance and usage atomically
|
||||||
|
UPDATE rollovers r
|
||||||
|
SET balance = balance - deduct_amount, usage = usage + deduct_amount
|
||||||
|
WHERE r.id = rollover_id;
|
||||||
|
|
||||||
|
remaining_amount := remaining_amount - deduct_amount;
|
||||||
|
rollover_total_deducted := rollover_total_deducted + deduct_amount;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT rollover_total_deducted;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Helper: Deduct from a single entity in entities JSONB
|
||||||
|
DROP FUNCTION IF EXISTS deduct_from_single_entity(jsonb, text, numeric, boolean, numeric, boolean);
|
||||||
|
|
||||||
|
CREATE FUNCTION deduct_from_single_entity(
|
||||||
|
entities_json jsonb,
|
||||||
|
entity_id text,
|
||||||
|
amount numeric,
|
||||||
|
allow_negative boolean DEFAULT false,
|
||||||
|
min_balance numeric DEFAULT 0,
|
||||||
|
track_adjustment boolean DEFAULT false
|
||||||
|
)
|
||||||
|
RETURNS TABLE(updated_entities jsonb, deducted numeric)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
entity_balance numeric;
|
||||||
|
entity_adjustment numeric;
|
||||||
|
actual_deduction numeric;
|
||||||
|
new_balance numeric;
|
||||||
|
new_adjustment numeric;
|
||||||
|
new_entities jsonb;
|
||||||
|
BEGIN
|
||||||
|
entity_balance := COALESCE((entities_json->entity_id->>'balance')::numeric, 0);
|
||||||
|
entity_adjustment := COALESCE((entities_json->entity_id->>'adjustment')::numeric, 0);
|
||||||
|
|
||||||
|
-- Calculate deduction respecting min_balance
|
||||||
|
IF allow_negative THEN
|
||||||
|
-- If min_balance is null, allow unlimited deduction
|
||||||
|
IF min_balance IS NULL THEN
|
||||||
|
actual_deduction := amount;
|
||||||
|
ELSE
|
||||||
|
-- Can go negative, but not below min_balance
|
||||||
|
actual_deduction := LEAST(amount, entity_balance - min_balance);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
-- Cap at current balance (min 0)
|
||||||
|
actual_deduction := LEAST(entity_balance, amount);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF actual_deduction != 0 THEN
|
||||||
|
new_balance := entity_balance - actual_deduction;
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
entities_json,
|
||||||
|
ARRAY[entity_id, 'balance'],
|
||||||
|
to_jsonb(new_balance)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update adjustment if tracking
|
||||||
|
IF track_adjustment THEN
|
||||||
|
new_adjustment := entity_adjustment + actual_deduction;
|
||||||
|
new_entities := jsonb_set(
|
||||||
|
new_entities,
|
||||||
|
ARRAY[entity_id, 'adjustment'],
|
||||||
|
to_jsonb(new_adjustment)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
new_entities := entities_json;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT new_entities, actual_deduction;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { type Feature, FeatureNotFoundError } from "@autumn/shared";
|
import { type Feature, FeatureNotFoundError } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||||
import {
|
import { getCreditSystemsFromFeature } from "../../../features/creditSystemUtils.js";
|
||||||
getCreditCost,
|
|
||||||
getCreditSystemsFromFeature,
|
|
||||||
} from "../../../features/creditSystemUtils.js";
|
|
||||||
|
|
||||||
export type FeatureDeduction = {
|
export type FeatureDeduction = {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
@@ -43,18 +40,18 @@ export const getTrackFeatureDeductions = ({
|
|||||||
deduction: mainFeatureDeduction,
|
deduction: mainFeatureDeduction,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const creditSystem of creditSystems) {
|
// for (const creditSystem of creditSystems) {
|
||||||
const creditSystemDeduction = getCreditCost({
|
// const creditSystemDeduction = getCreditCost({
|
||||||
featureId: mainFeature.id,
|
// featureId: mainFeature.id,
|
||||||
creditSystem,
|
// creditSystem,
|
||||||
amount: mainFeatureDeduction,
|
// amount: mainFeatureDeduction,
|
||||||
});
|
// });
|
||||||
|
|
||||||
featureDeductions.push({
|
// featureDeductions.push({
|
||||||
feature: creditSystem,
|
// feature: creditSystem,
|
||||||
deduction: creditSystemDeduction,
|
// deduction: creditSystemDeduction,
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
return featureDeductions;
|
return featureDeductions;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +1,54 @@
|
|||||||
|
import type { Event } from "@autumn/shared";
|
||||||
import {
|
import {
|
||||||
CusProductStatus,
|
CusProductStatus,
|
||||||
|
cusEntToCusPrice,
|
||||||
cusProductsToCusEnts,
|
cusProductsToCusEnts,
|
||||||
cusProductsToPrices,
|
cusProductsToCusPrices,
|
||||||
|
FeatureUsageType,
|
||||||
|
type FullCustomer,
|
||||||
|
getMaxOverage,
|
||||||
|
getRelevantFeatures,
|
||||||
|
InsufficientBalanceError,
|
||||||
|
InternalError,
|
||||||
|
notNullish,
|
||||||
|
nullish,
|
||||||
|
updateCusEntInFullCus,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import type { DrizzleCli } from "../../../../db/initDrizzle.js";
|
import type { DrizzleCli } from "../../../../db/initDrizzle.js";
|
||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
|
||||||
import { handleThresholdReached } from "../../../../trigger/handleThresholdReached.js";
|
import { adjustAllowance } from "../../../../trigger/adjustAllowance.js";
|
||||||
import {
|
|
||||||
deductAllowanceFromCusEnt,
|
|
||||||
deductFromUsageBasedCusEnt,
|
|
||||||
} from "../../../../trigger/updateBalanceTask.js";
|
|
||||||
import { EventService } from "../../../api/events/EventService.js";
|
import { EventService } from "../../../api/events/EventService.js";
|
||||||
import { CusService } from "../../../customers/CusService.js";
|
import { CusService } from "../../../customers/CusService.js";
|
||||||
import { refreshCusCache } from "../../../customers/cusCache/updateCachedCus.js";
|
import { refreshCusCache } from "../../../customers/cusCache/updateCachedCus.js";
|
||||||
import { deductFromApiCusRollovers } from "../../../customers/cusProducts/cusEnts/cusRollovers/rolloverDeductionUtils.js";
|
import {
|
||||||
|
getTotalNegativeBalance,
|
||||||
|
getUnlimitedAndUsageAllowed,
|
||||||
|
} from "../../../customers/cusProducts/cusEnts/cusEntUtils.js";
|
||||||
|
import { getCreditCost } from "../../../features/creditSystemUtils.js";
|
||||||
import { constructEvent, type EventInfo } from "./eventUtils.js";
|
import { constructEvent, type EventInfo } from "./eventUtils.js";
|
||||||
import type { FeatureDeduction } from "./getFeatureDeductions.js";
|
import type { FeatureDeduction } from "./getFeatureDeductions.js";
|
||||||
import { validateDeductionPossible } from "./validateDeductionPossible.js";
|
|
||||||
|
|
||||||
export type DeductionTxParams = {
|
export type DeductionTxParams = {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
deductions: FeatureDeduction[];
|
deductions: FeatureDeduction[];
|
||||||
eventInfo: EventInfo;
|
eventInfo?: EventInfo;
|
||||||
|
overageBehaviour?: "cap" | "reject";
|
||||||
|
addToAdjustment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// const { cusEnts, cusPrices } = await getCusEntsInFeatures({
|
|
||||||
// customer,
|
|
||||||
// internalFeatureIds: features.map((f) => f.internal_id!),
|
|
||||||
// logger,
|
|
||||||
// reverseOrder: org.config?.reverse_deduction_order,
|
|
||||||
// });
|
|
||||||
|
|
||||||
const deductFromCusEnts = async ({
|
const deductFromCusEnts = async ({
|
||||||
ctx,
|
ctx,
|
||||||
customerId,
|
customerId,
|
||||||
entityId,
|
entityId,
|
||||||
deductions,
|
deductions,
|
||||||
|
overageBehaviour = "cap",
|
||||||
|
addToAdjustment = false,
|
||||||
}: DeductionTxParams) => {
|
}: DeductionTxParams) => {
|
||||||
const { db, org, env } = ctx;
|
const { db, org, env } = ctx;
|
||||||
|
const fullCus = await CusService.getFull({
|
||||||
const customer = await CusService.getFull({
|
|
||||||
db,
|
db,
|
||||||
idOrInternalId: customerId,
|
idOrInternalId: customerId,
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
@@ -52,149 +58,207 @@ const deductFromCusEnts = async ({
|
|||||||
withSubs: true,
|
withSubs: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const printLogs = false;
|
||||||
|
|
||||||
|
if (printLogs) {
|
||||||
|
console.log(
|
||||||
|
`Deductions: `,
|
||||||
|
deductions.map((d) => ({
|
||||||
|
feature_id: d.feature.id,
|
||||||
|
deduction: d.deduction,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Need to deduct from customer entitlement...
|
||||||
|
for (const deduction of deductions) {
|
||||||
|
const { feature, deduction: toDeduct } = deduction;
|
||||||
|
|
||||||
|
const relevantFeatures = getRelevantFeatures({
|
||||||
|
features: ctx.features,
|
||||||
|
featureId: feature.id,
|
||||||
|
});
|
||||||
|
|
||||||
const cusEnts = cusProductsToCusEnts({
|
const cusEnts = cusProductsToCusEnts({
|
||||||
cusProducts: customer.customer_products,
|
cusProducts: fullCus.customer_products,
|
||||||
featureIds: deductions.map((d) => d.feature.id),
|
featureIds: relevantFeatures.map((f) => f.id),
|
||||||
reverseOrder: org.config?.reverse_deduction_order,
|
reverseOrder: org.config?.reverse_deduction_order,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cusPrices = cusProductsToPrices({
|
const { unlimited } = getUnlimitedAndUsageAllowed({
|
||||||
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,
|
cusEnts,
|
||||||
deductParams: {
|
internalFeatureId: feature.internal_id!,
|
||||||
db,
|
});
|
||||||
feature,
|
|
||||||
env,
|
if (cusEnts.length === 0 || unlimited) continue;
|
||||||
org,
|
|
||||||
cusPrices: cusPrices as any[],
|
const cusEntInput = cusEnts.map((ce) => {
|
||||||
customer,
|
const creditCost = getCreditCost({
|
||||||
entity: customer.entity,
|
featureId: feature.id,
|
||||||
},
|
creditSystem: ce.entitlement.feature,
|
||||||
setZeroAdjustment: true,
|
});
|
||||||
|
|
||||||
|
const maxOverage = getMaxOverage({ cusEnt: ce });
|
||||||
|
|
||||||
|
const cusPrice = cusEntToCusPrice({ cusEnt: ce });
|
||||||
|
const isFreeAllocated =
|
||||||
|
ce.entitlement.feature.config?.usage_type ===
|
||||||
|
FeatureUsageType.Continuous && nullish(cusPrice);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer_entitlement_id: ce.id,
|
||||||
|
credit_cost: creditCost,
|
||||||
|
entity_feature_id: ce.entitlement.entity_feature_id,
|
||||||
|
usage_allowed: ce.usage_allowed || isFreeAllocated,
|
||||||
|
min_balance: notNullish(maxOverage) ? -maxOverage : undefined,
|
||||||
|
add_to_adjustment: addToAdjustment,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect and sort rollovers by expires_at (oldest first)
|
||||||
|
const sortedRollovers = cusEnts
|
||||||
|
.flatMap((ce) => ce.rollovers || [])
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.expires_at && b.expires_at) return a.expires_at - b.expires_at;
|
||||||
|
if (a.expires_at && !b.expires_at) return -1;
|
||||||
|
if (!a.expires_at && b.expires_at) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rolloverIds = sortedRollovers.map((r) => r.id);
|
||||||
|
|
||||||
|
// Call the stored function to deduct from entitlements with credit costs
|
||||||
|
const result = await db.execute(
|
||||||
|
sql`SELECT * FROM deduct_allowance_from_entitlements(
|
||||||
|
${JSON.stringify(cusEntInput)}::jsonb,
|
||||||
|
${toDeduct},
|
||||||
|
${entityId || null},
|
||||||
|
${rolloverIds.length > 0 ? sql.raw(`ARRAY[${rolloverIds.map((id) => `'${id}'`).join(",")}]`) : null}
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse the JSONB result
|
||||||
|
const resultJson = result[0]?.deduct_allowance_from_entitlements as {
|
||||||
|
updates: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
balance: number;
|
||||||
|
entities: any;
|
||||||
|
adjustment: number;
|
||||||
|
deducted: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
remaining: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!resultJson) {
|
||||||
|
throw new InternalError({
|
||||||
|
message: "Failed to deduct from entitlements",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleThresholdReached({
|
const { updates, remaining } = resultJson;
|
||||||
org,
|
|
||||||
env,
|
// Check if deduction was rejected due to limits
|
||||||
features: ctx.features,
|
if (remaining > 0 && overageBehaviour === "reject") {
|
||||||
|
throw new InsufficientBalanceError({
|
||||||
|
message: `Insufficient balance to deduct ${toDeduct}. Remaining: ${remaining}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.logger.info(
|
||||||
|
`Deducted ${toDeduct - remaining} from feature ${feature.id}. Updated ${
|
||||||
|
Object.keys(updates).length
|
||||||
|
} entitlements. Remaining: ${remaining}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bill on Stripe for each updated entitlement
|
||||||
|
const cusPrices = cusProductsToCusPrices({
|
||||||
|
cusProducts: fullCus.customer_products,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const cusEntId of Object.keys(updates)) {
|
||||||
|
const update = updates[cusEntId];
|
||||||
|
const cusEnt = cusEnts.find((ce) => ce.id === cusEntId);
|
||||||
|
|
||||||
|
if (!cusEnt) continue;
|
||||||
|
|
||||||
|
// Calculate original negative balance
|
||||||
|
const originalGrpBalance = getTotalNegativeBalance({
|
||||||
|
cusEnt,
|
||||||
|
balance: cusEnt.balance!,
|
||||||
|
entities: cusEnt.entities!,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate new negative balance from updates
|
||||||
|
const newGrpBalance = getTotalNegativeBalance({
|
||||||
|
cusEnt,
|
||||||
|
balance: update.balance,
|
||||||
|
entities: update.entities,
|
||||||
|
});
|
||||||
|
|
||||||
|
await adjustAllowance({
|
||||||
db,
|
db,
|
||||||
feature,
|
env,
|
||||||
cusEnts: originalCusEnts,
|
org,
|
||||||
newCusEnts: cusEnts,
|
cusPrices: cusPrices as any,
|
||||||
fullCus: customer,
|
customer: fullCus,
|
||||||
|
affectedFeature: feature,
|
||||||
|
cusEnt: cusEnt as any,
|
||||||
|
originalBalance: originalGrpBalance,
|
||||||
|
newBalance: newGrpBalance,
|
||||||
logger: ctx.logger,
|
logger: ctx.logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert event into database
|
updateCusEntInFullCus({
|
||||||
return customer;
|
fullCus,
|
||||||
|
cusEntId,
|
||||||
|
update,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullCus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runDeductionTx = async (params: DeductionTxParams) => {
|
export const runDeductionTx = async (
|
||||||
|
params: DeductionTxParams,
|
||||||
|
): Promise<{
|
||||||
|
fullCus: FullCustomer | undefined;
|
||||||
|
event: Event | undefined;
|
||||||
|
}> => {
|
||||||
const ctx = params.ctx;
|
const ctx = params.ctx;
|
||||||
const { db, org, env, logger } = ctx;
|
const { db, org, env } = ctx;
|
||||||
|
|
||||||
|
let fullCus: FullCustomer | undefined;
|
||||||
|
let event: Event | undefined;
|
||||||
|
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
// Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests
|
// Pass tx as the db connection
|
||||||
// Include entity_id in lock key so different entities can update concurrently
|
const txParams = {
|
||||||
const lockKeyStr = `${params.customerId}_${org.id}_${env}${params.entityId ? `_${params.entityId}` : ""}`;
|
...params,
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
db: tx as unknown as typeof db,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const hash =
|
fullCus = await deductFromCusEnts(txParams);
|
||||||
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}`);
|
if (!fullCus) return;
|
||||||
|
|
||||||
// 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) {
|
if (params.eventInfo) {
|
||||||
const newEvent = await constructEvent({
|
const newEvent = await constructEvent({
|
||||||
ctx,
|
ctx: txParams.ctx,
|
||||||
eventInfo: params.eventInfo,
|
eventInfo: params.eventInfo,
|
||||||
fullCus: customer,
|
fullCus,
|
||||||
});
|
});
|
||||||
|
|
||||||
await EventService.insert({
|
event = await EventService.insert({
|
||||||
db: tx as unknown as DrizzleCli,
|
db: tx as unknown as DrizzleCli,
|
||||||
event: newEvent,
|
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",
|
isolationLevel: "read committed",
|
||||||
@@ -208,4 +272,107 @@ export const runDeductionTx = async (params: DeductionTxParams) => {
|
|||||||
org,
|
org,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullCus,
|
||||||
|
event,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 = cusProductsToCusPrices({
|
||||||
|
// cusProducts: customer.customer_products,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// }
|
||||||
|
|||||||
@@ -57,18 +57,11 @@ export const getCusEntMasterBalance = ({
|
|||||||
|
|
||||||
// Get unused count
|
// Get unused count
|
||||||
|
|
||||||
const unusedCount =
|
|
||||||
entities &&
|
|
||||||
entities.filter(
|
|
||||||
(entity) =>
|
|
||||||
entity.internal_feature_id == feature.internal_id && entity.deleted,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
balance: cusEnt.balance,
|
balance: cusEnt.balance,
|
||||||
adjustment: cusEnt.adjustment,
|
adjustment: cusEnt.adjustment,
|
||||||
count: 1,
|
count: 1,
|
||||||
unused: unusedCount,
|
unused: cusEnt.replaceables?.length || 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,9 +108,9 @@ export const getRelatedCusPrice = (
|
|||||||
) => {
|
) => {
|
||||||
return cusPrices.find((cusPrice) => {
|
return cusPrices.find((cusPrice) => {
|
||||||
const productMatch =
|
const productMatch =
|
||||||
cusPrice.customer_product_id == cusEnt.customer_product_id;
|
cusPrice.customer_product_id === cusEnt.customer_product_id;
|
||||||
|
|
||||||
const entMatch = cusPrice.price.entitlement_id == cusEnt.entitlement.id;
|
const entMatch = cusPrice.price.entitlement_id === cusEnt.entitlement.id;
|
||||||
|
|
||||||
return productMatch && entMatch;
|
return productMatch && entMatch;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,12 +73,15 @@ export const featureToCreditSystem = ({
|
|||||||
export const getCreditCost = ({
|
export const getCreditCost = ({
|
||||||
featureId,
|
featureId,
|
||||||
creditSystem,
|
creditSystem,
|
||||||
amount,
|
amount = 1,
|
||||||
}: {
|
}: {
|
||||||
featureId: string;
|
featureId: string;
|
||||||
creditSystem: Feature;
|
creditSystem: Feature;
|
||||||
amount: number;
|
amount?: number;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (creditSystem.type !== FeatureType.CreditSystem) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
const schema: CreditSchemaItem[] = creditSystem.config.schema;
|
const schema: CreditSchemaItem[] = creditSystem.config.schema;
|
||||||
|
|
||||||
for (const schemaItem of schema) {
|
for (const schemaItem of schema) {
|
||||||
|
|||||||
@@ -75,12 +75,14 @@ export const constructMeteredFeature = ({
|
|||||||
orgId,
|
orgId,
|
||||||
env,
|
env,
|
||||||
usageType,
|
usageType,
|
||||||
|
eventNames = [],
|
||||||
}: {
|
}: {
|
||||||
featureId: string;
|
featureId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
env: AppEnv;
|
env: AppEnv;
|
||||||
usageType: FeatureUsageType;
|
usageType: FeatureUsageType;
|
||||||
|
eventNames?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
const newFeature: Feature = {
|
const newFeature: Feature = {
|
||||||
internal_id: generateId("fe"),
|
internal_id: generateId("fe"),
|
||||||
@@ -106,7 +108,7 @@ export const constructMeteredFeature = ({
|
|||||||
usage_type: usageType,
|
usage_type: usageType,
|
||||||
},
|
},
|
||||||
archived: false,
|
archived: false,
|
||||||
event_names: [],
|
event_names: eventNames,
|
||||||
};
|
};
|
||||||
|
|
||||||
return newFeature;
|
return newFeature;
|
||||||
|
|||||||
38
server/src/test.ts
Normal file
38
server/src/test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { AutumnInt } from "./external/autumn/autumnCli.js";
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const autumn = new AutumnInt({ secretKey: process.env.JDEV! });
|
||||||
|
|
||||||
|
const concurrency = 1;
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < concurrency; i++) {
|
||||||
|
const simulateTrack = async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
const response = await autumn.track({
|
||||||
|
customer_id: "john",
|
||||||
|
feature_id: "credits",
|
||||||
|
value: 350,
|
||||||
|
entity_id: "entity_2",
|
||||||
|
});
|
||||||
|
console.log(response);
|
||||||
|
const end = Date.now();
|
||||||
|
console.log(`Track ${i} took ${end - start}ms`);
|
||||||
|
return {
|
||||||
|
latency: end - start,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
promises.push(simulateTrack());
|
||||||
|
}
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
const latencies = results.map((r) => r.latency);
|
||||||
|
const p99Latency = latencies.sort((a, b) => a - b)[
|
||||||
|
Math.floor(latencies.length * 0.99)
|
||||||
|
];
|
||||||
|
console.log(`P99 latency: ${p99Latency}ms`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.then(() => process.exit(0));
|
||||||
@@ -96,7 +96,7 @@ export const adjustAllowance = async ({
|
|||||||
!cusProduct ||
|
!cusProduct ||
|
||||||
!cusPrice ||
|
!cusPrice ||
|
||||||
billingType !== BillingType.InArrearProrated ||
|
billingType !== BillingType.InArrearProrated ||
|
||||||
originalBalance == newBalance
|
originalBalance === newBalance
|
||||||
) {
|
) {
|
||||||
return { newReplaceables: [], invoice: null, deletedReplaceables: null };
|
return { newReplaceables: [], invoice: null, deletedReplaceables: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
type AppEnv,
|
type AppEnv,
|
||||||
CusProductStatus,
|
CusProductStatus,
|
||||||
type Customer,
|
type Customer,
|
||||||
|
customerEntitlements,
|
||||||
|
customers,
|
||||||
ErrCode,
|
ErrCode,
|
||||||
type Feature,
|
type Feature,
|
||||||
FeatureType,
|
FeatureType,
|
||||||
@@ -521,21 +523,31 @@ export const runUpdateUsageTask = async ({
|
|||||||
|
|
||||||
const cusEnts = await db.transaction(
|
const cusEnts = await db.transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
|
// Lock ALL customer entitlements for this customer using JOIN
|
||||||
|
await tx.execute(sql`
|
||||||
|
SELECT ce.*
|
||||||
|
FROM ${customerEntitlements} ce
|
||||||
|
INNER JOIN ${customers} c ON ce.internal_customer_id = c.internal_id
|
||||||
|
WHERE c.id = ${customerId}
|
||||||
|
AND c.org_id = ${org.id}
|
||||||
|
AND c.env = ${env}
|
||||||
|
FOR UPDATE OF ce
|
||||||
|
`);
|
||||||
// Acquire advisory lock for this customer (and entity if provided) to serialize concurrent requests
|
// 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
|
// Include entity_id in lock key so different entities can update concurrently
|
||||||
const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`;
|
// const lockKeyStr = `${internalCustomerId}_${org.id}_${env}${entityId ? `_${entityId}` : ""}`;
|
||||||
const hash =
|
// const hash =
|
||||||
lockKeyStr.split("").reduce((acc, char) => {
|
// lockKeyStr.split("").reduce((acc, char) => {
|
||||||
return (acc << 5) - acc + char.charCodeAt(0);
|
// return (acc << 5) - acc + char.charCodeAt(0);
|
||||||
}, 0) | 0; // Convert to 32-bit integer
|
// }, 0) | 0; // Convert to 32-bit integer
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`,
|
// ` 🔒 [${eventId}] Acquiring advisory lock (hash=${hash}) for: ${lockKeyStr}`,
|
||||||
);
|
// );
|
||||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`);
|
// await tx.execute(sql`SELECT pg_advisory_xact_lock(${hash})`);
|
||||||
console.log(
|
// console.log(
|
||||||
` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`,
|
// ` 🔓 [${eventId}] Advisory lock acquired, proceeding with update`,
|
||||||
);
|
// );
|
||||||
|
|
||||||
return await updateUsage({
|
return await updateUsage({
|
||||||
db: tx as unknown as DrizzleCli,
|
db: tx as unknown as DrizzleCli,
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || "");
|
|
||||||
|
|
||||||
const result = await stripe.webhookEndpoints.create({
|
|
||||||
url: "https://express.dev.useautumn.com/webhooks/connect/sandbox",
|
|
||||||
enabled_events: [
|
|
||||||
"checkout.session.completed",
|
|
||||||
"customer.subscription.created",
|
|
||||||
"customer.subscription.updated",
|
|
||||||
"customer.subscription.deleted",
|
|
||||||
"customer.discount.deleted",
|
|
||||||
"invoice.paid",
|
|
||||||
"invoice.upcoming",
|
|
||||||
"invoice.created",
|
|
||||||
"invoice.finalized",
|
|
||||||
"invoice.updated",
|
|
||||||
"subscription_schedule.canceled",
|
|
||||||
"subscription_schedule.updated",
|
|
||||||
],
|
|
||||||
connect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(result);
|
|
||||||
|
|
||||||
// const account = await stripe.v2.core.accounts.create({
|
|
||||||
// contact_email: "johnyeo10@gmail.com",
|
|
||||||
// display_name: "John Yeo",
|
|
||||||
// dashboard: "full",
|
|
||||||
// identity: {
|
|
||||||
// country: "us",
|
|
||||||
// },
|
|
||||||
// configuration: {
|
|
||||||
// merchant: {},
|
|
||||||
// },
|
|
||||||
// defaults: {
|
|
||||||
// responsibilities: {
|
|
||||||
// losses_collector: "stripe",
|
|
||||||
// fees_collector: "stripe",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// console.log(account);
|
|
||||||
|
|
||||||
// console.log(result);
|
|
||||||
|
|
||||||
// const result = await stripe.v2.core.accounts.create({
|
|
||||||
// contact_email: "johnyeo10@gmail.com",
|
|
||||||
// display_name: "John Yeo",
|
|
||||||
// dashboard: "full",
|
|
||||||
// identity: {
|
|
||||||
// country: "us",
|
|
||||||
// },
|
|
||||||
// configuration: {
|
|
||||||
// merchant: {},
|
|
||||||
// },
|
|
||||||
// defaults: {
|
|
||||||
// responsibilities: {
|
|
||||||
// losses_collector: "stripe",
|
|
||||||
// fees_collector: "stripe",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// console.log(result);
|
|
||||||
|
|
||||||
// const accountLink = await stripe.accountLinks.create({
|
|
||||||
// account: "acct_1SIqs0RAB2jVVcNG",
|
|
||||||
// refresh_url: "https://useautumn.com/refresh",
|
|
||||||
// return_url: "https://useautumn.com/return",
|
|
||||||
// type: "account_onboarding",
|
|
||||||
// });
|
|
||||||
// console.log(accountLink);
|
|
||||||
};
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch(console.error)
|
|
||||||
.then(() => process.exit(0));
|
|
||||||
@@ -49,18 +49,51 @@ const balance = customer.features[TestFeature.Messages].balance;
|
|||||||
const used = customer.features[TestFeature.Messages].used;
|
const used = customer.features[TestFeature.Messages].used;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Expect Error
|
### Expect Error (Use This Instead of try-catch!)
|
||||||
|
|
||||||
|
**Always use `expectAutumnError` instead of manual try-catch blocks:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
|
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
|
||||||
|
|
||||||
|
// ✅ GOOD - Use expectAutumnError
|
||||||
await expectAutumnError({
|
await expectAutumnError({
|
||||||
errCode: ErrCode.CustomerNotFound,
|
errCode: ErrCode.CustomerNotFound,
|
||||||
func: async () => {
|
func: async () => {
|
||||||
await autumn.customers.get("invalid-id");
|
await autumn.customers.get("invalid-id");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ✅ GOOD - Test for duplicate idempotency key
|
||||||
|
await expectAutumnError({
|
||||||
|
errCode: ErrCode.DuplicateIdempotencyKey,
|
||||||
|
func: async () => {
|
||||||
|
await autumn.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
idempotency_key: "same-key",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ BAD - Don't use try-catch
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
await autumn.customers.get("invalid-id");
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
}
|
||||||
|
expect(errorThrown).toBe(true);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Common Error Codes:**
|
||||||
|
- `ErrCode.CustomerNotFound`
|
||||||
|
- `ErrCode.ProductNotFound`
|
||||||
|
- `ErrCode.FeatureNotFound`
|
||||||
|
- `ErrCode.InsufficientBalance`
|
||||||
|
- `ErrCode.DuplicateIdempotencyKey`
|
||||||
|
- `ErrCode.InvalidRequest`
|
||||||
|
|
||||||
## Public Key Restrictions
|
## Public Key Restrictions
|
||||||
|
|
||||||
Public keys can only access:
|
Public keys can only access:
|
||||||
@@ -80,6 +113,38 @@ Public keys CANNOT:
|
|||||||
- `test` - Individual test cases
|
- `test` - Individual test cases
|
||||||
- Use descriptive test names with `chalk.yellowBright()`
|
- Use descriptive test names with `chalk.yellowBright()`
|
||||||
|
|
||||||
|
## Customer Initialization
|
||||||
|
|
||||||
|
### Payment Methods
|
||||||
|
**IMPORTANT:** If your product has ANY price (overage, per-seat, usage-based, etc.), you MUST attach a payment method:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ GOOD - Product with prices requires payment method
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
attachPm: "success", // Required for any paid features
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ BAD - Product with prices but no payment method
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false, // Missing attachPm: "success"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `attachPm: "success"` when:
|
||||||
|
- Product has overage pricing (arrear items)
|
||||||
|
- Product has per-seat pricing
|
||||||
|
- Product has usage-based billing
|
||||||
|
- Any feature can trigger billing
|
||||||
|
|
||||||
|
Omit `attachPm` only for:
|
||||||
|
- Completely free products (no prices at all)
|
||||||
|
- Tests that don't require billing
|
||||||
|
|
||||||
## Imports
|
## Imports
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
536
server/tests/_guides/track-endpoint-tests.md
Normal file
536
server/tests/_guides/track-endpoint-tests.md
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# Guide: Writing /track Endpoint Tests
|
||||||
|
|
||||||
|
## What is /track?
|
||||||
|
|
||||||
|
The `/track` endpoint records usage for metered features and deducts from customer balances.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `customer_id` (required) - The customer to track usage for
|
||||||
|
- `feature_id` OR `event_name` (required) - The feature or event to track
|
||||||
|
- `value` (optional) - The amount to track (defaults to 1)
|
||||||
|
- `entity_id` (optional) - For entity-scoped features
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Deducts from customer balances
|
||||||
|
- Returns synchronously (no need for timeouts)
|
||||||
|
- Supports credit systems with automatic fallback
|
||||||
|
- Handles concurrent requests with SQL-level atomicity
|
||||||
|
|
||||||
|
## Step-by-Step: Writing a /track Test
|
||||||
|
|
||||||
|
### Step 1: Define What You're Testing
|
||||||
|
|
||||||
|
Identify the specific scenario:
|
||||||
|
- Basic metered feature deduction
|
||||||
|
- Credit system deduction
|
||||||
|
- Event-based tracking (multiple features from one event)
|
||||||
|
- Deduction order (feature → credit system)
|
||||||
|
- Concurrent track requests
|
||||||
|
- Balance capping (stop at 0 vs allow negative)
|
||||||
|
- Entity-scoped tracking
|
||||||
|
|
||||||
|
### Step 2: Construct Features & Products
|
||||||
|
|
||||||
|
#### Feature Types
|
||||||
|
|
||||||
|
**Basic Metered Features**:
|
||||||
|
```typescript
|
||||||
|
const messagesFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event-Based Features** (multiple features triggered by one event):
|
||||||
|
```typescript
|
||||||
|
// Both action1 and action2 listen to "action-event"
|
||||||
|
const action1Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
includedUsage: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action2Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action2,
|
||||||
|
includedUsage: 150,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credit Systems** (fallback pool for actions):
|
||||||
|
```typescript
|
||||||
|
const creditsFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Credits,
|
||||||
|
includedUsage: 100,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
// Action1 consumes from Credits with credit_cost = 0.2
|
||||||
|
// Action2 consumes from Credits with credit_cost = 0.6
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Combine into Products
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [messagesFeature, creditsFeature],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Initialize Test Environment
|
||||||
|
|
||||||
|
**Always use this exact order in `beforeAll`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
|
||||||
|
const testCase = "track-basic1";
|
||||||
|
const customerId = "track-basic1";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// 1. Create customer
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create products
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Attach product to customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Write Test Cases
|
||||||
|
|
||||||
|
**IMPORTANT: Use Decimal for balance calculations to avoid floating point errors**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should deduct exact value provided", async () => {
|
||||||
|
const initialBalance = 100;
|
||||||
|
const deductValue = 23.47;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
// Use Decimal to avoid floating point errors
|
||||||
|
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
|
||||||
|
|
||||||
|
expect(balance).toBe(expectedBalance);
|
||||||
|
expect(usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Scenarios
|
||||||
|
|
||||||
|
### 1. Basic Track (No Value)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should deduct 1 when no value provided", async () => {
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
// No value = defaults to 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Messages].balance).toBe(99);
|
||||||
|
expect(customer.features[TestFeature.Messages].usage).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Track with Value
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should deduct exact value", async () => {
|
||||||
|
const initialBalance = 100;
|
||||||
|
const deductValue = 37.89; // Use decimals for robustness
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Event-Based Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should deduct from multiple features using event_name", async () => {
|
||||||
|
const deductValue = 45.67;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event", // Triggers action1 AND action2
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Both features deducted
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(
|
||||||
|
new Decimal(200).sub(deductValue).toNumber()
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Action2].balance).toBe(
|
||||||
|
new Decimal(150).sub(deductValue).toNumber()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Credit Systems
|
||||||
|
|
||||||
|
**Direct Credit Tracking:**
|
||||||
|
```typescript
|
||||||
|
test("should deduct from credits directly", async () => {
|
||||||
|
const deductValue = 27.35;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Credits,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
new Decimal(100).sub(deductValue).toNumber()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Track Action (Uses Credits with Multiplier):**
|
||||||
|
```typescript
|
||||||
|
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
||||||
|
|
||||||
|
test("should deduct from credits with credit_cost multiplier", async () => {
|
||||||
|
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
|
||||||
|
const action1Value = 50.25;
|
||||||
|
|
||||||
|
const expectedCreditCost = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: action1Value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: action1Value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
new Decimal(200).sub(expectedCreditCost).toNumber()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Deduction Order (Feature First, Then Credits)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should deduct from action1 first, then credits", async () => {
|
||||||
|
// Product has: action1 (100 units) + credits (200 units)
|
||||||
|
|
||||||
|
// First track: only affects action1
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: 40.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(59.5);
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(200); // Untouched
|
||||||
|
|
||||||
|
// Second track: finishes action1, dips into credits
|
||||||
|
const deductValue = 80;
|
||||||
|
const remainingAction1 = 59.5;
|
||||||
|
const overflowAmount = deductValue - remainingAction1;
|
||||||
|
|
||||||
|
const creditCostForOverflow = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: overflowAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(0); // Depleted
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
new Decimal(200).sub(creditCostForOverflow).toNumber()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Concurrent Requests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should handle concurrent requests correctly", async () => {
|
||||||
|
const initialBalance = 100;
|
||||||
|
|
||||||
|
// Send 5 concurrent requests, each trying to deduct 10
|
||||||
|
const promises = [
|
||||||
|
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
|
||||||
|
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
|
||||||
|
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
|
||||||
|
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
|
||||||
|
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const expectedBalance = new Decimal(initialBalance).sub(50).toNumber();
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
|
||||||
|
expect(customer.features[TestFeature.Messages].usage).toBe(50);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Balance Capping
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should cap balance at 0 with default behavior", async () => {
|
||||||
|
// Initial balance: 5
|
||||||
|
// Try to deduct: 50 (more than available)
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Messages].balance).toBe(0); // Capped
|
||||||
|
expect(customer.features[TestFeature.Messages].usage).toBe(5); // Only deducted what was available
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Credit System Pairs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("should deduct from two credit system pairs simultaneously", async () => {
|
||||||
|
// Product has:
|
||||||
|
// - action1 (80) + credits (150)
|
||||||
|
// - action3 (60) + credits2 (100)
|
||||||
|
|
||||||
|
const deductValue = 25.5;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event", // Triggers both action1 and action3
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Both actions deducted
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(
|
||||||
|
new Decimal(80).sub(deductValue).toNumber()
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(
|
||||||
|
new Decimal(60).sub(deductValue).toNumber()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Credits untouched (actions had enough balance)
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(150);
|
||||||
|
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Imports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
|
||||||
|
const testCase = "track-X";
|
||||||
|
const customerId = "track-X";
|
||||||
|
|
||||||
|
const someFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [someFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-X: description")}`, () => {
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({ ctx, customerId, withTestClock: false });
|
||||||
|
await initProductsV0({ ctx, products: [freeProd], prefix: testCase });
|
||||||
|
await autumnV1.attach({ customer_id: customerId, product_id: freeProd.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
expect(customer.features[TestFeature.Messages].balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct correctly", async () => {
|
||||||
|
const deductValue = 23.47; // Use random decimals
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const expectedBalance = new Decimal(100).sub(deductValue).toNumber();
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Differences from /check
|
||||||
|
|
||||||
|
| Aspect | /check | /track |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| **Purpose** | Validate access | Record usage |
|
||||||
|
| **Modifies Data** | No | Yes (deducts balance) |
|
||||||
|
| **Returns** | Allowed/balance info | Success/event details |
|
||||||
|
| **Synchronous** | Yes | Yes (no timeouts needed) |
|
||||||
|
| **Credit Systems** | Check action, shows credit balance | Deducts from action, falls back to credits |
|
||||||
|
| **Concurrency** | N/A | Handled with SQL atomicity |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### ✅ DO
|
||||||
|
- Use `Decimal` for all balance calculations: `new Decimal(100).sub(23.47).toNumber()`
|
||||||
|
- Use random decimal values (23.47, 37.89, 50.25) for test robustness
|
||||||
|
- Test initial balance before tracking
|
||||||
|
- Test both `feature_id` and `event_name` approaches
|
||||||
|
- Import `getCreditCost` when testing credit systems
|
||||||
|
- Test deduction order (feature → credits)
|
||||||
|
- Verify both `balance` and `usage` fields
|
||||||
|
|
||||||
|
### ❌ DON'T
|
||||||
|
- Don't use raw arithmetic: `100 - 23.47` (floating point errors!)
|
||||||
|
- Don't use timeouts (track is synchronous)
|
||||||
|
- Don't test on Credits feature directly (test on actions)
|
||||||
|
- Don't assume balance order without sorting
|
||||||
|
- Don't forget to test concurrent scenarios
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Unique test case name (e.g., "track-basic1")
|
||||||
|
- [ ] Use chalk for describe block
|
||||||
|
- [ ] Use `Decimal` for balance calculations
|
||||||
|
- [ ] Random decimal values for `value` parameter
|
||||||
|
- [ ] Initialize in correct order: customer → products → attach
|
||||||
|
- [ ] Test initial balance first
|
||||||
|
- [ ] For credit systems: use `getCreditCost` helper
|
||||||
|
- [ ] Verify both `balance` and `usage` fields
|
||||||
|
- [ ] Test concurrent requests when relevant
|
||||||
|
- [ ] No setTimeout/timeouts (track is synchronous)
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### ❌ Floating Point Error
|
||||||
|
```typescript
|
||||||
|
// BAD
|
||||||
|
expect(balance).toBe(100 - 23.47); // May fail due to floating point
|
||||||
|
|
||||||
|
// GOOD
|
||||||
|
expect(balance).toBe(new Decimal(100).sub(23.47).toNumber());
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Testing Credits Directly
|
||||||
|
```typescript
|
||||||
|
// BAD - Tests credit feature directly
|
||||||
|
await autumnV1.track({
|
||||||
|
feature_id: TestFeature.Credits,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// GOOD - Tests action that uses credits
|
||||||
|
await autumnV1.track({
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: 50,
|
||||||
|
});
|
||||||
|
// Then check both action1 and credits balances
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Forgetting Credit Cost Multiplier
|
||||||
|
```typescript
|
||||||
|
// BAD - Assumes 1:1 deduction
|
||||||
|
expect(credits.balance).toBe(100 - 50);
|
||||||
|
|
||||||
|
// GOOD - Calculates with credit_cost
|
||||||
|
const expectedCost = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature,
|
||||||
|
amount: 50,
|
||||||
|
});
|
||||||
|
expect(credits.balance).toBe(new Decimal(100).sub(expectedCost).toNumber());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced: Testing Deduction Order
|
||||||
|
|
||||||
|
When a product has both a metered feature AND a credit system:
|
||||||
|
|
||||||
|
1. **First**: Deducts from the metered feature
|
||||||
|
2. **Then**: When depleted, falls back to credit system
|
||||||
|
3. **Credit Cost**: Applied when using credit system (not 1:1)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Setup: action1 (100) + credits (200), credit_cost = 0.2
|
||||||
|
|
||||||
|
// Track 40 → only action1 affected
|
||||||
|
// action1: 60, credits: 200
|
||||||
|
|
||||||
|
// Track 80 → finishes action1 (60), then uses credits for remaining 20
|
||||||
|
// action1: 0, credits: 200 - (20 * 0.2) = 196
|
||||||
|
|
||||||
|
// Track 50 → only credits affected
|
||||||
|
// action1: 0, credits: 196 - (50 * 0.2) = 186
|
||||||
|
```
|
||||||
|
|
||||||
@@ -176,6 +176,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item`
|
|||||||
expect(rollover1.balance).to.equal(0);
|
expect(rollover1.balance).to.equal(0);
|
||||||
expect(rollover2.balance).to.equal(350);
|
expect(rollover2.balance).to.equal(350);
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
|
||||||
it("should track and deduct from rollover + original balance", async () => {
|
it("should track and deduct from rollover + original balance", async () => {
|
||||||
await autumn.track({
|
await autumn.track({
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
|
|||||||
await resetAndGetCusEnt({
|
await resetAndGetCusEnt({
|
||||||
db,
|
db,
|
||||||
customer,
|
customer,
|
||||||
productGroup: free.group,
|
productGroup: free.group!,
|
||||||
featureId: TestFeature.Messages,
|
featureId: TestFeature.Messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing rollovers for feature item
|
|||||||
await resetAndGetCusEnt({
|
await resetAndGetCusEnt({
|
||||||
db,
|
db,
|
||||||
customer,
|
customer,
|
||||||
productGroup: free.group,
|
productGroup: free.group!,
|
||||||
featureId: TestFeature.Messages,
|
featureId: TestFeature.Messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing failed upgrades`)}`, () =>
|
|||||||
feature_id: TestFeature.Words,
|
feature_id: TestFeature.Words,
|
||||||
value: usage,
|
value: usage,
|
||||||
});
|
});
|
||||||
await timeout(4000);
|
|
||||||
|
|
||||||
const cus = await CusService.get({
|
const cus = await CusService.get({
|
||||||
db,
|
db,
|
||||||
|
|||||||
68
server/tests/balances/track/basic/track-basic1.test.ts
Normal file
68
server/tests/balances/track/basic/track-basic1.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const messagesFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [messagesFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic1";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic1: track with no value provided")}`, () => {
|
||||||
|
const customerId = "track-basic1";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 100", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct 1 when no value provided", async () => {
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(99);
|
||||||
|
expect(usage).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
server/tests/balances/track/basic/track-basic2.test.ts
Normal file
71
server/tests/balances/track/basic/track-basic2.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const messagesFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [messagesFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic2";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic2: track with value provided")}`, () => {
|
||||||
|
const customerId = "track-basic2";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 100", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct exact value provided", async () => {
|
||||||
|
const deductValue = 23.47;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(100 - deductValue);
|
||||||
|
expect(usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
71
server/tests/balances/track/basic/track-basic3.test.ts
Normal file
71
server/tests/balances/track/basic/track-basic3.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const action1Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
includedUsage: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [action1Feature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic3";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic3: track with event_name instead of feature_id")}`, () => {
|
||||||
|
const customerId = "track-basic3";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 150", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Action1].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from action1 using event_name", async () => {
|
||||||
|
const deductValue = 37.89;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event",
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Action1].balance;
|
||||||
|
const usage = customer.features[TestFeature.Action1].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(150 - deductValue);
|
||||||
|
expect(usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
server/tests/balances/track/basic/track-basic4.test.ts
Normal file
85
server/tests/balances/track/basic/track-basic4.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const action1Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
includedUsage: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action3Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action3,
|
||||||
|
includedUsage: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [action1Feature, action3Feature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic4";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic4: track with event_name deducts from multiple features")}`, () => {
|
||||||
|
const customerId = "track-basic4";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balances", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(200);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from both action1 and action3 using event_name", async () => {
|
||||||
|
const deductValue = 45.67;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event",
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
const action1Balance = customer.features[TestFeature.Action1].balance;
|
||||||
|
const action1Usage = customer.features[TestFeature.Action1].usage;
|
||||||
|
const action3Balance = customer.features[TestFeature.Action3].balance;
|
||||||
|
const action3Usage = customer.features[TestFeature.Action3].usage;
|
||||||
|
|
||||||
|
const expectedAction1Balance = new Decimal(200).sub(deductValue).toNumber();
|
||||||
|
const expectedAction3Balance = new Decimal(150).sub(deductValue).toNumber();
|
||||||
|
|
||||||
|
expect(action1Balance).toBe(expectedAction1Balance);
|
||||||
|
expect(action1Usage).toBe(deductValue);
|
||||||
|
expect(action3Balance).toBe(expectedAction3Balance);
|
||||||
|
expect(action3Usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
server/tests/balances/track/basic/track-basic5.test.ts
Normal file
91
server/tests/balances/track/basic/track-basic5.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const action1Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action2Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action2,
|
||||||
|
includedUsage: 150,
|
||||||
|
});
|
||||||
|
|
||||||
|
const action3Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action3,
|
||||||
|
includedUsage: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [action1Feature, action2Feature, action3Feature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic5";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic5: track specific feature_id only affects that feature")}`, () => {
|
||||||
|
const customerId = "track-basic5";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balances for all features", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(100);
|
||||||
|
expect(customer.features[TestFeature.Action2].balance).toBe(150);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should only deduct from action1 when tracking feature_id: action1", async () => {
|
||||||
|
const deductValue = 37.82;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// action1 should be deducted
|
||||||
|
const expectedAction1Balance = new Decimal(100).sub(deductValue).toNumber();
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(
|
||||||
|
expectedAction1Balance,
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Action1].usage).toBe(deductValue);
|
||||||
|
|
||||||
|
// action2 and action3 should remain unchanged
|
||||||
|
expect(customer.features[TestFeature.Action2].balance).toBe(150);
|
||||||
|
expect(customer.features[TestFeature.Action2].usage).toBe(0);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(200);
|
||||||
|
expect(customer.features[TestFeature.Action3].usage).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
server/tests/balances/track/basic/track-basic6.test.ts
Normal file
131
server/tests/balances/track/basic/track-basic6.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const messagesFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [messagesFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic6";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic6: test idempotency key prevents duplicate tracks")}`, () => {
|
||||||
|
const customerId = "track-basic6";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 100", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should process first track with idempotency key", async () => {
|
||||||
|
const deductValue = 25.5;
|
||||||
|
const idempotencyKey = "test-idempotency-key-1";
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
const expectedBalance = new Decimal(100).sub(deductValue).toNumber();
|
||||||
|
|
||||||
|
expect(balance).toBe(expectedBalance);
|
||||||
|
expect(usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject second track with same idempotency key", async () => {
|
||||||
|
const deductValue = 30.75; // Different value
|
||||||
|
const idempotencyKey = "test-idempotency-key-1"; // Same key
|
||||||
|
|
||||||
|
// Get balance before attempting duplicate track
|
||||||
|
const customerBefore = await autumnV1.customers.get(customerId);
|
||||||
|
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
// This should fail or be rejected due to duplicate idempotency key
|
||||||
|
let errorThrown = false;
|
||||||
|
try {
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorThrown = true;
|
||||||
|
// Optionally check error type/message
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorThrown).toBe(true);
|
||||||
|
|
||||||
|
// Balance should remain unchanged
|
||||||
|
const customerAfter = await autumnV1.customers.get(customerId);
|
||||||
|
const balanceAfter = customerAfter.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balanceAfter).toBe(balanceBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should process track with different idempotency key", async () => {
|
||||||
|
const deductValue = 15.25;
|
||||||
|
const idempotencyKey = "test-idempotency-key-2"; // Different key
|
||||||
|
|
||||||
|
const customerBefore = await autumnV1.customers.get(customerId);
|
||||||
|
const balanceBefore = customerBefore.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: deductValue,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
const expectedBalance = new Decimal(balanceBefore!)
|
||||||
|
.sub(deductValue)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
expect(balance).toBe(expectedBalance);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
server/tests/balances/track/basic/track-basic7.test.ts
Normal file
128
server/tests/balances/track/basic/track-basic7.test.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const messagesFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
unlimited: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [messagesFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-basic7";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-basic7: track with unlimited balance")}`, () => {
|
||||||
|
const customerId = "track-basic7";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have unlimited balance initially", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const unlimited = customer.features[TestFeature.Messages].unlimited;
|
||||||
|
|
||||||
|
expect(balance).toBe(0);
|
||||||
|
expect(unlimited).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remain unlimited after tracking without value", async () => {
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const unlimited = customer.features[TestFeature.Messages].unlimited;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(0);
|
||||||
|
expect(unlimited).toBe(true);
|
||||||
|
expect(usage).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remain unlimited after tracking with small value", async () => {
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const unlimited = customer.features[TestFeature.Messages].unlimited;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(0);
|
||||||
|
expect(unlimited).toBe(true);
|
||||||
|
expect(usage).toBe(11); // 1 from previous test + 10
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remain unlimited after tracking with large value", async () => {
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 1000000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const unlimited = customer.features[TestFeature.Messages].unlimited;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(0);
|
||||||
|
expect(unlimited).toBe(true);
|
||||||
|
expect(usage).toBe(1000011); // 11 from previous tests + 1000000
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remain unlimited after multiple concurrent tracks", async () => {
|
||||||
|
const trackPromises = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: i + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(trackPromises);
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const unlimited = customer.features[TestFeature.Messages].unlimited;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
// 1000011 from previous + sum(1..10) = 1000011 + 55
|
||||||
|
expect(balance).toBe(0);
|
||||||
|
expect(unlimited).toBe(true);
|
||||||
|
expect(usage).toBe(1000066);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
server/tests/balances/track/basic/track-basic8.test.ts
Normal file
136
server/tests/balances/track/basic/track-basic8.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import {
|
||||||
|
constructArrearItem,
|
||||||
|
constructFeatureItem,
|
||||||
|
} from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
import { trackWasSuccessful } from "../trackTestUtils.js";
|
||||||
|
|
||||||
|
const testCase = "trackBasic8";
|
||||||
|
const prepaidCustomerId = `${testCase}_prepaid`;
|
||||||
|
const payPerUseCustomerId = `${testCase}_payperuse`;
|
||||||
|
|
||||||
|
// Prepaid feature: 5 included, no overage allowed
|
||||||
|
const prepaidItem = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PayPerUse feature: 5 included, overage allowed at $0.01 per unit, usage_limit of 10
|
||||||
|
const payPerUseItem = constructArrearItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 5,
|
||||||
|
price: 0.01,
|
||||||
|
billingUnits: 1,
|
||||||
|
usageLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prepaidProduct = constructProduct({
|
||||||
|
id: "prepaid",
|
||||||
|
items: [prepaidItem],
|
||||||
|
type: "pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payPerUseProduct = constructProduct({
|
||||||
|
id: "payperuse",
|
||||||
|
items: [payPerUseItem],
|
||||||
|
type: "pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright(`${testCase}: Testing prepaid vs pay-per-use overage behavior`)}`, () => {
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Initialize both customers
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId: prepaidCustomerId,
|
||||||
|
withTestClock: false,
|
||||||
|
attachPm: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId: payPerUseCustomerId,
|
||||||
|
withTestClock: false,
|
||||||
|
attachPm: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize products
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [prepaidProduct, payPerUseProduct],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach prepaid product to prepaid customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: prepaidCustomerId,
|
||||||
|
product_id: prepaidProduct.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach payPerUse product to payPerUse customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: payPerUseCustomerId,
|
||||||
|
product_id: payPerUseProduct.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 5 for prepaid customer", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(prepaidCustomerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 5 for pay-per-use customer", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(payPerUseCustomerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject tracking 7 units when prepaid balance is 5 (no overage)", async () => {
|
||||||
|
const res = await autumnV1.track({
|
||||||
|
customer_id: prepaidCustomerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 7,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trackWasSuccessful({ res })).toBe(false);
|
||||||
|
expect(res.code).toBe("insufficient_balance");
|
||||||
|
|
||||||
|
// Verify balance remains unchanged
|
||||||
|
const finalCustomer = await autumnV1.customers.get(prepaidCustomerId);
|
||||||
|
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(finalBalance).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow tracking 7 units when PayPerUse balance is 5 (overage allowed)", async () => {
|
||||||
|
const res = await autumnV1.track({
|
||||||
|
customer_id: payPerUseCustomerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 7,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trackWasSuccessful({ res })).toBe(true);
|
||||||
|
|
||||||
|
// Verify balance went negative (overage)
|
||||||
|
const finalCustomer = await autumnV1.customers.get(payPerUseCustomerId);
|
||||||
|
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
|
||||||
|
const finalUsage = finalCustomer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(finalBalance).toBe(-2);
|
||||||
|
expect(finalUsage).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
import { trackWasSuccessful } from "../trackTestUtils.js";
|
||||||
|
|
||||||
|
const testCase = "concurrentTrack1";
|
||||||
|
const customerId = testCase;
|
||||||
|
|
||||||
|
const free = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [
|
||||||
|
constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright(`concurrentTrack1: Testing track with concurrent requests and balance capping`)}`, () => {
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [free],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach product to customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: free.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 5", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow concurrent requests and cap balance at 0", async () => {
|
||||||
|
// Send 5 concurrent requests, each trying to deduct 10
|
||||||
|
// Only 5 should be deducted (initial balance), capping at 0
|
||||||
|
const promises = [
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 10,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 10,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 10,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 10,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 10,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
// With cap behavior, all requests pass (cap at 0 instead of rejecting)
|
||||||
|
const allFulfilled = results.every((r) => trackWasSuccessful({ res: r }));
|
||||||
|
expect(allFulfilled).toBe(true);
|
||||||
|
|
||||||
|
// Check final balance
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const finalBalance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const finalUsage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
expect(finalBalance).toBe(0);
|
||||||
|
expect(finalUsage).toBe(5); // Only 5 was actually deducted (initial balance)
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, ProductItemFeatureType } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const testCase = "concurrentTrack2";
|
||||||
|
const customerId = testCase;
|
||||||
|
|
||||||
|
const pro = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [
|
||||||
|
constructFeatureItem({
|
||||||
|
featureId: TestFeature.Users,
|
||||||
|
includedUsage: 1,
|
||||||
|
featureType: ProductItemFeatureType.ContinuousUse,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright(`concurrentTrack2: Testing concurrent track, allocated feature`)}`, () => {
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [pro],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach product to customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: pro.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 1", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Users].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should only allow one concurrent track with balance of 1", async () => {
|
||||||
|
const promises = [
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
value: 1,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// console.log(results);
|
||||||
|
// return;
|
||||||
|
|
||||||
|
// const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||||
|
// const rejectedCount = results.filter((r) => r.status === "rejected").length;
|
||||||
|
|
||||||
|
// // Only 1 should succeed, 4 should be rejected due to insufficient balance
|
||||||
|
// expect(successCount).toBe(1);
|
||||||
|
// expect(rejectedCount).toBe(4);
|
||||||
|
|
||||||
|
// Check final balance
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const finalBalance = customer.features[TestFeature.Users].balance;
|
||||||
|
|
||||||
|
expect(finalBalance).toBe(-4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
// import { ApiVersion, ProductItemFeatureType } from "@autumn/shared";
|
||||||
|
// import chalk from "chalk";
|
||||||
|
// import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
// import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
// import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
// import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
// import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
// import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
// import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
// const testCase = "trackMisc3";
|
||||||
|
// const customerId = `${testCase}_cus1`;
|
||||||
|
|
||||||
|
// const userItem = constructFeatureItem({
|
||||||
|
// featureId: TestFeature.Users,
|
||||||
|
// includedUsage: 1,
|
||||||
|
// featureType: ProductItemFeatureType.ContinuousUse,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const pro = constructProduct({
|
||||||
|
// items: [userItem],
|
||||||
|
// type: "pro",
|
||||||
|
// });
|
||||||
|
|
||||||
|
// describe(`${chalk.yellowBright(`${testCase}: Testing track prepaid allocated feature with concurrent requests`)}`, () => {
|
||||||
|
// const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
// beforeAll(async () => {
|
||||||
|
// await initCustomerV3({
|
||||||
|
// ctx,
|
||||||
|
// customerId,
|
||||||
|
// withTestClock: false,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await initProductsV0({
|
||||||
|
// ctx,
|
||||||
|
// products: [pro],
|
||||||
|
// prefix: testCase,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Attach product to customer
|
||||||
|
// await autumnV1.attach({
|
||||||
|
// customer_id: customerId,
|
||||||
|
// product_id: pro.id,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("should have initial balance of 1", async () => {
|
||||||
|
// const customer = await autumnV1.customers.get(customerId);
|
||||||
|
// const balance = customer.features[TestFeature.Users].balance;
|
||||||
|
|
||||||
|
// expect(balance).toBe(1);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// test("should only allow one concurrent seat allocation with 1 included seat and create no duplicate invoices", async () => {
|
||||||
|
// const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// const initialInvoices = await ctx.stripeCli.invoices.list({
|
||||||
|
// customer: customer.stripe_id as string,
|
||||||
|
// });
|
||||||
|
// const initialInvoiceCount = initialInvoices.data.length;
|
||||||
|
|
||||||
|
// // Try to allocate 5 different seats concurrently - only 1 should succeed (the included seat)
|
||||||
|
// // The other 4 should be rejected because we only have 1 included seat
|
||||||
|
// const promises = [
|
||||||
|
// autumnV1.track({
|
||||||
|
// customer_id: customerId,
|
||||||
|
// feature_id: TestFeature.Users,
|
||||||
|
// value: 1,
|
||||||
|
// }),
|
||||||
|
// autumnV1.track({
|
||||||
|
// customer_id: customerId,
|
||||||
|
// feature_id: TestFeature.Users,
|
||||||
|
// value: 1,
|
||||||
|
// }),
|
||||||
|
// autumnV1.track({
|
||||||
|
// customer_id: customerId,
|
||||||
|
// feature_id: TestFeature.Users,
|
||||||
|
// value: 1,
|
||||||
|
// }),
|
||||||
|
// autumnV1.track({
|
||||||
|
// customer_id: customerId,
|
||||||
|
// feature_id: TestFeature.Users,
|
||||||
|
// value: 1,
|
||||||
|
// }),
|
||||||
|
// autumnV1.track({
|
||||||
|
// customer_id: customerId,
|
||||||
|
// feature_id: TestFeature.Users,
|
||||||
|
// value: 1,
|
||||||
|
// }),
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// const results = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
// const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||||
|
// const rejectedCount = results.filter((r) => r.status === "rejected").length;
|
||||||
|
|
||||||
|
// // Only 1 should succeed (included seat), 4 should be rejected
|
||||||
|
// expect(successCount).toBe(1);
|
||||||
|
// expect(rejectedCount).toBe(4);
|
||||||
|
|
||||||
|
// // Check final balance
|
||||||
|
// const finalCustomer = await autumnV1.customers.get(customerId);
|
||||||
|
// const finalBalance = finalCustomer.features[TestFeature.Users].balance;
|
||||||
|
|
||||||
|
// expect(finalBalance).toBe(0);
|
||||||
|
|
||||||
|
// // Verify no duplicate invoices were created
|
||||||
|
// // Since we only allocated the 1 included seat, no overage charges should occur
|
||||||
|
// const finalInvoices = await ctx.stripeCli.invoices.list({
|
||||||
|
// customer: customer.stripe_id as string,
|
||||||
|
// });
|
||||||
|
// const finalInvoiceCount = finalInvoices.data.length;
|
||||||
|
// const newInvoicesCreated = finalInvoiceCount - initialInvoiceCount;
|
||||||
|
|
||||||
|
// expect(newInvoicesCreated).toBe(0);
|
||||||
|
// });
|
||||||
|
// });
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
import { trackWasSuccessful } from "../trackTestUtils.js";
|
||||||
|
|
||||||
|
const testCase = "concurrentTrack4";
|
||||||
|
const customerId = testCase;
|
||||||
|
|
||||||
|
const messageItem = constructArrearItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 5,
|
||||||
|
price: 0.1,
|
||||||
|
billingUnits: 1,
|
||||||
|
usageLimit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pro = constructProduct({
|
||||||
|
id: "pro",
|
||||||
|
items: [messageItem],
|
||||||
|
type: "pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright(`${testCase}: Testing usage_limits with pay_per_use feature and concurrent requests`)}`, () => {
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
attachPm: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [pro],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach product to customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: pro.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 5 with usage_limit of 10", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const usageLimit = customer.features[TestFeature.Messages].usage_limit;
|
||||||
|
|
||||||
|
expect(balance).toBe(5);
|
||||||
|
expect(usageLimit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should enforce usage_limit with concurrent requests", async () => {
|
||||||
|
console.log(
|
||||||
|
"🚀 Starting 5 concurrent track calls (3 units each) at exact same time...",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to use 3 units concurrently - with usage_limit of 10, only 3 requests can succeed (3x3=9 <= 10)
|
||||||
|
const promises = [
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 3,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 3,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 3,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 3,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
value: 3,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
console.log(results);
|
||||||
|
|
||||||
|
const successCount = results.filter((r) =>
|
||||||
|
trackWasSuccessful({ res: r }),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const rejectedCount = results.filter(
|
||||||
|
(r) => !trackWasSuccessful({ res: r }),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
expect(successCount).toBe(3);
|
||||||
|
expect(rejectedCount).toBe(2);
|
||||||
|
|
||||||
|
// Wait for any async processing to complete
|
||||||
|
console.log(`⏳ Waiting 3s for all updates to persist...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
console.log(`📦 Final state after all requests:`);
|
||||||
|
console.log(
|
||||||
|
`- Balance: ${customer.features[TestFeature.Messages]?.balance} (expected: -4)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`- Usage: ${customer.features[TestFeature.Messages]?.usage} (expected: 9)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`- Usage limit: ${customer.features[TestFeature.Messages]?.usage_limit} (expected: 10)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Messages]?.balance).toBe(-4);
|
||||||
|
expect(customer.features[TestFeature.Messages]?.usage).toBe(9);
|
||||||
|
expect(customer.features[TestFeature.Messages]?.usage_limit).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, ProductItemFeatureType } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import {
|
||||||
|
constructArrearItem,
|
||||||
|
constructFeatureItem,
|
||||||
|
} from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
import { trackWasSuccessful } from "../trackTestUtils.js";
|
||||||
|
|
||||||
|
const testCase = "concurrentTrack5";
|
||||||
|
const customerId = testCase;
|
||||||
|
|
||||||
|
const seatItem = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Users,
|
||||||
|
includedUsage: 5,
|
||||||
|
featureType: ProductItemFeatureType.ContinuousUse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const perSeatMessagesItem = constructArrearItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
entityFeatureId: TestFeature.Users,
|
||||||
|
price: 0.01,
|
||||||
|
includedUsage: 500,
|
||||||
|
usageLimit: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pro = constructProduct({
|
||||||
|
id: "pro",
|
||||||
|
items: [seatItem, perSeatMessagesItem],
|
||||||
|
type: "pro",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright(`${testCase}: Testing per-entity track with concurrent requests`)}`, () => {
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
attachPm: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [pro],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach product to customer
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: pro.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create 5 seats each with 500 messages", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const seatBalance = customer.features[TestFeature.Users].balance;
|
||||||
|
expect(seatBalance).toBe(5);
|
||||||
|
|
||||||
|
// Create 5 entities (seats)
|
||||||
|
const entities = [
|
||||||
|
{ id: "seat1", name: "Seat 1" },
|
||||||
|
{ id: "seat2", name: "Seat 2" },
|
||||||
|
{ id: "seat3", name: "Seat 3" },
|
||||||
|
{ id: "seat4", name: "Seat 4" },
|
||||||
|
{ id: "seat5", name: "Seat 5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
await autumnV1.entities.create(customerId, {
|
||||||
|
id: entity.id,
|
||||||
|
name: entity.name,
|
||||||
|
feature_id: TestFeature.Users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each seat has 500 messages
|
||||||
|
const updatedEntity = await autumnV1.entities.get(
|
||||||
|
customerId,
|
||||||
|
entities[0].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updatedEntity.features[TestFeature.Messages].balance).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should enforce usage_limit of 600 per seat with concurrent 200-unit requests", async () => {
|
||||||
|
const entityId = "seat1";
|
||||||
|
|
||||||
|
// Verify seat1 has 500 included messages with 600 usage_limit
|
||||||
|
const entityRes = await autumnV1.entities.get(customerId, entityId);
|
||||||
|
expect(entityRes.features[TestFeature.Messages].balance).toBe(500);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"🚀 Starting 5 concurrent track calls (200 units each) for seat1...",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Initial state: balance=${entityRes.features[TestFeature.Messages].balance}, usage_limit=${entityRes.features[TestFeature.Messages].usage_limit}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try 5 concurrent 200-unit sends to seat1
|
||||||
|
// With usage_limit of 600, only 3 should succeed (3×200=600 <= 600)
|
||||||
|
const promises = [
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
entity_id: entityId,
|
||||||
|
value: 200,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
entity_id: entityId,
|
||||||
|
value: 200,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
entity_id: entityId,
|
||||||
|
value: 200,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
entity_id: entityId,
|
||||||
|
value: 200,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
entity_id: entityId,
|
||||||
|
value: 200,
|
||||||
|
overage_behaviour: "reject",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
const successCount = results.filter((r) =>
|
||||||
|
trackWasSuccessful({ res: r }),
|
||||||
|
).length;
|
||||||
|
const rejectedCount = results.filter(
|
||||||
|
(r) => !trackWasSuccessful({ res: r }),
|
||||||
|
).length;
|
||||||
|
console.log(
|
||||||
|
`\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(successCount).toBe(3);
|
||||||
|
expect(rejectedCount).toBe(2);
|
||||||
|
|
||||||
|
// Get final state
|
||||||
|
const finalEntityRes = await autumnV1.entities.get(customerId, entityId);
|
||||||
|
console.log(`\n📦 Final state for ${entityId}:`);
|
||||||
|
console.log(
|
||||||
|
`- Balance: ${finalEntityRes.features[TestFeature.Messages].balance} (expected: -100)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`- Usage: ${finalEntityRes.features[TestFeature.Messages].usage} (expected: 600)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`- Usage limit: ${finalEntityRes.features[TestFeature.Messages].usage_limit} (expected: 600)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(finalEntityRes.features[TestFeature.Messages].balance).toBe(-100);
|
||||||
|
expect(finalEntityRes.features[TestFeature.Messages].usage).toBe(600);
|
||||||
|
|
||||||
|
// Verify other seats remain untouched at 500
|
||||||
|
for (const seatId of ["seat2", "seat3", "seat4", "seat5"]) {
|
||||||
|
const otherSeatRes = await autumnV1.entities.get(customerId, seatId);
|
||||||
|
expect(otherSeatRes.features[TestFeature.Messages].balance).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const creditsFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Credits,
|
||||||
|
includedUsage: 100,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [creditsFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-credit-system1";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-credit-system1: track credits directly")}`, () => {
|
||||||
|
const customerId = "track-credit-system1";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 100 credits", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Credits].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from credits directly", async () => {
|
||||||
|
const deductValue = 27.35;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Credits,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Credits].balance;
|
||||||
|
const usage = customer.features[TestFeature.Credits].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(100 - deductValue);
|
||||||
|
expect(usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const creditsFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Credits,
|
||||||
|
includedUsage: 200,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [creditsFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-credit-system2";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-credit-system2: track metered features using credit system")}`, () => {
|
||||||
|
const customerId = "track-credit-system2";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 200 credits", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Credits].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from credits for action1 with credit_cost multiplier", async () => {
|
||||||
|
const action1Value = 50.25;
|
||||||
|
const expectedCreditCost = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: action1Value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: action1Value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Credits].balance;
|
||||||
|
const usage = customer.features[TestFeature.Credits].usage;
|
||||||
|
|
||||||
|
expect(balance).toBe(200 - expectedCreditCost);
|
||||||
|
expect(usage).toBe(expectedCreditCost);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from credits for action2 with different credit_cost", async () => {
|
||||||
|
// Get current balance after action1
|
||||||
|
const customerBefore = await autumnV1.customers.get(customerId);
|
||||||
|
const balanceBefore = customerBefore.features[TestFeature.Credits].balance!;
|
||||||
|
|
||||||
|
const action2Value = 33.67;
|
||||||
|
const expectedCreditCost = getCreditCost({
|
||||||
|
featureId: TestFeature.Action2,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: action2Value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action2,
|
||||||
|
value: action2Value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Credits].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(
|
||||||
|
new Decimal(balanceBefore).minus(expectedCreditCost).toNumber(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const action1Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
includedUsage: 100,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const creditsFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Credits,
|
||||||
|
includedUsage: 200,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [action1Feature, creditsFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-credit-system3";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-credit-system3: test deduction order - action1 first, then credits")}`, () => {
|
||||||
|
const customerId = "track-credit-system3";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balances", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(100);
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from action1 first (not credits)", async () => {
|
||||||
|
const deductValue = 40.5;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Action1 should be deducted
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(
|
||||||
|
100 - deductValue,
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Action1].usage).toBe(deductValue);
|
||||||
|
|
||||||
|
// Credits should be untouched
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(200);
|
||||||
|
expect(customer.features[TestFeature.Credits].usage).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should finish action1 balance and dip into credits", async () => {
|
||||||
|
// Current: action1 = 59.5, credits = 200
|
||||||
|
// Deduct 80 -> should take 59.5 from action1, then 20.5 from credits (with credit_cost)
|
||||||
|
const deductValue = 80;
|
||||||
|
const remainingAction1 = 59.5;
|
||||||
|
const overflowAmount = deductValue - remainingAction1;
|
||||||
|
|
||||||
|
const creditCostForOverflow = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: overflowAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Action1 should be fully depleted
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(0);
|
||||||
|
expect(customer.features[TestFeature.Action1].usage).toBe(100);
|
||||||
|
|
||||||
|
// Credits should be deducted by credit_cost * overflow
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
200 - creditCostForOverflow,
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Credits].usage).toBe(
|
||||||
|
creditCostForOverflow,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct only from credits now that action1 is depleted", async () => {
|
||||||
|
const customerBefore = await autumnV1.customers.get(customerId);
|
||||||
|
const creditsBefore = customerBefore.features[TestFeature.Credits].balance;
|
||||||
|
|
||||||
|
const deductValue = 50.75;
|
||||||
|
const creditCost = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Action1,
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Action1 should still be 0
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(0);
|
||||||
|
|
||||||
|
// Credits should be deducted
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
new Decimal(creditsBefore!).minus(creditCost).toNumber(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion, type LimitedItem } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const action1Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
includedUsage: 80,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const creditsFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Credits,
|
||||||
|
includedUsage: 150,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const action3Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Action3,
|
||||||
|
includedUsage: 60,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const credits2Feature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Credits2,
|
||||||
|
includedUsage: 100,
|
||||||
|
}) as LimitedItem;
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [action1Feature, creditsFeature, action3Feature, credits2Feature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-credit-system4";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-credit-system4: test deduction with two credit system pairs")}`, () => {
|
||||||
|
const customerId = "track-credit-system4";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
|
||||||
|
const credit2Feature = ctx.features.find(
|
||||||
|
(f) => f.id === TestFeature.Credits2,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balances for all features", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(80);
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(150);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(60);
|
||||||
|
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct from both action1 and action3 using event_name", async () => {
|
||||||
|
const deductValue = 25.5;
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event",
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Both actions should be deducted
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(
|
||||||
|
new Decimal(80).sub(deductValue).toNumber(),
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Action1].usage).toBe(deductValue);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(
|
||||||
|
new Decimal(60).sub(deductValue).toNumber(),
|
||||||
|
);
|
||||||
|
expect(customer.features[TestFeature.Action3].usage).toBe(deductValue);
|
||||||
|
|
||||||
|
// Credits untouched (actions had enough balance)
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(150);
|
||||||
|
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should finish action1 and action3, then dip into both credit systems", async () => {
|
||||||
|
// Get current state after previous test
|
||||||
|
const customerBefore = await autumnV1.customers.get(customerId);
|
||||||
|
const remainingAction1 =
|
||||||
|
customerBefore.features[TestFeature.Action1].balance!;
|
||||||
|
const remainingAction3 =
|
||||||
|
customerBefore.features[TestFeature.Action3].balance!;
|
||||||
|
const creditsBefore = customerBefore.features[TestFeature.Credits].balance!;
|
||||||
|
const credits2Before =
|
||||||
|
customerBefore.features[TestFeature.Credits2].balance!;
|
||||||
|
|
||||||
|
// Deduct 70 -> should finish both actions, then use credits
|
||||||
|
const deductValue = 70;
|
||||||
|
const overflowAction1 = deductValue - remainingAction1;
|
||||||
|
const overflowAction3 = deductValue - remainingAction3;
|
||||||
|
|
||||||
|
const creditCostAction1 = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: overflowAction1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const creditCostAction3 = getCreditCost({
|
||||||
|
featureId: TestFeature.Action3,
|
||||||
|
creditSystem: credit2Feature!,
|
||||||
|
amount: overflowAction3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event",
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Both actions should be fully depleted
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(0);
|
||||||
|
expect(customer.features[TestFeature.Action1].usage).toBe(80);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(0);
|
||||||
|
expect(customer.features[TestFeature.Action3].usage).toBe(60);
|
||||||
|
|
||||||
|
// Both credit systems should be deducted
|
||||||
|
const expectedCredits = new Decimal(creditsBefore)
|
||||||
|
.sub(creditCostAction1)
|
||||||
|
.toNumber();
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
expectedCredits,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedCredits2 = new Decimal(credits2Before)
|
||||||
|
.sub(creditCostAction3)
|
||||||
|
.toNumber();
|
||||||
|
expect(customer.features[TestFeature.Credits2].balance).toBe(
|
||||||
|
expectedCredits2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct only from credit systems after actions depleted", async () => {
|
||||||
|
const customerBefore = await autumnV1.customers.get(customerId);
|
||||||
|
const creditsBefore = customerBefore.features[TestFeature.Credits].balance;
|
||||||
|
const credits2Before =
|
||||||
|
customerBefore.features[TestFeature.Credits2].balance;
|
||||||
|
|
||||||
|
const deductValue = 40.25;
|
||||||
|
|
||||||
|
const creditCostAction1 = getCreditCost({
|
||||||
|
featureId: TestFeature.Action1,
|
||||||
|
creditSystem: creditFeature!,
|
||||||
|
amount: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const creditCostAction3 = getCreditCost({
|
||||||
|
featureId: TestFeature.Action3,
|
||||||
|
creditSystem: credit2Feature!,
|
||||||
|
amount: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
event_name: "action-event",
|
||||||
|
value: deductValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
|
||||||
|
// Actions should still be 0
|
||||||
|
expect(customer.features[TestFeature.Action1].balance).toBe(0);
|
||||||
|
expect(customer.features[TestFeature.Action3].balance).toBe(0);
|
||||||
|
|
||||||
|
// Credits has enough balance
|
||||||
|
expect(customer.features[TestFeature.Credits].balance).toBe(
|
||||||
|
new Decimal(creditsBefore!).sub(creditCostAction1).toNumber(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Credits2 doesn't have enough balance, so it caps at 0 (no usage_allowed)
|
||||||
|
const expectedCredits2 = new Decimal(credits2Before!)
|
||||||
|
.sub(creditCostAction3)
|
||||||
|
.toNumber();
|
||||||
|
expect(customer.features[TestFeature.Credits2].balance).toBe(
|
||||||
|
Math.max(0, expectedCredits2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
server/tests/balances/track/legacy/track-legacy1.test.ts
Normal file
80
server/tests/balances/track/legacy/track-legacy1.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { ApiVersion } from "@autumn/shared";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { Decimal } from "decimal.js";
|
||||||
|
import { TestFeature } from "tests/setup/v2Features.js";
|
||||||
|
import ctx from "tests/utils/testInitUtils/createTestContext.js";
|
||||||
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
|
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||||
|
|
||||||
|
const messagesFeature = constructFeatureItem({
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
includedUsage: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeProd = constructProduct({
|
||||||
|
type: "free",
|
||||||
|
isDefault: false,
|
||||||
|
items: [messagesFeature],
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCase = "track-legacy1";
|
||||||
|
|
||||||
|
describe(`${chalk.yellowBright("track-legacy1: test legacy properties format with value")}`, () => {
|
||||||
|
const customerId = "track-legacy1";
|
||||||
|
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initCustomerV3({
|
||||||
|
ctx,
|
||||||
|
customerId,
|
||||||
|
withTestClock: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initProductsV0({
|
||||||
|
ctx,
|
||||||
|
products: [freeProd],
|
||||||
|
prefix: testCase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await autumnV1.attach({
|
||||||
|
customer_id: customerId,
|
||||||
|
product_id: freeProd.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should have initial balance of 100", async () => {
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
|
||||||
|
expect(balance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduct value from properties object", async () => {
|
||||||
|
const initialBalance = 100;
|
||||||
|
const deductValue = 35.82;
|
||||||
|
|
||||||
|
// Legacy format: properties.value instead of value
|
||||||
|
await autumnV1.track({
|
||||||
|
customer_id: customerId,
|
||||||
|
feature_id: TestFeature.Messages,
|
||||||
|
properties: {
|
||||||
|
value: deductValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await autumnV1.customers.get(customerId);
|
||||||
|
const balance = customer.features[TestFeature.Messages].balance;
|
||||||
|
const usage = customer.features[TestFeature.Messages].usage;
|
||||||
|
|
||||||
|
const expectedBalance = new Decimal(initialBalance)
|
||||||
|
.sub(deductValue)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
expect(balance).toBe(expectedBalance);
|
||||||
|
expect(usage).toBe(deductValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { ApiVersion, ProductItemFeatureType, type Organization } from "@autumn/shared";
|
|
||||||
import type { AppEnv, Autumn } from "autumn-js";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import type { Stripe } from "stripe";
|
|
||||||
import { setupBefore } from "tests/before.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 { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
|
|
||||||
import { TestFeature } from "tests/setup/v2Features.js";
|
|
||||||
|
|
||||||
const testCase = "trackMisc1";
|
|
||||||
const customerId = `${testCase}_cus1`;
|
|
||||||
|
|
||||||
const pro = constructProduct({
|
|
||||||
id: "pro",
|
|
||||||
items: [constructFeatureItem({ featureId: TestFeature.Messages, includedUsage: 5, featureType: ProductItemFeatureType.SingleUse })],
|
|
||||||
type: "pro",
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track consumable usage`)}`, () => {
|
|
||||||
let db: DrizzleCli;
|
|
||||||
let org: Organization;
|
|
||||||
let env: AppEnv;
|
|
||||||
let stripeCli: Stripe;
|
|
||||||
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
|
||||||
let autumnJs: Autumn;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(customerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [pro],
|
|
||||||
prefix: testCase,
|
|
||||||
})
|
|
||||||
|
|
||||||
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",
|
|
||||||
})
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow all requests to pass and cap balance at 0", async () => {
|
|
||||||
const customer = await autumnInt.customers.get(customerId);
|
|
||||||
const balance = customer.features[TestFeature.Messages].balance;
|
|
||||||
expect(balance).to.equal(5, `Balance should be 5, got ${balance} | Balances: ${JSON.stringify(customer.features)}`);
|
|
||||||
|
|
||||||
const promises = [
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 10,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 10,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 10,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 10,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 10,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
let results = await Promise.allSettled(promises);
|
|
||||||
|
|
||||||
// With free feature capping, all requests pass (cap at 0 instead of rejecting)
|
|
||||||
expect(results.every(r => r.status === "fulfilled")).to.equal(true, `${results.map(r => r.status).join(", ")} <- all should pass`);
|
|
||||||
|
|
||||||
const { data: balances, error } = await autumnJs.customers.get(
|
|
||||||
customerId,
|
|
||||||
);
|
|
||||||
expect(error).to.be.null;
|
|
||||||
expect(balances?.features[TestFeature.Messages]?.balance).to.equal(
|
|
||||||
0,
|
|
||||||
`Balance should cap at 0, got ${balances?.features[TestFeature.Messages]?.balance}`,
|
|
||||||
);
|
|
||||||
expect(balances?.features[TestFeature.Messages]?.usage).to.equal(
|
|
||||||
5,
|
|
||||||
`Usage should be 5 (only what was available), got ${balances?.features[TestFeature.Messages]?.usage}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
|
||||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
|
||||||
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.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",
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
|
||||||
let autumnJs: Autumn;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(customerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [pro],
|
|
||||||
prefix: testCase,
|
|
||||||
});
|
|
||||||
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const promises = [
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
|
||||||
|
|
||||||
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(error).to.be.null;
|
|
||||||
expect(balances?.features[TestFeature.Users]?.balance).to.equal(
|
|
||||||
0,
|
|
||||||
`Balance should be 0 (allocated feature), got ${balances?.features[TestFeature.Users]?.balance}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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 { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
|
||||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
|
||||||
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
|
|
||||||
|
|
||||||
const testCase = "trackMisc3";
|
|
||||||
const customerId = `${testCase}_cus1`;
|
|
||||||
|
|
||||||
const userItem = constructFeatureItem({
|
|
||||||
featureId: TestFeature.Users,
|
|
||||||
includedUsage: 1,
|
|
||||||
featureType: ProductItemFeatureType.ContinuousUse,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pro = constructProduct({
|
|
||||||
id: "pro",
|
|
||||||
items: [userItem],
|
|
||||||
type: "pro",
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing trackMisc track prepaid allocated feature with concurrent requests`)}`, () => {
|
|
||||||
let db: DrizzleCli;
|
|
||||||
let org: Organization;
|
|
||||||
let env: AppEnv;
|
|
||||||
let stripeCli: Stripe;
|
|
||||||
const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
|
||||||
let autumnJs: Autumn;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(customerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [pro],
|
|
||||||
prefix: testCase,
|
|
||||||
});
|
|
||||||
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only allow one concurrent seat allocation with 1 included seat and create no duplicate invoices", 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)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialInvoices = await stripeCli.invoices.list({
|
|
||||||
customer: customer.stripe_id as string,
|
|
||||||
});
|
|
||||||
const initialInvoiceCount = initialInvoices.data.length;
|
|
||||||
|
|
||||||
// Try to allocate 5 different seats concurrently - only 1 should succeed (the included seat)
|
|
||||||
// The other 4 should be rejected because we only have 1 included seat
|
|
||||||
const promises = [
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
value: 1,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
|
||||||
|
|
||||||
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 (included seat), got ${successCount} | Results: ${results.map((r) => r.status).join(", ")}`,
|
|
||||||
);
|
|
||||||
expect(rejectedCount).to.equal(
|
|
||||||
4,
|
|
||||||
`Expected exactly 4 rejections (exceeded included), 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,
|
|
||||||
`Balance should be 0 (allocated feature), got ${balances?.features[TestFeature.Users]?.balance}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify no duplicate invoices were created
|
|
||||||
// Since we only allocated the 1 included seat, no overage charges should occur
|
|
||||||
const finalInvoices = await stripeCli.invoices.list({
|
|
||||||
customer: customer.stripe_id as string,
|
|
||||||
});
|
|
||||||
const finalInvoiceCount = finalInvoices.data.length;
|
|
||||||
const newInvoicesCreated = finalInvoiceCount - initialInvoiceCount;
|
|
||||||
|
|
||||||
expect(newInvoicesCreated).to.equal(
|
|
||||||
0,
|
|
||||||
`Expected 0 new invoices (only used included seat), got ${newInvoicesCreated}. Initial: ${initialInvoiceCount}, Final: ${finalInvoiceCount}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { ApiVersion, type Organization } 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 { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
|
||||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
|
||||||
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
|
|
||||||
|
|
||||||
const testCase = "trackMisc4";
|
|
||||||
const customerId = `${testCase}_cus1`;
|
|
||||||
|
|
||||||
const messageItem = constructArrearItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
includedUsage: 5,
|
|
||||||
price: 0.1,
|
|
||||||
billingUnits: 1,
|
|
||||||
usageLimit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pro = constructProduct({
|
|
||||||
id: "pro",
|
|
||||||
items: [messageItem],
|
|
||||||
type: "pro",
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing usage_limits with PayPerUse feature and concurrent requests`)}`, () => {
|
|
||||||
let db: DrizzleCli;
|
|
||||||
let org: Organization;
|
|
||||||
let env: AppEnv;
|
|
||||||
let stripeCli: Stripe;
|
|
||||||
const autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
|
||||||
let autumnJs: Autumn;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(customerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [pro],
|
|
||||||
prefix: testCase,
|
|
||||||
});
|
|
||||||
|
|
||||||
await createProducts({
|
|
||||||
autumn: autumnInt,
|
|
||||||
products: [pro],
|
|
||||||
customerId,
|
|
||||||
db,
|
|
||||||
orgId: org.id,
|
|
||||||
env,
|
|
||||||
}).catch((_) => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create a customer and issue balances", async () => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enforce usage_limit with concurrent requests", async () => {
|
|
||||||
const customer = await autumnInt.customers.get(customerId);
|
|
||||||
console.log("customer", customer);
|
|
||||||
const balance = customer.features[TestFeature.Messages].balance;
|
|
||||||
const usageLimit = customer.features[TestFeature.Messages].usage_limit;
|
|
||||||
|
|
||||||
expect(balance).to.equal(5, `Balance should be 5, got ${balance}`);
|
|
||||||
expect(usageLimit).to.equal(
|
|
||||||
10,
|
|
||||||
`Usage limit should be 10, got ${usageLimit}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"🚀 Starting 5 concurrent track calls (3 units each) at exact same time...",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
` Initial state: balance=${balance}, usage_limit=${usageLimit} (max total usage in billing cycle)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try to use 3 units concurrently - with usage_limit of 10, only 3 requests can succeed (3x3=9 <= 10)
|
|
||||||
const promises = [
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 3,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 3,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 3,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 3,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 3,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
|
||||||
|
|
||||||
console.log("📊 Results breakdown:");
|
|
||||||
results.forEach((result, index) => {
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
console.log(
|
|
||||||
` [${index}] ❌ REJECTED:`,
|
|
||||||
result.reason?.message || result.reason,
|
|
||||||
);
|
|
||||||
console.log(` Error code:`, result.reason?.code);
|
|
||||||
console.log(` Status code:`, result.reason?.statusCode);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
` [${index}] ✅ FULFILLED (HTTP 200):`,
|
|
||||||
JSON.stringify(result.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
|
||||||
const rejectedCount = results.filter((r) => r.status === "rejected").length;
|
|
||||||
console.log(
|
|
||||||
`\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(successCount).to.equal(3, `Expected exactly 3 HTTP 200 responses, got ${successCount}`);
|
|
||||||
expect(rejectedCount).to.equal(2, `Expected exactly 2 HTTP errors (usage_limit exceeded), got ${rejectedCount}`);
|
|
||||||
|
|
||||||
// Wait for any async processing to complete
|
|
||||||
console.log(`⏳ Waiting 3s for all updates to persist...`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
const { data: balances, error } = await autumnJs.customers.get(customerId);
|
|
||||||
|
|
||||||
console.log(`📦 Final state after all requests:`);
|
|
||||||
console.log(
|
|
||||||
`- Balance: ${balances?.features[TestFeature.Messages]?.balance} (expected: -4)`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`- Usage: ${balances?.features[TestFeature.Messages]?.usage} (expected: 9)`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`- Usage limit: ${balances?.features[TestFeature.Messages]?.usage_limit} (expected: 10)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(balances?.features[TestFeature.Messages]?.balance).to.equal(
|
|
||||||
-4,
|
|
||||||
`Balance should be -4 (5 included - 9 used), got ${balances?.features[TestFeature.Messages]?.balance}`,
|
|
||||||
);
|
|
||||||
expect(balances?.features[TestFeature.Messages]?.usage).to.equal(
|
|
||||||
9,
|
|
||||||
`Usage should be 9, got ${balances?.features[TestFeature.Messages]?.usage}`,
|
|
||||||
);
|
|
||||||
expect(balances?.features[TestFeature.Messages]?.usage_limit).to.equal(
|
|
||||||
10,
|
|
||||||
`Usage limit should remain 10, got ${balances?.features[TestFeature.Messages]?.usage_limit}`,
|
|
||||||
);
|
|
||||||
// With usage_limit of 10, only 3 requests of value 3 can succeed (9 total)
|
|
||||||
// The 4th request would bring total to 12, exceeding the usage_limit
|
|
||||||
// expect(successCount).to.equal(3, `Expected exactly 3 successes (3x3=9 <= usage_limit of 10), got ${successCount} | Results: ${results.map(r => r.status).join(", ")}`);
|
|
||||||
// expect(rejectedCount).to.equal(2, `Expected exactly 2 rejections, got ${rejectedCount} | Results: ${results.map(r => r.status).join(", ")}`);
|
|
||||||
|
|
||||||
// expect(error).to.be.null;
|
|
||||||
|
|
||||||
// Balance consumed from included: min(9, 5) = 5, so balance = 0
|
|
||||||
// The remaining 4 units (9 - 5) are overages charged via PayPerUse
|
|
||||||
// expect(balances?.features[TestFeature.Messages]?.balance).to.equal(
|
|
||||||
// 0,
|
|
||||||
// `Balance should be 0 (all 5 included used), got ${balances?.features[TestFeature.Messages]?.balance}`,
|
|
||||||
// );
|
|
||||||
// expect(balances?.features[TestFeature.Messages]?.usage_limit).to.equal(
|
|
||||||
// 10,
|
|
||||||
// `Usage limit should remain 10, got ${balances?.features[TestFeature.Messages]?.usage_limit}`,
|
|
||||||
// );
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { ApiVersion, ProductItemFeatureType, type Organization } from "@autumn/shared";
|
|
||||||
import type { AppEnv, Autumn } from "autumn-js";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import type { Stripe } from "stripe";
|
|
||||||
import { setupBefore } from "tests/before.js";
|
|
||||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
|
||||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
|
||||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
|
||||||
import { constructArrearItem, constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
|
||||||
import { addPrefixToProducts } from "tests/attach/utils.js";
|
|
||||||
import { createProducts } from "tests/utils/productUtils.js";
|
|
||||||
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
|
|
||||||
import { TestFeature } from "tests/setup/v2Features.js";
|
|
||||||
|
|
||||||
const testCase = "trackMisc5";
|
|
||||||
const customerId = `${testCase}_cus1`;
|
|
||||||
|
|
||||||
const seatItem = constructFeatureItem({
|
|
||||||
featureId: TestFeature.Users,
|
|
||||||
includedUsage: 5,
|
|
||||||
featureType: ProductItemFeatureType.ContinuousUse,
|
|
||||||
});
|
|
||||||
|
|
||||||
const perSeatMessagesItem = constructArrearItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
entityFeatureId: TestFeature.Users,
|
|
||||||
price: 0.01,
|
|
||||||
includedUsage: 500,
|
|
||||||
usageLimit: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pro = constructProduct({
|
|
||||||
id: "pro",
|
|
||||||
items: [seatItem, perSeatMessagesItem],
|
|
||||||
type: "pro",
|
|
||||||
})
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing per-entity trackMisc track 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;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(customerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [pro],
|
|
||||||
prefix: testCase,
|
|
||||||
})
|
|
||||||
|
|
||||||
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",
|
|
||||||
})
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create 5 seats each with 500 messages", async () => {
|
|
||||||
const customer = await autumnInt.customers.get(customerId);
|
|
||||||
const seatBalance = customer.features[TestFeature.Users].balance;
|
|
||||||
expect(seatBalance).to.equal(5, `Seat balance should be 5, got ${seatBalance}`);
|
|
||||||
|
|
||||||
// Create 5 entities (seats)
|
|
||||||
const entities = [
|
|
||||||
{ id: "seat1", name: "Seat 1" },
|
|
||||||
{ id: "seat2", name: "Seat 2" },
|
|
||||||
{ id: "seat3", name: "Seat 3" },
|
|
||||||
{ id: "seat4", name: "Seat 4" },
|
|
||||||
{ id: "seat5", name: "Seat 5" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entity of entities) {
|
|
||||||
await autumnInt.entities.create(customerId, {
|
|
||||||
id: entity.id,
|
|
||||||
name: entity.name,
|
|
||||||
feature_id: TestFeature.Users,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify each seat has 500 messages
|
|
||||||
const updatedEntity = await autumnInt.entities.get(customerId, entities[0].id);
|
|
||||||
console.log(JSON.stringify(updatedEntity, null, 4));
|
|
||||||
expect(updatedEntity.features[TestFeature.Messages].balance).to.equal(500, JSON.stringify(updatedEntity, null, 4));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should enforce usage_limit of 600 per seat with concurrent 200-unit requests", async () => {
|
|
||||||
const entityId = "seat1";
|
|
||||||
|
|
||||||
// Verify seat1 has 500 included messages with 600 usage_limit
|
|
||||||
const entityRes = await autumnInt.entities.get(customerId, entityId);
|
|
||||||
expect(entityRes.features[TestFeature.Messages].balance).to.equal(500);
|
|
||||||
// expect(entityRes.features[TestFeature.Messages].usage_limit).to.equal(600);
|
|
||||||
|
|
||||||
console.log("🚀 Starting 5 concurrent track calls (200 units each) for seat1...");
|
|
||||||
console.log(` Initial state: balance=${entityRes.features[TestFeature.Messages].balance}, usage_limit=${entityRes.features[TestFeature.Messages].usage_limit}`);
|
|
||||||
|
|
||||||
// Try 5 concurrent 200-unit sends to seat1
|
|
||||||
// With usage_limit of 600, only 3 should succeed (3×200=600 <= 600)
|
|
||||||
const promises = [
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
entity_id: entityId,
|
|
||||||
value: 200,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
entity_id: entityId,
|
|
||||||
value: 200,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
entity_id: entityId,
|
|
||||||
value: 200,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
entity_id: entityId,
|
|
||||||
value: 200,
|
|
||||||
}),
|
|
||||||
autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
entity_id: entityId,
|
|
||||||
value: 200,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
let results = await Promise.allSettled(promises);
|
|
||||||
|
|
||||||
console.log("📊 Results breakdown:");
|
|
||||||
results.forEach((result, index) => {
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
console.log(` [${index}] ❌ REJECTED:`, result.reason?.message || result.reason);
|
|
||||||
console.log(` Error code:`, result.reason?.code);
|
|
||||||
} else {
|
|
||||||
console.log(` [${index}] ✅ FULFILLED (HTTP 200):`, JSON.stringify(result.value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const successCount = results.filter(r => r.status === "fulfilled").length;
|
|
||||||
const rejectedCount = results.filter(r => r.status === "rejected").length;
|
|
||||||
console.log(`\n📈 Summary: ${successCount} HTTP 200 responses, ${rejectedCount} HTTP errors`);
|
|
||||||
|
|
||||||
expect(successCount).to.equal(3, `Expected exactly 3 HTTP 200 responses, got ${successCount}`);
|
|
||||||
expect(rejectedCount).to.equal(2, `Expected exactly 2 HTTP errors (usage_limit exceeded), got ${rejectedCount}`);
|
|
||||||
|
|
||||||
// Get final state
|
|
||||||
const finalEntityRes = await autumnInt.entities.get(customerId, entityId);
|
|
||||||
console.log(`\n📦 Final state for ${entityId}:`);
|
|
||||||
console.log(`- Balance: ${finalEntityRes.features[TestFeature.Messages].balance} (expected: -100)`);
|
|
||||||
console.log(`- Usage: ${finalEntityRes.features[TestFeature.Messages].usage} (expected: 600)`);
|
|
||||||
console.log(`- Usage limit: ${finalEntityRes.features[TestFeature.Messages].usage_limit} (expected: 600)`);
|
|
||||||
|
|
||||||
expect(finalEntityRes.features[TestFeature.Messages].balance).to.equal(
|
|
||||||
-100,
|
|
||||||
`Balance should be -100 (500 included - 600 used), got ${finalEntityRes.features[TestFeature.Messages].balance}`,
|
|
||||||
);
|
|
||||||
expect(finalEntityRes.features[TestFeature.Messages].usage).to.equal(
|
|
||||||
600,
|
|
||||||
`Usage should be 600, got ${finalEntityRes.features[TestFeature.Messages].usage}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify other seats remain untouched at 500
|
|
||||||
for (const seatId of ["seat2", "seat3", "seat4", "seat5"]) {
|
|
||||||
const otherSeatRes = await autumnInt.entities.get(customerId, seatId);
|
|
||||||
expect(otherSeatRes.features[TestFeature.Messages].balance).to.equal(
|
|
||||||
500,
|
|
||||||
`${seatId} should still have 500 messages, got ${otherSeatRes.features[TestFeature.Messages].balance}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import { ApiVersion, type Organization } from "@autumn/shared";
|
|
||||||
import type { AppEnv, Autumn } from "autumn-js";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import type { Stripe } from "stripe";
|
|
||||||
import { setupBefore } from "tests/before.js";
|
|
||||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
|
||||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
|
||||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
|
||||||
import { constructArrearItem, constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
|
||||||
import { addPrefixToProducts } from "tests/attach/utils.js";
|
|
||||||
import { createProducts } from "tests/utils/productUtils.js";
|
|
||||||
import { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
|
|
||||||
import { TestFeature } from "tests/setup/v2Features.js";
|
|
||||||
|
|
||||||
const testCase = "trackMisc6";
|
|
||||||
const prepaidCustomerId = `${testCase}_prepaid_cus`;
|
|
||||||
const payPerUseCustomerId = `${testCase}_payperuse_cus`;
|
|
||||||
|
|
||||||
// Prepaid feature: 5 included, no overage allowed
|
|
||||||
const prepaidItem = constructFeatureItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
includedUsage: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
// PayPerUse feature: 5 included, overage allowed at $0.01 per unit, usage_limit of 10
|
|
||||||
const payPerUseItem = constructArrearItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
includedUsage: 5,
|
|
||||||
price: 0.01,
|
|
||||||
billingUnits: 1,
|
|
||||||
usageLimit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prepaidProduct = constructProduct({
|
|
||||||
id: "prepaid",
|
|
||||||
items: [prepaidItem],
|
|
||||||
type: "pro",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payPerUseProduct = constructProduct({
|
|
||||||
id: "payperuse",
|
|
||||||
items: [payPerUseItem],
|
|
||||||
type: "pro",
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing prepaid vs PayPerUse overage behavior`)}`, () => {
|
|
||||||
let db: DrizzleCli;
|
|
||||||
let org: Organization;
|
|
||||||
let env: AppEnv;
|
|
||||||
let stripeCli: Stripe;
|
|
||||||
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
|
||||||
let autumnJs: Autumn;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
|
|
||||||
// Delete both customers
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(prepaidCustomerId);
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await (autumnInt as AutumnInt).customers.delete(payPerUseCustomerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [prepaidProduct, payPerUseProduct],
|
|
||||||
prefix: testCase,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create products for prepaid customer
|
|
||||||
await createProducts({
|
|
||||||
autumn: autumnInt,
|
|
||||||
products: [prepaidProduct],
|
|
||||||
customerId: prepaidCustomerId,
|
|
||||||
db,
|
|
||||||
orgId: org.id,
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create products for pay-per-use customer
|
|
||||||
await createProducts({
|
|
||||||
autumn: autumnInt,
|
|
||||||
products: [payPerUseProduct],
|
|
||||||
customerId: payPerUseCustomerId,
|
|
||||||
db,
|
|
||||||
orgId: org.id,
|
|
||||||
env,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create prepaid customer and attach product", async () => {
|
|
||||||
const { customer } = await initCustomerV2({
|
|
||||||
autumn: autumnInt,
|
|
||||||
customerId: prepaidCustomerId,
|
|
||||||
org,
|
|
||||||
env,
|
|
||||||
db,
|
|
||||||
attachPm: "success",
|
|
||||||
})
|
|
||||||
expect(customer).to.exist;
|
|
||||||
expect(customer.id).to.equal(prepaidCustomerId);
|
|
||||||
|
|
||||||
await autumnJs.attach({
|
|
||||||
customer_id: prepaidCustomerId,
|
|
||||||
product_id: prepaidProduct.id,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create pay-per-use customer and attach product", async () => {
|
|
||||||
const { customer } = await initCustomerV2({
|
|
||||||
autumn: autumnInt,
|
|
||||||
customerId: payPerUseCustomerId,
|
|
||||||
org,
|
|
||||||
env,
|
|
||||||
db,
|
|
||||||
attachPm: "success",
|
|
||||||
})
|
|
||||||
expect(customer).to.exist;
|
|
||||||
expect(customer.id).to.equal(payPerUseCustomerId);
|
|
||||||
|
|
||||||
await autumnJs.attach({
|
|
||||||
customer_id: payPerUseCustomerId,
|
|
||||||
product_id: payPerUseProduct.id,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject tracking 7 units when prepaid balance is 5 (no overage)", async () => {
|
|
||||||
const customer = await autumnInt.customers.get(prepaidCustomerId);
|
|
||||||
const balance = customer.features[TestFeature.Messages].balance;
|
|
||||||
expect(balance).to.equal(5, `Balance should be 5, got ${balance}`);
|
|
||||||
|
|
||||||
console.log("🚀 Tracking 7 units with prepaid balance of 5 (no overage allowed)...");
|
|
||||||
|
|
||||||
let error: any = null;
|
|
||||||
try {
|
|
||||||
await autumnInt.track({
|
|
||||||
customer_id: prepaidCustomerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 7,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).to.exist;
|
|
||||||
expect(error.message).to.include("Insufficient balance");
|
|
||||||
expect(error.message).to.include("Available: 5");
|
|
||||||
expect(error.message).to.include("Required: 7");
|
|
||||||
|
|
||||||
console.log("❌ Request rejected:", error.message);
|
|
||||||
|
|
||||||
// Verify balance remains unchanged
|
|
||||||
const finalCustomer = await autumnInt.customers.get(prepaidCustomerId);
|
|
||||||
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
|
|
||||||
|
|
||||||
console.log(`📦 Final balance: ${finalBalance} (expected: 5)`);
|
|
||||||
expect(finalBalance).to.equal(5, `Balance should remain 5, got ${finalBalance}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow tracking 7 units when PayPerUse balance is 5 (overage allowed)", async () => {
|
|
||||||
const customer = await autumnInt.customers.get(payPerUseCustomerId);
|
|
||||||
const balance = customer.features[TestFeature.Messages].balance;
|
|
||||||
expect(balance).to.equal(5, `Balance should be 5, got ${balance}`);
|
|
||||||
|
|
||||||
console.log("📊 Customer feature details:", JSON.stringify(customer.features[TestFeature.Messages], null, 2));
|
|
||||||
|
|
||||||
// Get initial invoice count
|
|
||||||
const initialInvoices = await stripeCli.invoices.list({
|
|
||||||
customer: customer.stripe_id as string,
|
|
||||||
});
|
|
||||||
const initialInvoiceCount = initialInvoices.data.length;
|
|
||||||
|
|
||||||
console.log("🚀 Tracking 7 units with PayPerUse balance of 5 (overage allowed)...");
|
|
||||||
|
|
||||||
let error: any = null;
|
|
||||||
let response: any = null;
|
|
||||||
try {
|
|
||||||
response = await autumnInt.track({
|
|
||||||
customer_id: payPerUseCustomerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 7,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).to.be.null;
|
|
||||||
expect(response).to.exist;
|
|
||||||
console.log("✅ Request succeeded:", JSON.stringify(response));
|
|
||||||
|
|
||||||
// Wait for processing (even though it should be synchronous with the PR changes)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
// Verify balance went negative (overage)
|
|
||||||
const finalCustomer = await autumnInt.customers.get(payPerUseCustomerId);
|
|
||||||
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
|
|
||||||
const finalUsage = finalCustomer.features[TestFeature.Messages].usage;
|
|
||||||
|
|
||||||
console.log(`📦 Final balance: ${finalBalance} (expected: -2)`);
|
|
||||||
console.log(`📦 Final usage: ${finalUsage} (expected: 7)`);
|
|
||||||
|
|
||||||
expect(finalBalance).to.equal(-2, `Balance should be -2 (5 included - 7 used), got ${finalBalance}`);
|
|
||||||
expect(finalUsage).to.equal(7, `Usage should be 7, got ${finalUsage}`);
|
|
||||||
|
|
||||||
// Note: Invoices may be created async or on billing cycle
|
|
||||||
// For now, we just log the invoice count
|
|
||||||
const finalInvoices = await stripeCli.invoices.list({
|
|
||||||
customer: customer.stripe_id as string,
|
|
||||||
});
|
|
||||||
const finalInvoiceCount = finalInvoices.data.length;
|
|
||||||
const newInvoicesCreated = finalInvoiceCount - initialInvoiceCount;
|
|
||||||
|
|
||||||
console.log(`💳 Invoices: ${newInvoicesCreated} new invoice(s) created (may be 0 if invoiced later)`);
|
|
||||||
|
|
||||||
if (newInvoicesCreated > 0) {
|
|
||||||
const latestInvoice = finalInvoices.data[0];
|
|
||||||
console.log(` Invoice total: $${(latestInvoice.total / 100).toFixed(2)} (expected: 2 units × $0.01 = $0.02)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { AllowanceType, ApiVersion, Infinite, type Organization } from "@autumn/shared";
|
|
||||||
import type { AppEnv, Autumn } from "autumn-js";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import type { Stripe } from "stripe";
|
|
||||||
import { setupBefore } from "tests/before.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 { initCustomerV2 } from "@/utils/scriptUtils/initCustomer.js";
|
|
||||||
import { TestFeature } from "tests/setup/v2Features.js";
|
|
||||||
|
|
||||||
const testCase = "trackMisc7";
|
|
||||||
const customerId = `${testCase}_cus1`;
|
|
||||||
|
|
||||||
// Free feature (included only, no price) - should cap at 0
|
|
||||||
const freeItem = constructFeatureItem({
|
|
||||||
featureId: TestFeature.Messages,
|
|
||||||
includedUsage: 50,
|
|
||||||
});
|
|
||||||
const pro = constructProduct({
|
|
||||||
id: "pro",
|
|
||||||
items: [freeItem],
|
|
||||||
type: "pro",
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(`${chalk.yellowBright(`trackMisc/${testCase}: Testing free balance capping`)}`, () => {
|
|
||||||
let db: DrizzleCli;
|
|
||||||
let org: Organization;
|
|
||||||
let env: AppEnv;
|
|
||||||
let stripeCli: Stripe;
|
|
||||||
let autumnInt: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
|
||||||
let autumnJs: Autumn;
|
|
||||||
|
|
||||||
before(async function () {
|
|
||||||
await setupBefore(this);
|
|
||||||
db = this.db;
|
|
||||||
org = this.org;
|
|
||||||
env = this.env;
|
|
||||||
stripeCli = this.stripeCli;
|
|
||||||
autumnJs = this.autumnJs;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await autumnInt.customers.delete(customerId);
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
await addPrefixToProducts({
|
|
||||||
products: [pro],
|
|
||||||
prefix: testCase,
|
|
||||||
});
|
|
||||||
|
|
||||||
await createProducts({
|
|
||||||
autumn: autumnInt,
|
|
||||||
products: [pro],
|
|
||||||
customerId,
|
|
||||||
db,
|
|
||||||
orgId: org.id,
|
|
||||||
env,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create customer and attach product", async () => {
|
|
||||||
const { customer } = await initCustomerV2({
|
|
||||||
autumn: autumnInt,
|
|
||||||
customerId,
|
|
||||||
org,
|
|
||||||
env,
|
|
||||||
db,
|
|
||||||
attachPm: "success",
|
|
||||||
});
|
|
||||||
await autumnJs.attach({
|
|
||||||
customer_id: customerId,
|
|
||||||
product_id: pro.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(customer).to.exist;
|
|
||||||
expect(customer.id).to.equal(customerId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cap free balance at 0 when tracking more than available", async () => {
|
|
||||||
const customer = await autumnInt.customers.get(customerId);
|
|
||||||
const initialBalance = customer.features[TestFeature.Messages].balance;
|
|
||||||
expect(initialBalance).to.equal(50, `Initial balance should be 50, got ${initialBalance}`);
|
|
||||||
|
|
||||||
console.log(`🚀 Tracking 60 units with free balance of 50 (should cap at 0)...`);
|
|
||||||
|
|
||||||
await autumnInt.track({
|
|
||||||
customer_id: customerId,
|
|
||||||
feature_id: TestFeature.Messages,
|
|
||||||
value: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalCustomer = await autumnInt.customers.get(customerId);
|
|
||||||
const finalBalance = finalCustomer.features[TestFeature.Messages].balance;
|
|
||||||
const finalUsage = finalCustomer.features[TestFeature.Messages].usage;
|
|
||||||
|
|
||||||
console.log(`📦 Final state: balance=${finalBalance}, usage=${finalUsage}`);
|
|
||||||
|
|
||||||
expect(finalBalance).to.equal(0, `Balance should cap at 0, got ${finalBalance}`);
|
|
||||||
expect(finalUsage).to.equal(50, `Usage should be 50, got ${finalUsage}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
5
server/tests/balances/track/trackTestUtils.ts
Normal file
5
server/tests/balances/track/trackTestUtils.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SuccessCode, type TrackResponse } from "@autumn/shared";
|
||||||
|
|
||||||
|
export const trackWasSuccessful = ({ res }: { res: TrackResponse }) => {
|
||||||
|
return res.code === SuccessCode.EventReceived;
|
||||||
|
};
|
||||||
@@ -20,6 +20,9 @@ export enum TestFeature {
|
|||||||
Action1 = "action1", // single use (pay per use)
|
Action1 = "action1", // single use (pay per use)
|
||||||
Action2 = "action2", // single use (pay per use)
|
Action2 = "action2", // single use (pay per use)
|
||||||
Credits = "credits", // credit system
|
Credits = "credits", // credit system
|
||||||
|
|
||||||
|
Action3 = "action3", // single use (pay per use)
|
||||||
|
Credits2 = "credits2", // credit system
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = process.env.TESTS_ORG_ID!;
|
const orgId = process.env.TESTS_ORG_ID!;
|
||||||
@@ -64,12 +67,21 @@ export const features = {
|
|||||||
orgId,
|
orgId,
|
||||||
env: AppEnv.Sandbox,
|
env: AppEnv.Sandbox,
|
||||||
usageType: FeatureUsageType.Single,
|
usageType: FeatureUsageType.Single,
|
||||||
|
eventNames: ["action-event"],
|
||||||
}),
|
}),
|
||||||
[TestFeature.Action2]: constructMeteredFeature({
|
[TestFeature.Action2]: constructMeteredFeature({
|
||||||
featureId: TestFeature.Action2,
|
featureId: TestFeature.Action2,
|
||||||
orgId,
|
orgId,
|
||||||
env: AppEnv.Sandbox,
|
env: AppEnv.Sandbox,
|
||||||
usageType: FeatureUsageType.Single,
|
usageType: FeatureUsageType.Single,
|
||||||
|
// eventNames: ["action-event"],
|
||||||
|
}),
|
||||||
|
[TestFeature.Action3]: constructMeteredFeature({
|
||||||
|
featureId: TestFeature.Action3,
|
||||||
|
orgId,
|
||||||
|
env: AppEnv.Sandbox,
|
||||||
|
usageType: FeatureUsageType.Single,
|
||||||
|
eventNames: ["action-event"],
|
||||||
}),
|
}),
|
||||||
[TestFeature.Credits]: constructCreditSystem({
|
[TestFeature.Credits]: constructCreditSystem({
|
||||||
featureId: TestFeature.Credits,
|
featureId: TestFeature.Credits,
|
||||||
@@ -86,4 +98,15 @@ export const features = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
[TestFeature.Credits2]: constructCreditSystem({
|
||||||
|
featureId: TestFeature.Credits2,
|
||||||
|
orgId,
|
||||||
|
env: AppEnv.Sandbox,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
metered_feature_id: TestFeature.Action3,
|
||||||
|
credit_cost: 1.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export const TrackParamsSchema = z
|
|||||||
entity_data: EntityDataSchema.optional().meta({
|
entity_data: EntityDataSchema.optional().meta({
|
||||||
description: "Data for creating the entity if it doesn't exist",
|
description: "Data for creating the entity if it doesn't exist",
|
||||||
}),
|
}),
|
||||||
|
overage_behaviour: z.enum(["cap", "reject"]).optional().meta({
|
||||||
|
description: "The behavior when the balance is insufficient",
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -78,7 +81,7 @@ export const TrackParamsSchema = z
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TrackResultSchema = z.object({
|
export const TrackResponseSchema = z.object({
|
||||||
id: z.string().meta({
|
id: z.string().meta({
|
||||||
description: "The ID of the created event",
|
description: "The ID of the created event",
|
||||||
}),
|
}),
|
||||||
@@ -100,3 +103,4 @@ export const TrackResultSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type TrackParams = z.infer<typeof TrackParamsSchema>;
|
export type TrackParams = z.infer<typeof TrackParamsSchema>;
|
||||||
|
export type TrackResponse = z.infer<typeof TrackResponseSchema>;
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ export const SetUsageParamsSchema = z.object({
|
|||||||
|
|
||||||
customer_data: CustomerDataSchema.optional(),
|
customer_data: CustomerDataSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type SetUsageParams = z.infer<typeof SetUsageParamsSchema>;
|
||||||
|
|||||||
13
shared/api/errors/classes/balancesErrClasses.ts
Normal file
13
shared/api/errors/classes/balancesErrClasses.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { RecaseError } from "../../../index.js";
|
||||||
|
import { BalancesErrorCode } from "../codes/balancesErrCodes.js";
|
||||||
|
|
||||||
|
export class InsufficientBalanceError extends RecaseError {
|
||||||
|
constructor(opts?: { message?: string }) {
|
||||||
|
super({
|
||||||
|
message: opts?.message || "Insufficient balance",
|
||||||
|
code: BalancesErrorCode.InsufficientBalance,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
this.name = "InsufficientBalanceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
6
shared/api/errors/codes/balancesErrCodes.ts
Normal file
6
shared/api/errors/codes/balancesErrCodes.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const BalancesErrorCode = {
|
||||||
|
InsufficientBalance: "insufficient_balance",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BalancesErrorCode =
|
||||||
|
(typeof BalancesErrorCode)[keyof typeof BalancesErrorCode];
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
export * from "./base/InternalError.js";
|
export * from "./base/InternalError.js";
|
||||||
export * from "./base/RecaseError.js";
|
export * from "./base/RecaseError.js";
|
||||||
|
export * from "./classes/balancesErrClasses.js";
|
||||||
export * from "./classes/cusErrClasses.js";
|
export * from "./classes/cusErrClasses.js";
|
||||||
export * from "./classes/cusProductErrClasses.js";
|
export * from "./classes/cusProductErrClasses.js";
|
||||||
export * from "./classes/productErrClasses.js";
|
export * from "./classes/productErrClasses.js";
|
||||||
|
export * from "./codes/balancesErrCodes.js";
|
||||||
export * from "./codes/cusErrCodes.js";
|
export * from "./codes/cusErrCodes.js";
|
||||||
export * from "./codes/cusProductErrCodes.js";
|
export * from "./codes/cusProductErrCodes.js";
|
||||||
export * from "./codes/productErrCodes.js";
|
export * from "./codes/productErrCodes.js";
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export * from "./referrals/referralsOpenApi.js";
|
|||||||
// Balances
|
// Balances
|
||||||
export * from "./balances/check/previousVersions/CheckResponseV0.js";
|
export * from "./balances/check/previousVersions/CheckResponseV0.js";
|
||||||
export * from "./balances/trackModels.js";
|
export * from "./balances/trackModels.js";
|
||||||
|
export * from "./balances/usageModels.js";
|
||||||
// Errors
|
// Errors
|
||||||
export * from "./errors/index.js";
|
export * from "./errors/index.js";
|
||||||
// Models
|
// Models
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export enum SuccessCode {
|
export enum SuccessCode {
|
||||||
// Events
|
// Track
|
||||||
|
SuccessfullyDeducted = "successfully_deducted",
|
||||||
EventReceived = "event_received",
|
EventReceived = "event_received",
|
||||||
EventReceivedCustomerCreated = "event_received_customer_created",
|
EventReceivedCustomerCreated = "event_received_customer_created",
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Decimal } from "decimal.js";
|
||||||
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||||
|
import type { FullCusEntWithFullCusProduct } from "../../models/cusProductModels/cusEntModels/cusEntWithProduct.js";
|
||||||
import { notNullish, nullish } from "../utils.js";
|
import { notNullish, nullish } from "../utils.js";
|
||||||
|
|
||||||
export const getSummedEntityBalances = ({
|
export const getSummedEntityBalances = ({
|
||||||
@@ -67,3 +69,22 @@ export const getCusEntBalance = ({
|
|||||||
count: 1,
|
count: 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMaxOverage = ({
|
||||||
|
cusEnt,
|
||||||
|
}: {
|
||||||
|
cusEnt: FullCusEntWithFullCusProduct;
|
||||||
|
}) => {
|
||||||
|
const usageLimit = cusEnt.entitlement.usage_limit;
|
||||||
|
if (nullish(usageLimit)) return undefined;
|
||||||
|
|
||||||
|
// const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||||
|
// if (cusPrice && isPrepaidPrice({ price: cusPrice.price })) return undefined;
|
||||||
|
if (!cusEnt.usage_allowed) return undefined;
|
||||||
|
|
||||||
|
const maxOverage = new Decimal(usageLimit)
|
||||||
|
.sub(cusEnt.balance || 0)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
return maxOverage;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { FullCustomerEntitlement } from "@models/cusProductModels/cusEntModels/cusEntModels.js";
|
import type {
|
||||||
|
EntityBalance,
|
||||||
|
FullCustomerEntitlement,
|
||||||
|
} from "@models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||||
|
|
||||||
export const formatCusEnt = ({
|
export const formatCusEnt = ({
|
||||||
cusEnt,
|
cusEnt,
|
||||||
@@ -7,3 +10,37 @@ export const formatCusEnt = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`;
|
return `${cusEnt.entitlement.feature_id} (${cusEnt.entitlement.interval}) (${cusEnt.balance})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import type { FullCustomer } from "@autumn/shared";
|
||||||
|
|
||||||
|
export const updateCusEntInFullCus = ({
|
||||||
|
fullCus,
|
||||||
|
cusEntId,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
fullCus: FullCustomer;
|
||||||
|
cusEntId: string;
|
||||||
|
update: {
|
||||||
|
balance: number;
|
||||||
|
entities: Record<string, EntityBalance> | undefined;
|
||||||
|
adjustment: number;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
for (let i = 0; i < fullCus.customer_products.length; i++) {
|
||||||
|
for (
|
||||||
|
let j = 0;
|
||||||
|
j < fullCus.customer_products[i].customer_entitlements.length;
|
||||||
|
j++
|
||||||
|
) {
|
||||||
|
const ce = fullCus.customer_products[i].customer_entitlements[j];
|
||||||
|
if (ce.id === cusEntId) {
|
||||||
|
fullCus.customer_products[i].customer_entitlements[j] = {
|
||||||
|
...ce,
|
||||||
|
balance: update.balance,
|
||||||
|
entities: update.entities,
|
||||||
|
adjustment: update.adjustment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||||
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||||
import { FeatureType } from "../../models/featureModels/featureEnums.js";
|
import { FeatureType } from "../../models/featureModels/featureEnums.js";
|
||||||
import { AllowanceType } from "../../models/productModels/entModels/entModels.js";
|
import { AllowanceType } from "../../models/productModels/entModels/entModels.js";
|
||||||
import { entIntervalToValue } from "../intervalUtils.js";
|
import { entIntervalToValue } from "../intervalUtils.js";
|
||||||
@@ -15,41 +15,41 @@ export const sortCusEntsForDeduction = (
|
|||||||
const bEnt = b.entitlement;
|
const bEnt = b.entitlement;
|
||||||
|
|
||||||
// 1. If boolean, go first
|
// 1. If boolean, go first
|
||||||
if (aEnt.feature.type == FeatureType.Boolean) {
|
if (aEnt.feature.type === FeatureType.Boolean) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bEnt.feature.type == FeatureType.Boolean) {
|
if (bEnt.feature.type === FeatureType.Boolean) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. If a is credit system and b is not, a should go last
|
// 1. If a is credit system and b is not, a should go last
|
||||||
if (
|
if (
|
||||||
aEnt.feature.type == FeatureType.CreditSystem &&
|
aEnt.feature.type === FeatureType.CreditSystem &&
|
||||||
bEnt.feature.type != FeatureType.CreditSystem
|
bEnt.feature.type !== FeatureType.CreditSystem
|
||||||
) {
|
) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If a is not credit system and b is, a should go first
|
// 2. If a is not credit system and b is, a should go first
|
||||||
if (
|
if (
|
||||||
aEnt.feature.type != FeatureType.CreditSystem &&
|
aEnt.feature.type !== FeatureType.CreditSystem &&
|
||||||
bEnt.feature.type == FeatureType.CreditSystem
|
bEnt.feature.type === FeatureType.CreditSystem
|
||||||
) {
|
) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sort by unlimited (unlimited goes first)
|
// 2. Sort by unlimited (unlimited goes first)
|
||||||
if (
|
if (
|
||||||
aEnt.allowance_type == AllowanceType.Unlimited &&
|
aEnt.allowance_type === AllowanceType.Unlimited &&
|
||||||
bEnt.allowance_type != AllowanceType.Unlimited
|
bEnt.allowance_type !== AllowanceType.Unlimited
|
||||||
) {
|
) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
aEnt.allowance_type != AllowanceType.Unlimited &&
|
aEnt.allowance_type !== AllowanceType.Unlimited &&
|
||||||
bEnt.allowance_type == AllowanceType.Unlimited
|
bEnt.allowance_type === AllowanceType.Unlimited
|
||||||
) {
|
) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export const sortCusEntsForDeduction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If one has a next_reset_at, it should go first
|
// If one has a next_reset_at, it should go first
|
||||||
let nextResetFirst = reverseOrder ? 1 : -1;
|
const nextResetFirst = reverseOrder ? 1 : -1;
|
||||||
|
|
||||||
if (a.next_reset_at && !b.next_reset_at) {
|
if (a.next_reset_at && !b.next_reset_at) {
|
||||||
return nextResetFirst;
|
return nextResetFirst;
|
||||||
@@ -76,8 +76,8 @@ export const sortCusEntsForDeduction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sort by interval
|
// 3. Sort by interval
|
||||||
let aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count);
|
const aVal = entIntervalToValue(aEnt.interval, aEnt.interval_count);
|
||||||
let bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count);
|
const bVal = entIntervalToValue(bEnt.interval, bEnt.interval_count);
|
||||||
if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) {
|
if (aEnt.interval && bEnt.interval && !aVal.eq(bVal)) {
|
||||||
if (reverseOrder) {
|
if (reverseOrder) {
|
||||||
return bVal.sub(aVal).toNumber();
|
return bVal.sub(aVal).toNumber();
|
||||||
@@ -89,8 +89,8 @@ export const sortCusEntsForDeduction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if a is main product
|
// Check if a is main product
|
||||||
let aIsAddOn = a.customer_product?.product?.is_add_on;
|
const aIsAddOn = a.customer_product?.product?.is_add_on;
|
||||||
let bIsAddOn = b.customer_product?.product?.is_add_on;
|
const bIsAddOn = b.customer_product?.product?.is_add_on;
|
||||||
|
|
||||||
if (aIsAddOn && !bIsAddOn) {
|
if (aIsAddOn && !bIsAddOn) {
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiFeatureSchema } from "@api/features/apiFeature.js";
|
|||||||
import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js";
|
import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js";
|
||||||
import { FeatureType } from "../models/featureModels/featureEnums.js";
|
import { FeatureType } from "../models/featureModels/featureEnums.js";
|
||||||
import type { Feature } from "../models/featureModels/featureModels.js";
|
import type { Feature } from "../models/featureModels/featureModels.js";
|
||||||
|
import { creditSystemContainsFeature } from "./featureUtils/creditSystemUtils.js";
|
||||||
// import {
|
// import {
|
||||||
// constructBooleanFeature,
|
// constructBooleanFeature,
|
||||||
// constructCreditSystem,
|
// constructCreditSystem,
|
||||||
@@ -35,3 +36,20 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => {
|
|||||||
credit_schema: creditSchema,
|
credit_schema: creditSchema,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRelevantFeatures = ({
|
||||||
|
features,
|
||||||
|
featureId,
|
||||||
|
}: {
|
||||||
|
features: Feature[];
|
||||||
|
featureId: string;
|
||||||
|
}) => {
|
||||||
|
return features.filter(
|
||||||
|
(f) =>
|
||||||
|
f.id === featureId ||
|
||||||
|
creditSystemContainsFeature({
|
||||||
|
creditSystem: f,
|
||||||
|
meteredFeatureId: featureId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
24
shared/utils/featureUtils/creditSystemUtils.ts
Normal file
24
shared/utils/featureUtils/creditSystemUtils.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CreditSchemaItem } from "../../models/featureModels/featureConfig/creditConfig.js";
|
||||||
|
import { FeatureType } from "../../models/featureModels/featureEnums.js";
|
||||||
|
import type { Feature } from "../../models/featureModels/featureModels.js";
|
||||||
|
|
||||||
|
export const creditSystemContainsFeature = ({
|
||||||
|
creditSystem,
|
||||||
|
meteredFeatureId,
|
||||||
|
}: {
|
||||||
|
creditSystem: Feature;
|
||||||
|
meteredFeatureId: string;
|
||||||
|
}) => {
|
||||||
|
if (creditSystem.type !== FeatureType.CreditSystem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const schema: CreditSchemaItem[] = creditSystem.config.schema;
|
||||||
|
|
||||||
|
for (const schemaItem of schema) {
|
||||||
|
if (schemaItem.metered_feature_id === meteredFeatureId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user