feat: 🎸 unlimited decutions + collapse plan id
This commit is contained in:
@@ -105,15 +105,21 @@ export const listByCursor = async ({
|
||||
if (row.deductions) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.deductions);
|
||||
if (Array.isArray(parsed)) {
|
||||
deductions = parsed as TrackDeduction[];
|
||||
} else if (parsed && Array.isArray(parsed.list)) {
|
||||
deductions = parsed.list as TrackDeduction[];
|
||||
// Tinybird's JSON column re-encodes nested-object array items
|
||||
// as strings; second parse brings them back to TrackDeduction.
|
||||
const rawList = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && Array.isArray(parsed.list)
|
||||
? parsed.list
|
||||
: null;
|
||||
if (rawList) {
|
||||
deductions = rawList.map((item: unknown) =>
|
||||
typeof item === "string"
|
||||
? (JSON.parse(item) as TrackDeduction)
|
||||
: (item as TrackDeduction),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON — leave null so the caller can distinguish missing
|
||||
// vs explicit empty.
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp);
|
||||
|
||||
@@ -91,15 +91,21 @@ export const listEvents = async ({
|
||||
if (row.deductions) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.deductions);
|
||||
if (Array.isArray(parsed)) {
|
||||
deductions = parsed as TrackDeduction[];
|
||||
} else if (parsed && Array.isArray(parsed.list)) {
|
||||
deductions = parsed.list as TrackDeduction[];
|
||||
// Tinybird's JSON column re-encodes nested-object array items
|
||||
// as strings; second parse brings them back to TrackDeduction.
|
||||
const rawList = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && Array.isArray(parsed.list)
|
||||
? parsed.list
|
||||
: null;
|
||||
if (rawList) {
|
||||
deductions = rawList.map((item: unknown) =>
|
||||
typeof item === "string"
|
||||
? (JSON.parse(item) as TrackDeduction)
|
||||
: (item as TrackDeduction),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON — leave null so the caller can distinguish missing
|
||||
// vs explicit empty.
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,8 +14,10 @@ import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { getCustomerNames } from "@/internal/analytics/actions/getCustomerNames.js";
|
||||
import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { eventActions } from "../actions/eventActions.js";
|
||||
import { collapsePlanIdGroups } from "./utils/collapsePlanIdGroups.js";
|
||||
|
||||
const STANDARD_INTERVAL_DAYS: Record<string, number> = {
|
||||
"24h": 1,
|
||||
@@ -129,6 +131,30 @@ export const handleInternalAggregateEvents = createRoute({
|
||||
return { start: aligned.getTime(), end: now.getTime() };
|
||||
})();
|
||||
|
||||
const isPlanIdGrouping = group_by === "plan_id" || group_by === "$plan_id";
|
||||
let internalIdToPublicId: Record<string, string> | undefined;
|
||||
let planNames: Record<string, string> | undefined;
|
||||
let effectiveMaxGroups: number | undefined;
|
||||
|
||||
if (isPlanIdGrouping) {
|
||||
const allProductRows = await ProductService.listCachedAllVersions({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
effectiveMaxGroups = Math.max(
|
||||
allProductRows.length + 1,
|
||||
max_groups ?? 0,
|
||||
10,
|
||||
);
|
||||
internalIdToPublicId = {};
|
||||
planNames = {};
|
||||
for (const p of allProductRows) {
|
||||
internalIdToPublicId[p.internal_id] = p.id;
|
||||
planNames[p.id] = p.name ?? p.id;
|
||||
}
|
||||
}
|
||||
|
||||
const [{ formatted: events, truncated }, totals] = await Promise.all([
|
||||
eventActions.aggregate({
|
||||
ctx,
|
||||
@@ -143,7 +169,7 @@ export const handleInternalAggregateEvents = createRoute({
|
||||
group_by: group_by,
|
||||
customer,
|
||||
timezone: timezone,
|
||||
max_groups,
|
||||
max_groups: isPlanIdGrouping ? effectiveMaxGroups : max_groups,
|
||||
},
|
||||
}),
|
||||
eventActions.getCountAndSum({
|
||||
@@ -161,6 +187,10 @@ export const handleInternalAggregateEvents = createRoute({
|
||||
}),
|
||||
]);
|
||||
|
||||
if (isPlanIdGrouping && events?.data && internalIdToPublicId) {
|
||||
collapsePlanIdGroups({ events, internalIdToPublicId });
|
||||
}
|
||||
|
||||
// When grouping by entity_id, resolve entity names from ClickHouse
|
||||
let entityNames: Record<string, string> | undefined;
|
||||
if (group_by === "entity_id" && events?.data) {
|
||||
@@ -214,6 +244,7 @@ export const handleInternalAggregateEvents = createRoute({
|
||||
truncated,
|
||||
entityNames,
|
||||
customerNames,
|
||||
planNames,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { ClickHouseResult } from "@autumn/shared";
|
||||
|
||||
export const collapsePlanIdGroups = ({
|
||||
events,
|
||||
internalIdToPublicId,
|
||||
}: {
|
||||
events: ClickHouseResult;
|
||||
internalIdToPublicId: Record<string, string>;
|
||||
}) => {
|
||||
const collapsed = new Map<string, Record<string, string | number>>();
|
||||
|
||||
for (const row of events.data) {
|
||||
const planId = String(row.plan_id ?? "");
|
||||
const collapsedId =
|
||||
planId === "" || planId === "AUTUMN_RESERVED"
|
||||
? planId
|
||||
: internalIdToPublicId[planId] ?? planId;
|
||||
|
||||
const key = `${row.period}|${collapsedId}`;
|
||||
const existing = collapsed.get(key);
|
||||
|
||||
if (!existing) {
|
||||
collapsed.set(key, {
|
||||
...row,
|
||||
plan_id: collapsedId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
if (k === "period" || k === "plan_id") continue;
|
||||
existing[k] = new Decimal(existing[k] ?? 0)
|
||||
.plus(new Decimal(v as number))
|
||||
.toDecimalPlaces(10)
|
||||
.toNumber();
|
||||
}
|
||||
}
|
||||
|
||||
events.data = Array.from(collapsed.values());
|
||||
events.rows = events.data.length;
|
||||
};
|
||||
@@ -109,6 +109,7 @@ export const executePostgresDeductionV2 = async ({
|
||||
rollovers,
|
||||
customerEntitlements,
|
||||
unlimitedFeatureIds,
|
||||
unlimitedCusEnt,
|
||||
lock: preparedLock,
|
||||
} = prepareFeatureDeductionV2({
|
||||
ctx,
|
||||
@@ -129,6 +130,25 @@ export const executePostgresDeductionV2 = async ({
|
||||
redisInstance: ctx.redisV2,
|
||||
});
|
||||
}
|
||||
// Attribute the event to the unlimited plan even though we skip
|
||||
// the actual deduction. Without this, resolveInternalProductIdForEvent
|
||||
// gets an empty mutation log and the event lands in "No plan".
|
||||
if (unlimitedCusEnt) {
|
||||
const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1);
|
||||
if (syntheticDelta !== 0) {
|
||||
allMutationLogs.push({
|
||||
target_type: "customer_entitlement",
|
||||
customer_entitlement_id: unlimitedCusEnt.id,
|
||||
rollover_id: null,
|
||||
entity_id: entityId ?? null,
|
||||
credit_cost: 1,
|
||||
balance_delta: syntheticDelta,
|
||||
adjustment_delta: 0,
|
||||
usage_delta: 0,
|
||||
value_delta: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export const executeRedisDeductionV2 = async ({
|
||||
rollovers,
|
||||
customerEntitlements,
|
||||
unlimitedFeatureIds,
|
||||
unlimitedCusEnt,
|
||||
lock: preparedLock,
|
||||
} = prepareFeatureDeductionV2({
|
||||
ctx,
|
||||
@@ -140,6 +141,25 @@ export const executeRedisDeductionV2 = async ({
|
||||
redisInstance: redisInstance ?? ctx.redisV2,
|
||||
});
|
||||
}
|
||||
// Attribute the event to the unlimited plan even though we skip
|
||||
// the actual deduction. Without this, resolveInternalProductIdForEvent
|
||||
// gets an empty mutation log and the event lands in "No plan".
|
||||
if (unlimitedCusEnt) {
|
||||
const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1);
|
||||
if (syntheticDelta !== 0) {
|
||||
allMutationLogs.push({
|
||||
target_type: "customer_entitlement",
|
||||
customer_entitlement_id: unlimitedCusEnt.id,
|
||||
rollover_id: null,
|
||||
entity_id: entityId ?? null,
|
||||
credit_cost: 1,
|
||||
balance_delta: syntheticDelta,
|
||||
adjustment_delta: 0,
|
||||
usage_delta: 0,
|
||||
value_delta: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
AllowanceType,
|
||||
cusEntToStartingBalance,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type FullSubject,
|
||||
fullSubjectToCustomerEntitlements,
|
||||
fullSubjectToOverageAllowedByFeatureId,
|
||||
@@ -58,6 +60,15 @@ export const prepareFeatureDeductionV2 = ({
|
||||
});
|
||||
|
||||
const unlimitedFeatureIds: string[] = [];
|
||||
// Track the chosen unlimited cusEnt so the deduction short-circuit can
|
||||
// still attribute the event to a plan. Prefer cusEnts whose feature
|
||||
// matches the tracked one (over credit-system parents); within that,
|
||||
// take the first match in the already-sorted customerEntitlements list.
|
||||
let unlimitedCusEntPrimary: FullCusEntWithFullCusProduct | undefined;
|
||||
let unlimitedCusEntFallback: FullCusEntWithFullCusProduct | undefined;
|
||||
const isUnlimitedCusEnt = (ce: FullCusEntWithFullCusProduct): boolean =>
|
||||
ce.entitlement.allowance_type === AllowanceType.Unlimited ||
|
||||
Boolean(ce.unlimited);
|
||||
|
||||
for (const relevantFeature of relevantFeatures) {
|
||||
const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({
|
||||
@@ -67,9 +78,22 @@ export const prepareFeatureDeductionV2 = ({
|
||||
|
||||
if (featureUnlimited) {
|
||||
unlimitedFeatureIds.push(relevantFeature.id);
|
||||
const matchingCusEnt = customerEntitlements.find(
|
||||
(ce) =>
|
||||
ce.internal_feature_id === relevantFeature.internal_id &&
|
||||
isUnlimitedCusEnt(ce),
|
||||
);
|
||||
if (!matchingCusEnt) continue;
|
||||
if (relevantFeature.id === feature.id) {
|
||||
unlimitedCusEntPrimary ??= matchingCusEnt;
|
||||
} else {
|
||||
unlimitedCusEntFallback ??= matchingCusEnt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unlimitedCusEnt = unlimitedCusEntPrimary ?? unlimitedCusEntFallback;
|
||||
|
||||
const effectiveFeatureIds = relevantFeatures.map((candidate) => candidate.id);
|
||||
const spendLimitByFeatureId = fullSubjectToSpendLimitByFeatureId({
|
||||
fullSubject,
|
||||
@@ -194,6 +218,7 @@ export const prepareFeatureDeductionV2 = ({
|
||||
credit_cost: rollover.credit_cost,
|
||||
})),
|
||||
unlimitedFeatureIds,
|
||||
unlimitedCusEnt,
|
||||
lock: preparedLock,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -45,6 +45,11 @@ export type PreparedFeatureDeduction = {
|
||||
// rolloverIds: string[];
|
||||
rollovers: RolloverDeduction[];
|
||||
unlimitedFeatureIds: string[];
|
||||
// Chosen unlimited cusEnt to attribute events to when the deduction
|
||||
// short-circuits via unlimitedFeatureIds. Prefers a cusEnt matching the
|
||||
// tracked feature over a credit-system parent. Undefined when no
|
||||
// unlimited cusEnt is present.
|
||||
unlimitedCusEnt?: FullCusEntWithFullCusProduct;
|
||||
lock?: {
|
||||
enabled: true;
|
||||
lock_id?: string;
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import type { Logger } from "@/external/logtail/logtailUtils";
|
||||
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache";
|
||||
import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils";
|
||||
import {
|
||||
buildAllVersionsProductsCacheKey,
|
||||
buildProductsCacheKey,
|
||||
PRODUCTS_CACHE_TTL,
|
||||
} from "./productCacheUtils";
|
||||
import { getLatestProducts, isFreeProduct } from "./productUtils";
|
||||
import { sortFullProducts } from "./productUtils/sortProductUtils";
|
||||
|
||||
@@ -297,6 +301,31 @@ export class ProductService {
|
||||
});
|
||||
}
|
||||
|
||||
static async listCachedAllVersions({
|
||||
db,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}): Promise<Array<{ internal_id: string; id: string; name: string; version: number }>> {
|
||||
return queryWithCache({
|
||||
key: buildAllVersionsProductsCacheKey({ orgId, env }),
|
||||
ttl: PRODUCTS_CACHE_TTL,
|
||||
fn: () =>
|
||||
db
|
||||
.select({
|
||||
internal_id: products.internal_id,
|
||||
id: products.id,
|
||||
name: products.name,
|
||||
version: products.version,
|
||||
})
|
||||
.from(products)
|
||||
.where(and(eq(products.org_id, orgId), eq(products.env, env))),
|
||||
});
|
||||
}
|
||||
|
||||
static async listFull({
|
||||
db,
|
||||
orgId,
|
||||
|
||||
@@ -57,6 +57,17 @@ export const buildProductsCacheKey = ({
|
||||
return `${prefix}:${hash}`;
|
||||
};
|
||||
|
||||
/** Builds the cache key for all product versions (unfiltered, no joins) */
|
||||
export const buildAllVersionsProductsCacheKey = ({
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
}) => {
|
||||
return `${buildProductsCacheKeyPrefix({ orgId, env })}:all_versions`;
|
||||
};
|
||||
|
||||
/** All possible archived query param values that can be cached */
|
||||
const ARCHIVED_VARIANTS = [undefined, false, true] as const;
|
||||
|
||||
@@ -71,13 +82,16 @@ export const invalidateProductsCache = async ({
|
||||
if (redis.status !== "ready") return;
|
||||
|
||||
// Build all possible cache keys (deterministic based on archived param variants)
|
||||
const keysToDelete = ARCHIVED_VARIANTS.map((archived) =>
|
||||
buildProductsCacheKey({
|
||||
orgId,
|
||||
env,
|
||||
queryParams: archived !== undefined ? { archived } : undefined,
|
||||
}),
|
||||
);
|
||||
const keysToDelete = [
|
||||
...ARCHIVED_VARIANTS.map((archived) =>
|
||||
buildProductsCacheKey({
|
||||
orgId,
|
||||
env,
|
||||
queryParams: archived !== undefined ? { archived } : undefined,
|
||||
}),
|
||||
),
|
||||
buildAllVersionsProductsCacheKey({ orgId, env }),
|
||||
];
|
||||
|
||||
const regions = getConfiguredRegions();
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import { initDrizzle } from "@/db/initDrizzle.js";
|
||||
import { logger } from "@/external/logtail/logtailUtils.js";
|
||||
import { redis } from "@/external/redis/initRedis.js";
|
||||
import { redisV2 } from "@/external/redis/initRedisV2.js";
|
||||
import { getSqsClient } from "@/queue/initSqs.js";
|
||||
import { deletePlatformSubOrg } from "@/internal/orgs/deleteOrg/deletePlatformSubOrg.js";
|
||||
import { PurgeQueueCommand } from "@aws-sdk/client-sqs";
|
||||
import { OrgService } from "@/internal/orgs/OrgService.js";
|
||||
import { clearOrg } from "./utils/setup/clearOrg.js";
|
||||
import { setupOrg } from "./utils/setup/setupOrg.js";
|
||||
@@ -138,6 +140,20 @@ export const clearMasterOrg = async () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
const purgeQueue = async (queueUrl: string | undefined, label: string) => {
|
||||
if (!queueUrl) return;
|
||||
try {
|
||||
const sqs = getSqsClient({ queueUrl });
|
||||
await sqs.send(new PurgeQueueCommand({ QueueUrl: queueUrl }));
|
||||
console.log(chalk.green(`✅ Purged ${label} SQS queue.`));
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`⚠️ Skipped ${label} SQS purge: ${err}`));
|
||||
}
|
||||
};
|
||||
|
||||
await purgeQueue(process.env.SQS_QUEUE_URL_V2, "primary");
|
||||
await purgeQueue(process.env.TRACK_SQS_QUEUE_URL, "track");
|
||||
|
||||
console.log(chalk.green("\n✅ Master org setup complete!\n"));
|
||||
} catch (error) {
|
||||
console.error(chalk.red("\n❌ Error:"), error);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* TDD test: when a customer has both an unlimited and a limited cusEnt for the
|
||||
* same feature, a track event currently short-circuits in
|
||||
* executePostgresDeductionV2 / executeRedisDeductionV2 (`unlimitedFeatureIds.length > 0`)
|
||||
* with zero mutation logs. resolveInternalProductIdForEvent then returns null,
|
||||
* so the persisted event has `internal_product_id = null` and an empty
|
||||
* `deductions` array — exactly the "No plan" bucket we see in Mintlify's
|
||||
* analytics for Anthropic.
|
||||
*
|
||||
* Red-failure mode (current behavior):
|
||||
* - event.internal_product_id IS NULL
|
||||
* - event.deductions IS NULL (or empty)
|
||||
* - track response `deductions` is empty/undefined
|
||||
*
|
||||
* Green-success criteria (after fix):
|
||||
* - event.internal_product_id === unlimited plan's internal_id
|
||||
* - event.deductions contains a synthetic entry pointing at the unlimited
|
||||
* cusEnt with the track call's value (echoed entity_id when present)
|
||||
* - track response `deductions` contains the same synthetic entry
|
||||
* - the limited cusEnt's balance is NOT mutated (we only attribute, never
|
||||
* actually deduct on the unlimited path)
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV5,
|
||||
type TrackResponseV3,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { timeout } from "@tests/utils/genUtils.js";
|
||||
import {
|
||||
cleanupOrgRollout,
|
||||
setOrgRolloutPercent,
|
||||
} from "@tests/utils/rolloutTestUtils.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db/initDrizzle.js";
|
||||
import {
|
||||
customerEntitlements,
|
||||
events,
|
||||
products as productsTable,
|
||||
} from "@autumn/shared";
|
||||
|
||||
const TINYBIRD_INGEST_WAIT_MS = 3000;
|
||||
|
||||
test(
|
||||
`${chalk.yellowBright("unlimited-cusent-attribution: track on customer with mixed unlimited+limited entitlements attributes the event to the unlimited plan")}`,
|
||||
async () => {
|
||||
const customerId = `unlimited-cusent-attribution-${Date.now()}`;
|
||||
|
||||
// Base plan: limited messages (100/month).
|
||||
const baseProd = products.base({
|
||||
id: "base-limited",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
// Add-on plan: unlimited messages. Anthropic's analogue is enterprise v1
|
||||
// with allowance_type=unlimited stacked alongside enterprise v2.
|
||||
const unlimitedAddon = products.base({
|
||||
id: "unlimited-addon",
|
||||
items: [items.unlimitedMessages()],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
const { ctx, autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [baseProd, unlimitedAddon] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: baseProd.id }),
|
||||
s.attach({ productId: unlimitedAddon.id }),
|
||||
],
|
||||
});
|
||||
|
||||
const orgId = ctx.org.id;
|
||||
|
||||
try {
|
||||
// Force V3 path (executeRedisDeductionV2 → resolveInternalProductIdForEvent).
|
||||
await setOrgRolloutPercent({ orgId, percent: 100 });
|
||||
|
||||
// initProductsV0 mutates each product's `id` in-place to suffix it
|
||||
// with productPrefix (defaults to customerId), so `unlimitedAddon.id`
|
||||
// is already the final stored id.
|
||||
const unlimitedPlanId = unlimitedAddon.id;
|
||||
const unlimitedProductRow = await db
|
||||
.select({ internal_id: productsTable.internal_id })
|
||||
.from(productsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(productsTable.org_id, orgId),
|
||||
eq(productsTable.env, ctx.env),
|
||||
eq(productsTable.id, unlimitedPlanId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const expectedUnlimitedInternalId =
|
||||
unlimitedProductRow[0]?.internal_id ?? null;
|
||||
expect(expectedUnlimitedInternalId).not.toBeNull();
|
||||
|
||||
// Track usage. Anthropic's calls are AI_CREDITS with value 1..N; we
|
||||
// use value=7 here so the synthetic deduction is unambiguous.
|
||||
const TRACK_VALUE = 7;
|
||||
const trackResponse = (await autumnV2_2.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: TRACK_VALUE,
|
||||
})) as TrackResponseV3;
|
||||
|
||||
// ---- Synchronous track response assertions ----------------------
|
||||
// After the fix, the synthetic mutation log flows through
|
||||
// projectMutationLogsToTrackDeductionsV2 → response.deductions.
|
||||
expect(trackResponse.deductions).toBeDefined();
|
||||
expect(trackResponse.deductions ?? []).toHaveLength(1);
|
||||
const responseDeduction = (trackResponse.deductions ?? [])[0];
|
||||
expect(responseDeduction?.feature_id).toBe(TestFeature.Messages);
|
||||
expect(responseDeduction?.plan_id).toBe(unlimitedPlanId);
|
||||
expect(responseDeduction?.value).toBe(TRACK_VALUE);
|
||||
|
||||
// ---- Persisted event assertions ---------------------------------
|
||||
// Wait for the event batch flush (globalEventBatchingManager).
|
||||
await timeout(TINYBIRD_INGEST_WAIT_MS);
|
||||
|
||||
const customer = (await autumnV2_2.customers.get(customerId, {
|
||||
with_autumn_id: true,
|
||||
})) as ApiCustomerV5 & { autumn_id?: string };
|
||||
const internalCustomerId = customer.autumn_id;
|
||||
expect(internalCustomerId).toBeDefined();
|
||||
|
||||
const eventRows = await db
|
||||
.select({
|
||||
id: events.id,
|
||||
event_name: events.event_name,
|
||||
value: events.value,
|
||||
entity_id: events.entity_id,
|
||||
internal_product_id: events.internal_product_id,
|
||||
deductions: events.deductions,
|
||||
})
|
||||
.from(events)
|
||||
.where(
|
||||
and(
|
||||
eq(events.org_id, orgId),
|
||||
eq(events.env, ctx.env),
|
||||
eq(events.internal_customer_id, internalCustomerId as string),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(events.created_at))
|
||||
.limit(5);
|
||||
|
||||
expect(eventRows.length).toBeGreaterThan(0);
|
||||
const latestEvent = eventRows[0];
|
||||
|
||||
// The fix: the event is attributed to the unlimited plan.
|
||||
expect(latestEvent.internal_product_id).toBe(
|
||||
expectedUnlimitedInternalId,
|
||||
);
|
||||
|
||||
// The deductions array on the persisted event mirrors the synthetic
|
||||
// mutation log: one entry with the unlimited plan's id and the
|
||||
// tracked value.
|
||||
expect(latestEvent.deductions).not.toBeNull();
|
||||
expect(latestEvent.deductions ?? []).toHaveLength(1);
|
||||
const persistedDeduction = (latestEvent.deductions ?? [])[0];
|
||||
expect(persistedDeduction?.feature_id).toBe(TestFeature.Messages);
|
||||
expect(persistedDeduction?.plan_id).toBe(unlimitedPlanId);
|
||||
expect(persistedDeduction?.value).toBe(TRACK_VALUE);
|
||||
|
||||
// The track call had no entity_id, so the synthetic mutation log
|
||||
// should echo null and the persisted event row should have entity_id
|
||||
// = null. (See "Echo the track calls entity ID" requirement.)
|
||||
expect(latestEvent.entity_id).toBeNull();
|
||||
|
||||
// ---- Non-mutation invariant -------------------------------------
|
||||
// The synthetic mutation log is in-memory only; no cusEnt row should
|
||||
// be touched. Query both cusEnt balances directly: the limited
|
||||
// one stays at 100 (its starting allowance), and the unlimited one
|
||||
// stays at whatever it started at.
|
||||
const cusEntRows = await db
|
||||
.select({
|
||||
id: customerEntitlements.id,
|
||||
balance: customerEntitlements.balance,
|
||||
unlimited: customerEntitlements.unlimited,
|
||||
})
|
||||
.from(customerEntitlements)
|
||||
.where(
|
||||
eq(
|
||||
customerEntitlements.internal_customer_id,
|
||||
internalCustomerId as string,
|
||||
),
|
||||
);
|
||||
const limitedRow = cusEntRows.find((r) => !r.unlimited);
|
||||
const unlimitedRow = cusEntRows.find((r) => r.unlimited);
|
||||
expect(limitedRow?.balance).toBe(100);
|
||||
expect(unlimitedRow?.unlimited).toBe(true);
|
||||
} finally {
|
||||
await cleanupOrgRollout({ orgId });
|
||||
}
|
||||
},
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { PageContainer } from "@/components/general/PageContainer";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { useFeatureFlags } from "@/hooks/useFeatureFlags";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide";
|
||||
@@ -32,6 +31,7 @@ export const AnalyticsView = () => {
|
||||
const [clickHouseDisabled, setClickHouseDisabled] = useState(false);
|
||||
const [hasCleared, setHasCleared] = useState(false);
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
const [planDeselected, setPlanDeselected] = useState<Set<string>>(new Set());
|
||||
const navigate = useNavigate();
|
||||
|
||||
const env = useEnv();
|
||||
@@ -50,61 +50,11 @@ export const AnalyticsView = () => {
|
||||
truncated,
|
||||
entityNames,
|
||||
customerNames,
|
||||
planNames,
|
||||
totals,
|
||||
eventNames: responseEventNames,
|
||||
} = useAnalyticsData({ hasCleared });
|
||||
|
||||
// Build internal_product_id → display name from the products cache so the
|
||||
// chart can label plan_id groups (backend ships raw internal ids).
|
||||
// `/products/products` only returns the latest version per public id, so
|
||||
// historical versions (e.g. a customer still on v1 after we ship v2) get
|
||||
// merged in from `customer.customer_products`. When the same public id has
|
||||
// multiple versions in scope, suffix with ` v{version}`.
|
||||
const { products } = useProductsQuery({ allVersions: true });
|
||||
const planNames = useMemo(() => {
|
||||
type Entry = {
|
||||
internal_id: string;
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
};
|
||||
const entries: Entry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (e: Partial<Entry> & { internal_id?: string | null }) => {
|
||||
if (!e.internal_id || seen.has(e.internal_id)) return;
|
||||
seen.add(e.internal_id);
|
||||
entries.push({
|
||||
internal_id: e.internal_id,
|
||||
id: e.id ?? e.internal_id,
|
||||
name: e.name ?? e.id ?? e.internal_id,
|
||||
version: e.version ?? 1,
|
||||
});
|
||||
};
|
||||
|
||||
for (const p of products) push(p);
|
||||
for (const cp of customer?.customer_products ?? []) {
|
||||
push({
|
||||
internal_id: cp.product?.internal_id,
|
||||
id: cp.product?.id,
|
||||
name: cp.product?.name,
|
||||
version: cp.product?.version,
|
||||
});
|
||||
}
|
||||
|
||||
// Count public id occurrences so we only suffix when there's ambiguity.
|
||||
const idCount = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
idCount.set(e.id, (idCount.get(e.id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const e of entries) {
|
||||
const showVersion = (idCount.get(e.id) ?? 0) >= 2;
|
||||
map[e.internal_id] = showVersion ? `${e.name} v${e.version}` : e.name;
|
||||
}
|
||||
return map;
|
||||
}, [products, customer]);
|
||||
|
||||
// Show toast when data is truncated due to too many unique group values
|
||||
const hasShownTruncationToast = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -119,9 +69,9 @@ export const AnalyticsView = () => {
|
||||
}
|
||||
}, [truncated, groupBy]);
|
||||
|
||||
// Clear the filter when groupBy changes
|
||||
useEffect(() => {
|
||||
setGroupFilter(null);
|
||||
setPlanDeselected(new Set());
|
||||
}, [groupBy]);
|
||||
|
||||
// Extract unique group values from events data for filtering
|
||||
@@ -165,16 +115,20 @@ export const AnalyticsView = () => {
|
||||
return { chartData: null, chartConfig: null };
|
||||
}
|
||||
|
||||
// Apply frontend filter if a group filter is selected. Use an
|
||||
// explicit null check because empty string is a meaningful filter
|
||||
// value for plan_id ("no plan").
|
||||
let filteredEvents = events;
|
||||
if (groupBy && groupFilter !== null) {
|
||||
// Handle special case for column-based operators (not a property)
|
||||
if (groupBy === "plan_id" && planDeselected.size > 0) {
|
||||
const filteredData = events.data.filter(
|
||||
(row: Record<string, string | number>) =>
|
||||
!planDeselected.has(String(row.plan_id ?? "")),
|
||||
);
|
||||
filteredEvents = {
|
||||
...events,
|
||||
data: filteredData,
|
||||
rows: filteredData.length,
|
||||
};
|
||||
} else if (groupBy && groupBy !== "plan_id" && groupFilter !== null) {
|
||||
const groupByColumn =
|
||||
groupBy === "customer_id" ||
|
||||
groupBy === "entity_id" ||
|
||||
groupBy === "plan_id"
|
||||
groupBy === "customer_id" || groupBy === "entity_id"
|
||||
? groupBy
|
||||
: `properties.${groupBy}`;
|
||||
const filteredData = events.data.filter(
|
||||
@@ -206,7 +160,7 @@ export const AnalyticsView = () => {
|
||||
});
|
||||
|
||||
return { chartData: transformed, chartConfig: config };
|
||||
}, [events, features, groupBy, groupFilter, entityNames, customerNames, planNames]);
|
||||
}, [events, features, groupBy, groupFilter, planDeselected, entityNames, customerNames, planNames]);
|
||||
|
||||
// Build legend entries (sorted desc, zero-values filtered). The
|
||||
// width-aware overflow logic lives in ChartLegend.
|
||||
@@ -313,6 +267,8 @@ export const AnalyticsView = () => {
|
||||
propertyKeys,
|
||||
groupFilter,
|
||||
setGroupFilter,
|
||||
planDeselected,
|
||||
setPlanDeselected,
|
||||
availableGroupValues,
|
||||
entityNames,
|
||||
customerNames,
|
||||
@@ -356,7 +312,7 @@ export const AnalyticsView = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!chartData && !queryLoading && (
|
||||
{(!chartData || chartData.data.length === 0) && !queryLoading && (
|
||||
<div className="flex-1 px-10 pt-6">
|
||||
<p className="text-t3 text-sm">
|
||||
No events found. Please widen your filters.{" "}
|
||||
|
||||
@@ -7,13 +7,16 @@ import { Check } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -34,12 +37,30 @@ export const SelectGroupByDropdown = ({
|
||||
const {
|
||||
groupFilter,
|
||||
setGroupFilter,
|
||||
planDeselected,
|
||||
setPlanDeselected,
|
||||
availableGroupValues,
|
||||
entityNames,
|
||||
customerNames,
|
||||
planNames,
|
||||
} = useAnalyticsContext();
|
||||
|
||||
const togglePlanDeselected = (value: string) => {
|
||||
setPlanDeselected((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(value)) next.delete(value);
|
||||
else next.add(value);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const allPlansSelected = (planDeselected?.size ?? 0) === 0;
|
||||
const handlePlanSelectAll = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlanDeselected(new Set());
|
||||
};
|
||||
|
||||
const currentGroupBy = searchParams.get("group_by") || "";
|
||||
const customerId = searchParams.get("customer_id");
|
||||
const showCustomerIdOption = !customerId;
|
||||
@@ -244,11 +265,82 @@ export const SelectGroupByDropdown = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filter section - only shown when a groupBy is selected */}
|
||||
{currentGroupBy && availableGroupValues.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{currentGroupBy === "plan_id" &&
|
||||
availableGroupValues.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs">Filter plans</span>
|
||||
{!allPlansSelected && (
|
||||
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
|
||||
{availableGroupValues.length -
|
||||
(planDeselected?.size ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-56 max-w-none w-max !overflow-x-visible">
|
||||
<div className="flex items-center justify-between px-2 h-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlanSelectAll}
|
||||
className={cn(
|
||||
"px-1 h-5 flex items-center gap-1 text-t2 text-xs hover:text-t1 bg-accent cursor-pointer rounded-md",
|
||||
allPlansSelected &&
|
||||
"bg-primary/10 text-primary hover:text-primary/80",
|
||||
)}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-64 overflow-y-auto overflow-x-visible">
|
||||
{availableGroupValues.map((value: string) => {
|
||||
const displayValue =
|
||||
value === "AUTUMN_RESERVED"
|
||||
? "Other values"
|
||||
: value === ""
|
||||
? "No plan"
|
||||
: (planNames?.[value] ?? value);
|
||||
const isChecked = !planDeselected?.has(value);
|
||||
const wouldDeselectLast =
|
||||
isChecked &&
|
||||
availableGroupValues.length -
|
||||
(planDeselected?.size ?? 0) ===
|
||||
1;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
closeOnClick={false}
|
||||
disabled={wouldDeselectLast}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (wouldDeselectLast) return;
|
||||
togglePlanDeselected(value);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
className="border-border"
|
||||
/>
|
||||
<span className="text-xs whitespace-nowrap">
|
||||
{displayValue}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentGroupBy &&
|
||||
currentGroupBy !== "plan_id" &&
|
||||
availableGroupValues.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-t4 font-normal">
|
||||
Filter by value
|
||||
</DropdownMenuLabel>
|
||||
@@ -266,15 +358,11 @@ export const SelectGroupByDropdown = ({
|
||||
const displayValue =
|
||||
value === "AUTUMN_RESERVED"
|
||||
? "Other values"
|
||||
: value === "" && currentGroupBy === "plan_id"
|
||||
? "No plan"
|
||||
: currentGroupBy === "entity_id"
|
||||
? (entityNames?.[value] ?? value)
|
||||
: currentGroupBy === "customer_id"
|
||||
? (customerNames?.[value] ?? value)
|
||||
: currentGroupBy === "plan_id"
|
||||
? (planNames?.[value] ?? value)
|
||||
: value;
|
||||
: currentGroupBy === "entity_id"
|
||||
? (entityNames?.[value] ?? value)
|
||||
: currentGroupBy === "customer_id"
|
||||
? (customerNames?.[value] ?? value)
|
||||
: value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
@@ -291,9 +379,8 @@ export const SelectGroupByDropdown = ({
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -100,6 +100,7 @@ export const useAnalyticsData = ({
|
||||
truncated: data?.truncated ?? false,
|
||||
entityNames: (data?.entityNames as Record<string, string>) ?? undefined,
|
||||
customerNames: (data?.customerNames as Record<string, string>) ?? undefined,
|
||||
planNames: (data?.planNames as Record<string, string>) ?? undefined,
|
||||
totals:
|
||||
(data?.totals as
|
||||
| Record<string, { count: number; sum: number }>
|
||||
|
||||
Reference in New Issue
Block a user