Files
cfw-autumn/server/src/internal/balances/track/handleTrack.ts
2025-11-08 19:31:38 +00:00

222 lines
5.8 KiB
TypeScript

import {
ApiVersion,
ErrCode,
InsufficientBalanceError,
isContUseFeature,
RecaseError,
SuccessCode,
type TrackParams,
TrackParamsSchema,
type TrackResponse,
} from "@autumn/shared";
import type { RequestContext } from "@/honoUtils/HonoEnv.js";
import { createRoute } from "../../../honoMiddlewares/routeHandler.js";
import { tryRedisWrite } from "../../../utils/cacheUtils/cacheUtils.js";
import { runRedisDeduction } from "./redisTrackUtils/runRedisDeduction.js";
import type { FeatureDeduction } from "./trackUtils/getFeatureDeductions.js";
import {
getTrackEventNameDeductions,
getTrackFeatureDeductions,
} from "./trackUtils/getFeatureDeductions.js";
import { runDeductionTx } from "./trackUtils/runDeductionTx.js";
/**
* Execute PostgreSQL-based tracking with full transaction support
*/
const executePostgresTracking = async ({
ctx,
body,
featureDeductions,
}: {
ctx: RequestContext;
body: TrackParams;
featureDeductions: FeatureDeduction[];
}) => {
const response: TrackResponse = {
id: "",
code: SuccessCode.EventReceived,
customer_id: body.customer_id,
entity_id: body.entity_id,
feature_id: body.feature_id,
event_name: body.event_name,
};
try {
const { event } = await runDeductionTx({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
deductions: featureDeductions,
overageBehaviour: body.overage_behavior,
eventInfo: {
event_name: body.feature_id || body.event_name!,
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
refreshCache: true,
});
response.id = event?.id || "";
} catch (error) {
if (error instanceof InsufficientBalanceError) {
response.code = "insufficient_balance";
} else {
throw error;
}
}
return response;
};
export const handleTrack = createRoute({
body: TrackParamsSchema,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
// Legacy: support value in properties
if (body.properties?.value) {
body.value = body.properties.value;
}
// Validate: event_name cannot be used with overage_behavior: "reject"
if (body.event_name && body.overage_behavior === "reject") {
throw new RecaseError({
message:
'overage_behavior "reject" is not supported with event_name. Use feature_id or set overage_behavior to "cap".',
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Build feature deductions
const featureDeductions = body.feature_id
? getTrackFeatureDeductions({
ctx,
featureId: body.feature_id,
value: body.value,
})
: getTrackEventNameDeductions({
ctx,
eventName: body.event_name!,
value: body.value,
});
const hasContUseFeature = featureDeductions.some((deduction) =>
isContUseFeature({ feature: deduction.feature }),
);
// Scenario 1: idempotency_key requires PostgreSQL (for event persistence)
if (body.idempotency_key || hasContUseFeature) {
const response = await executePostgresTracking({
ctx,
body,
featureDeductions,
});
if (ctx.apiVersion.gte(ApiVersion.V1_1)) return c.json(response);
return c.json({ success: true });
}
let code: string = SuccessCode.EventReceived;
// Scenario 2: Try Redis first, fallback to PostgreSQL if needed
const success = await tryRedisWrite(async () => {
const { error } = await runRedisDeduction({
ctx,
customerId: body.customer_id,
entityId: body.entity_id,
featureDeductions,
overageBehavior: body.overage_behavior || "cap",
eventInfo: {
event_name: body.feature_id || body.event_name!,
value: body.value ?? 1,
properties: body.properties,
timestamp: body.timestamp,
idempotency_key: body.idempotency_key,
},
});
if (error) {
console.log(`Redis deduction error: ${error}`);
code = "insufficient_balance";
}
});
if (!success) {
console.log(`Falling back to postgres tracking`);
const response = await executePostgresTracking({
ctx,
body,
featureDeductions,
});
console.log(`Response: ${JSON.stringify(response)}`);
if (ctx.apiVersion.gte(ApiVersion.V1_1)) return c.json(response);
return c.json({ success: true });
}
// Redis deduction failed (e.g., insufficient balance)
const response = {
id: "",
code: code,
customer_id: body.customer_id,
entity_id: body.entity_id,
feature_id: body.feature_id,
event_name: body.event_name,
};
if (ctx.apiVersion.gte(ApiVersion.V1_1)) return c.json(response);
return c.json({ success: false });
},
});
// Original PostgreSQL-based implementation (commented out for reference)
// 1. Run track deduction tx
// try {
// const start = Date.now();
// const { fullCus, event } = await runDeductionTx({
// ctx,
// customerId: body.customer_id,
// entityId: body.entity_id,
// deductions: featureDeductions,
// overageBehavior: body.overage_behavior,
// 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}`);
// 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,
// };
// if (ctx.apiVersion.gte(ApiVersion.V1_1)) return c.json(response);
// return c.json({ success: true });
// } 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;
// }