diff --git a/scripts/seed/seedEvents.ts b/scripts/seed/seedEvents.ts index c78cd7ca9..5d3a87158 100644 --- a/scripts/seed/seedEvents.ts +++ b/scripts/seed/seedEvents.ts @@ -1,5 +1,11 @@ -import { AppEnv, ErrCode, type EventInsert, RecaseError } from "@autumn/shared"; -import { initDrizzle } from "@server/db/initDrizzle.js"; +import { + AppEnv, + ErrCode, + type EventInsert, + organizations, + RecaseError, +} from "@autumn/shared"; +import { type DrizzleCli, initDrizzle } from "@server/db/initDrizzle.js"; import { EventService } from "@server/internal/api/events/EventService.js"; import { loadLocalEnv } from "@server/utils/envUtils.js"; import { generateId } from "@server/utils/genUtils.js"; @@ -36,10 +42,10 @@ const CONFIG = { interface CliArgs { customer_id: string; + org_slug: string; count?: number; env?: AppEnv; - org_id?: string; - feature_ids?: string; + feature_ids: string; } // ============================================================================ @@ -55,12 +61,12 @@ function parseArgs(): CliArgs { const [key, value] = arg.slice(2).split("="); if (key === "customer_id") { parsed.customer_id = value; + } else if (key === "org_slug") { + parsed.org_slug = value; } else if (key === "count") { parsed.count = Number.parseInt(value, 10); } else if (key === "env") { parsed.env = value as AppEnv; - } else if (key === "org_id") { - parsed.org_id = value; } else if (key === "feature_ids") { parsed.feature_ids = value; } @@ -71,13 +77,29 @@ function parseArgs(): CliArgs { console.error(chalk.red("❌ Error: --customer_id is required")); console.log( chalk.yellow( - "\nUsage: bun run scripts/seed/seedEvents.ts --customer_id= --feature_ids= [--count=] [--env=] [--org_id=]", + "\nUsage: bun run scripts/seed/seedEvents.ts --customer_id= --org_slug= --feature_ids= [--count=] [--env=]", ), ); console.log(chalk.gray("\nExample:")); console.log( chalk.gray( - " bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --feature_ids=api_call,page_view --count=50 --env=sandbox", + " bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --org_slug=my-org --feature_ids=api_call,page_view --count=50 --env=sandbox", + ), + ); + process.exit(1); + } + + if (!parsed.org_slug) { + console.error(chalk.red("❌ Error: --org_slug is required")); + console.log( + chalk.yellow( + "\nUsage: bun run scripts/seed/seedEvents.ts --customer_id= --org_slug= --feature_ids= [--count=] [--env=]", + ), + ); + console.log(chalk.gray("\nExample:")); + console.log( + chalk.gray( + " bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --org_slug=my-org --feature_ids=api_call,page_view --count=50 --env=sandbox", ), ); process.exit(1); @@ -87,13 +109,13 @@ function parseArgs(): CliArgs { console.error(chalk.red("❌ Error: --feature_ids is required")); console.log( chalk.yellow( - "\nUsage: bun run scripts/seed/seedEvents.ts --customer_id= --feature_ids= [--count=] [--env=] [--org_id=]", + "\nUsage: bun run scripts/seed/seedEvents.ts --customer_id= --org_slug= --feature_ids= [--count=] [--env=]", ), ); console.log(chalk.gray("\nExample:")); console.log( chalk.gray( - " bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --feature_ids=api_call,page_view --count=50 --env=sandbox", + " bun run scripts/seed/seedEvents.ts --customer_id=cus_123 --org_slug=my-org --feature_ids=api_call,page_view --count=50 --env=sandbox", ), ); process.exit(1); @@ -106,47 +128,52 @@ function parseArgs(): CliArgs { // VALIDATION // ============================================================================ +async function validateOrg({ + db, + orgSlug, +}: { + db: DrizzleCli; + orgSlug: string; +}) { + const org = await db.query.organizations.findFirst({ + where: (orgs, { eq }) => eq(orgs.slug, orgSlug), + }); + + if (!org) { + throw new RecaseError({ + message: `Organization with slug '${orgSlug}' not found`, + code: ErrCode.OrgNotFound, + statusCode: 404, + }); + } + + return org; +} + async function validateCustomer({ db, customerId, orgId, env, }: { - db: ReturnType["db"]; + db: DrizzleCli; customerId: string; - orgId?: string; + orgId: string; env: AppEnv; }) { - // Build the where clause based on whether org_id is provided const customer = await db.query.customers.findFirst({ where: (customers, { eq, or, and }) => { - const customerMatch = or( - eq(customers.id, customerId), - eq(customers.internal_id, customerId), + return and( + or(eq(customers.id, customerId), eq(customers.internal_id, customerId)), + eq(customers.org_id, orgId), + eq(customers.env, env), ); - - if (orgId) { - return and( - customerMatch, - eq(customers.org_id, orgId), - eq(customers.env, env), - ); - } - - return customerMatch; - }, - with: { - org: true, }, }); if (!customer) { - const errorMsg = orgId - ? `Customer '${customerId}' not found in org '${orgId}' with env '${env}'` - : `Customer '${customerId}' not found`; - throw new RecaseError({ - message: errorMsg, + message: `Customer '${customerId}' not found in org '${orgId}' with env '${env}'`, code: ErrCode.CustomerNotFound, statusCode: 404, }); @@ -169,11 +196,13 @@ function generateRandomTimestamp({ daysBack }: { daysBack: number }): Date { function generateEvents({ count, customer, + org, env, featureIds, }: { count: number; customer: Awaited>; + org: Awaited>; env: AppEnv; featureIds: string[]; }): EventInsert[] { @@ -187,8 +216,8 @@ function generateEvents({ const event: EventInsert = { id: generateId("evt"), - org_id: customer.org_id, - org_slug: customer.org?.slug || "unknown", + org_id: org.id, + org_slug: org.slug, internal_customer_id: customer.internal_id, customer_id: customer.id || "", env, @@ -236,28 +265,38 @@ async function main() { const args = parseArgs(); const eventCount = args.count || CONFIG.defaultEventCount; const env = args.env || CONFIG.defaultEnv; - const featureIds = args.feature_ids!.split(",").map((id) => id.trim()); + const featureIds = args.feature_ids.split(",").map((id) => id.trim()); console.log(chalk.cyan("Configuration:")); console.log(chalk.gray(` Customer ID: ${args.customer_id}`)); + console.log(chalk.gray(` Organization Slug: ${args.org_slug}`)); console.log(chalk.gray(` Event Count: ${eventCount}`)); console.log(chalk.gray(` Environment: ${env}`)); console.log(chalk.gray(` Feature IDs: ${featureIds.join(", ")}`)); - if (args.org_id) { - console.log(chalk.gray(` Organization ID: ${args.org_id}`)); - } console.log(); // Initialize database connection const { db, client } = initDrizzle(); try { + // Validate organization exists + console.log(chalk.cyan("Validating organization...")); + const org = await validateOrg({ + db, + orgSlug: args.org_slug, + }); + + console.log( + chalk.green(`✅ Organization found: ${org.slug} (${org.name})`), + ); + console.log(); + // Validate customer exists console.log(chalk.cyan("Validating customer...")); const customer = await validateCustomer({ db, customerId: args.customer_id, - orgId: args.org_id, + orgId: org.id, env, }); @@ -266,9 +305,6 @@ async function main() { `✅ Customer found: ${customer.id} (${customer.name || "No name"})`, ), ); - console.log( - chalk.gray(` Organization: ${customer.org?.name || customer.org_id}`), - ); console.log(); // Generate events @@ -276,6 +312,7 @@ async function main() { const events = generateEvents({ count: eventCount, customer, + org, env, featureIds, }); diff --git a/scripts/setup/events/recreate-clickhouse-event-views.ts b/scripts/setup/events/recreate-clickhouse-event-views.ts new file mode 100644 index 000000000..8abc5a9bf --- /dev/null +++ b/scripts/setup/events/recreate-clickhouse-event-views.ts @@ -0,0 +1,67 @@ +/** + * Recreate ClickHouse date range views + * Creates both legacy views (for AnalyticsService) and new event aggregation views (for EventsAggregationService) + * Usage: bun run scripts/setup/events/recreate-clickhouse-event-views.ts + * Or: infisical run --env=dev -- bun scripts/setup/events/recreate-clickhouse-event-views.ts + * Or: infisical run --env=prod -- bun scripts/setup/events/recreate-clickhouse-event-views.ts + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createClient } from "@clickhouse/client"; + +async function main() { + const required = [ + "CLICKHOUSE_URL", + "CLICKHOUSE_USERNAME", + "CLICKHOUSE_PASSWORD", + ]; + + const missing = required.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error("Missing env vars:", missing.join(", ")); + process.exit(1); + } + + const client = createClient({ + url: process.env.CLICKHOUSE_URL, + username: process.env.CLICKHOUSE_USERNAME, + password: process.env.CLICKHOUSE_PASSWORD, + }); + + try { + const queriesDir = path.join( + import.meta.dir, + "../../server/src/external/clickhouse/queries", + ); + + const viewsToRecreate = [ + "CREATE_DATE_RANGE_VIEW.sql", + "CREATE_DATE_RANGE_BC_VIEW.sql", + "CREATE_EVENT_AGGREGATION_DATE_RANGE_VIEW.sql", + "CREATE_EVENT_AGGREGATION_DATE_RANGE_BC_VIEW.sql", + ]; + + console.log("🔄 Recreating ClickHouse views...\n"); + + for (const sqlFile of viewsToRecreate) { + const filePath = path.join(queriesDir, sqlFile); + const sql = fs.readFileSync(filePath, "utf8"); + + console.log(`📝 Executing: ${sqlFile}`); + await client.query({ query: sql }); + console.log(`✅ Success: ${sqlFile}\n`); + } + + console.log("✅ All views recreated successfully!"); + console.log("\n🎯 The bin count issue should now be fixed."); + console.log(" Test with your curl command to verify."); + } catch (error) { + console.error("❌ Error:", error); + process.exit(1); + } finally { + await client.close(); + } +} + +main(); diff --git a/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_BC_VIEW.sql b/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_BC_VIEW.sql index 4ccdef5ce..dd0fcda3d 100644 --- a/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_BC_VIEW.sql +++ b/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_BC_VIEW.sql @@ -2,10 +2,16 @@ CREATE or replace VIEW date_range_bc_view AS SELECT CASE WHEN {bin_size:String} = 'hour' THEN - date_trunc('hour', {start_date:DateTime} - interval {interval_offset:UInt32} hour) + interval number hour + date_trunc('hour', {start_date:DateTime} - interval {days:UInt32} day) + interval number hour WHEN {bin_size:String} = 'month' THEN - date_trunc('month', {start_date:DateTime} - interval {interval_offset:UInt32} month) + interval number month + date_trunc('month', {start_date:DateTime} - interval {days:UInt32} day) + interval number month ELSE - date_trunc('day', {start_date:DateTime} - interval {interval_offset:UInt32} day) + interval number day + date_trunc('day', {start_date:DateTime} - interval {days:UInt32} day) + interval number day END as period -FROM numbers({bin_count:UInt32}); \ No newline at end of file +FROM numbers( + CASE + WHEN {bin_size:String} = 'hour' THEN {days:UInt32} * 24 + 1 + WHEN {bin_size:String} = 'month' THEN toUInt32(ceil(toFloat64({days:UInt32}) / 30.0)) + 1 + ELSE {days:UInt32} + 1 + END +); diff --git a/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_VIEW.sql b/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_VIEW.sql index 2fef6a9f3..a0cc91441 100644 --- a/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_VIEW.sql +++ b/server/src/external/clickhouse/queries/CREATE_DATE_RANGE_VIEW.sql @@ -2,10 +2,16 @@ CREATE or replace VIEW date_range_view AS SELECT CASE WHEN {bin_size:String} = 'hour' THEN - date_trunc('hour', now() - interval {interval_offset:UInt32} hour) + interval number hour + date_trunc('hour', now() - interval {days:UInt32} day) + interval number hour WHEN {bin_size:String} = 'month' THEN - date_trunc('month', now() - interval {interval_offset:UInt32} month) + interval number month + date_trunc('month', now() - interval {days:UInt32} day) + interval number month ELSE - date_trunc('day', now() - interval {interval_offset:UInt32} day) + interval number day + date_trunc('day', now() - interval {days:UInt32} day) + interval number day END as period -FROM numbers({bin_count:UInt32}); +FROM numbers( + CASE + WHEN {bin_size:String} = 'hour' THEN {days:UInt32} * 24 + 1 + WHEN {bin_size:String} = 'month' THEN toUInt32(ceil(toFloat64({days:UInt32}) / 30.0)) + 1 + ELSE {days:UInt32} + 1 + END +); diff --git a/server/src/external/clickhouse/queries/CREATE_EVENT_AGGREGATION_DATE_RANGE_BC_VIEW.sql b/server/src/external/clickhouse/queries/CREATE_EVENT_AGGREGATION_DATE_RANGE_BC_VIEW.sql new file mode 100644 index 000000000..001a3cd85 --- /dev/null +++ b/server/src/external/clickhouse/queries/CREATE_EVENT_AGGREGATION_DATE_RANGE_BC_VIEW.sql @@ -0,0 +1,11 @@ +CREATE or replace VIEW event_aggregation_date_range_bc_view AS +SELECT + CASE + WHEN {bin_size:String} = 'hour' THEN + date_trunc('hour', {start_date:DateTime} - interval {interval_offset:UInt32} hour) + interval number hour + WHEN {bin_size:String} = 'month' THEN + date_trunc('month', {start_date:DateTime} - interval {interval_offset:UInt32} month) + interval number month + ELSE + date_trunc('day', {start_date:DateTime} - interval {interval_offset:UInt32} day) + interval number day + END as period +FROM numbers({bin_count:UInt32}); diff --git a/server/src/external/clickhouse/queries/CREATE_EVENT_AGGREGATION_DATE_RANGE_VIEW.sql b/server/src/external/clickhouse/queries/CREATE_EVENT_AGGREGATION_DATE_RANGE_VIEW.sql new file mode 100644 index 000000000..6ee045c1d --- /dev/null +++ b/server/src/external/clickhouse/queries/CREATE_EVENT_AGGREGATION_DATE_RANGE_VIEW.sql @@ -0,0 +1,11 @@ +CREATE or replace VIEW event_aggregation_date_range_view AS +SELECT + CASE + WHEN {bin_size:String} = 'hour' THEN + date_trunc('hour', now() - interval {interval_offset:UInt32} hour) + interval number hour + WHEN {bin_size:String} = 'month' THEN + date_trunc('month', now() - interval {interval_offset:UInt32} month) + interval number month + ELSE + date_trunc('day', now() - interval {interval_offset:UInt32} day) + interval number day + END as period +FROM numbers({bin_count:UInt32}); diff --git a/server/src/internal/analytics/analyticsUtils.ts b/server/src/internal/analytics/analyticsUtils.ts index 8afda6d75..cced54d94 100644 --- a/server/src/internal/analytics/analyticsUtils.ts +++ b/server/src/internal/analytics/analyticsUtils.ts @@ -14,26 +14,12 @@ import { isFreeProduct } from "../products/productUtils.js"; export async function getBillingCycleStartDate( customer?: FullCustomer, db?: DrizzleCli, - intervalType?: "1bc" | "3bc", + intervalType?: "1bc" | "3bc" | "last_cycle", ) { - // If no customer provided, return empty object (for aggregateAll case) if (!customer || !db || !intervalType) { return {}; } - // const customerHasProducts = notNullish(customer.customer_products); - // // const customerHasSubscriptions = notNullish(customer.subscriptions); - - // // if (!customerHasProducts) { - // // return {}; // No products, return empty object - // // } - - // // const subscriptions = await AnalyticsService.getSubscriptionsIfNeeded( - // // customer, - // // customerHasSubscriptions, - // // db - // // ); - const subscriptions = customer.subscriptions || []; const cusProducts = customer.customer_products.filter( (product: FullCusProduct) => ACTIVE_STATUSES.includes(product.status), @@ -46,7 +32,7 @@ export async function getBillingCycleStartDate( ); const areAllProductsFree = checkIfAllProductsAreFree(fullProducts); - const { startDates, endDates } = areAllProductsFree + const { startDates, endDates, createdDates } = areAllProductsFree ? getDateRangesFromEntitlements(customer.customer_products) : getDateRangesFromSubscriptions(cusProducts, subscriptions); @@ -54,7 +40,12 @@ export async function getBillingCycleStartDate( return {}; } - return calculateBillingCycleResult(startDates, endDates, intervalType); + return calculateBillingCycleResult( + startDates, + endDates, + createdDates, + intervalType, + ); } export function checkIfAllProductsAreFree( @@ -74,9 +65,10 @@ export function formatDateToString(date: Date): string { export function getDateRangesFromSubscriptions( customerProductsFiltered: FullCusProduct[], subscriptions: Subscription[], -): { startDates: string[]; endDates: string[] } { +): { startDates: string[]; endDates: string[]; createdDates: string[] } { const startDates: string[] = []; const endDates: string[] = []; + const createdDates: string[] = []; customerProductsFiltered.forEach((product: FullCusProduct) => { product.subscription_ids?.forEach((subscriptionId: string) => { @@ -96,21 +88,25 @@ export function getDateRangesFromSubscriptions( new Date((subscription.current_period_end ?? 0) * 1000), ), ); + createdDates.push( + formatDateToString(new Date((subscription.created_at ?? 0) * 1000)), + ); } }); }); - return { startDates, endDates }; + return { startDates, endDates, createdDates }; } export function getDateRangesFromEntitlements( customerProducts?: FullCusProduct[], -): { startDates: string[]; endDates: string[] } { +): { startDates: string[]; endDates: string[]; createdDates: string[] } { const startDates: string[] = []; const endDates: string[] = []; + const createdDates: string[] = []; if (!customerProducts || customerProducts.length < 1) { - return { startDates, endDates }; + return { startDates, endDates, createdDates }; } customerProducts.forEach((product: FullCusProduct) => { @@ -138,23 +134,55 @@ export function getDateRangesFromEntitlements( if (startDate) { startDates.push(startDate); } + + createdDates.push(formatDateToString(new Date(entitlement.created_at))); }, ); }); - return { startDates, endDates }; + return { startDates, endDates, createdDates }; } export function calculateBillingCycleResult( startDates: string[], endDates: string[], - intervalType: "1bc" | "3bc", + createdDates: string[], + intervalType: "1bc" | "3bc" | "last_cycle", ) { - const startDate = new Date(startDates[0]); - const endDate = new Date(endDates[0]); - const gap = endDate.getTime() - startDate.getTime(); + const currentStartDate = new Date(startDates[0]); + const currentEndDate = new Date(endDates[0]); + const gap = currentEndDate.getTime() - currentStartDate.getTime(); const gapDays = Math.floor(gap / (1000 * 60 * 60 * 24)); + if (intervalType === "last_cycle") { + const earliestCreation = createdDates.reduce((earliest, current) => { + const currentDate = new Date(current); + const earliestDate = new Date(earliest); + return currentDate < earliestDate ? current : earliest; + }, createdDates[0]); + + const createdAt = new Date(earliestCreation); + + const isSubscriptionCreatedDuringOrAfterCurrentPeriod = + createdAt >= currentStartDate; + if (isSubscriptionCreatedDuringOrAfterCurrentPeriod) { + return { + startDate: startDates[0], + endDate: endDates[0], + gap: gapDays, + }; + } + + const previousEndDate = new Date(currentStartDate.getTime()); + const previousStartDate = new Date(currentStartDate.getTime() - gap); + + return { + startDate: formatDateToString(previousStartDate), + endDate: formatDateToString(previousEndDate), + gap: gapDays, + }; + } + return { startDate: startDates[0], endDate: endDates[0], diff --git a/server/src/internal/events/EventsAggregationService.ts b/server/src/internal/events/EventsAggregationService.ts index 872aec290..460c92079 100644 --- a/server/src/internal/events/EventsAggregationService.ts +++ b/server/src/internal/events/EventsAggregationService.ts @@ -1,4 +1,6 @@ import { + BILLING_CYCLE_INTERVALS, + type BillingCycleIntervalEnum, type BillingCycleResult, type CalculateCustomRangeParamsInput, type CalculateCustomRangeParamsOutput, @@ -56,13 +58,15 @@ export class EventsAggregationService { }; } - const isBillingCycle = intervalType === "1bc" || intervalType === "3bc"; + const isBillingCycle = BILLING_CYCLE_INTERVALS.includes( + intervalType as BillingCycleIntervalEnum, + ); const getBCResults = isBillingCycle && !params.aggregateAll && params.customer ? ((await getBillingCycleStartDate( params.customer, db, - intervalType as "1bc" | "3bc", + intervalType as "1bc" | "3bc" | "last_cycle", )) as BillingCycleResult | null) : null; @@ -106,6 +110,7 @@ export class EventsAggregationService { "90d": 90, "1bc": (gap ?? 0) + 1, "3bc": (gap ?? 0) + 1, + last_cycle: (gap ?? 0) + 1, }; } @@ -160,7 +165,9 @@ export class EventsAggregationService { const intervalType = params.interval; const useCustomDateQuery = - intervalType === "1bc" || intervalType === "3bc" || !!params.custom_range; + BILLING_CYCLE_INTERVALS.includes( + intervalType as BillingCycleIntervalEnum, + ) || !!params.custom_range; const shouldCalculateBillingCycle = useCustomDateQuery && @@ -172,7 +179,7 @@ export class EventsAggregationService { ? ((await getBillingCycleStartDate( params.customer, db, - intervalType as "1bc" | "3bc", + intervalType as "1bc" | "3bc" | "last_cycle", )) as BillingCycleResult | null) : null; @@ -238,7 +245,7 @@ with customer_events as ( select dr.period${groupBy.select}, ${countExpressions} -from date_range_view(bin_size={bin_size:String}, bin_count={bin_count:UInt32}, interval_offset={interval_offset:UInt32}) dr +from event_aggregation_date_range_view(bin_size={bin_size:String}, bin_count={bin_count:UInt32}, interval_offset={interval_offset:UInt32}) dr left join customer_events e on date_trunc({bin_size:String}, e.timestamp) = dr.period group by dr.period${groupBy.groupBy} @@ -258,7 +265,7 @@ with customer_events as ( select dr.period${groupBy.select}, ${countExpressions} -from date_range_bc_view(bin_size={bin_size:String}, start_date={end_date:DateTime}, bin_count={bin_count:UInt32}, interval_offset={interval_offset:UInt32}) dr +from event_aggregation_date_range_bc_view(bin_size={bin_size:String}, start_date={end_date:DateTime}, bin_count={bin_count:UInt32}, interval_offset={interval_offset:UInt32}) dr left join customer_events e on date_trunc({bin_size:String}, e.timestamp) = dr.period ${customRangeFilter} @@ -296,8 +303,21 @@ order by dr.period${groupBy.orderBy}; const standardIntervalBinCount = intervalTypeToDaysMap[intervalType as keyof typeof intervalTypeToDaysMap]; + // Billing cycles already have correct count (gap + 1), don't add another offset + const isBillingCycle = + BILLING_CYCLE_INTERVALS.includes( + intervalType as BillingCycleIntervalEnum, + ) && + !params.custom_range && + getBCResults?.gap !== undefined; + + const binMultiplier = binSize === "hour" ? 24 : 1; + const finalBinCount = - binCount ?? calculateBinCount(standardIntervalBinCount); + binCount ?? + (isBillingCycle + ? standardIntervalBinCount * binMultiplier + : calculateBinCount(standardIntervalBinCount)); // Use date_range_bc_view query for billing cycles or custom ranges const useBillingCycleQuery = @@ -308,12 +328,19 @@ order by dr.period${groupBy.orderBy}; const queryToUse = useBillingCycleQuery ? queryBillingCycle : query; // Calculate interval offset based on query type: + // - Billing cycles: offset = gap (how far back from end_date to start_date) // - Custom ranges: offset = bin_count (already calculated correctly with +1) - // - Billing cycles: offset = bin_count (gap already includes correct calculation) // - Standard intervals: offset = bin_count - 1 (to include current period) - const intervalOffset = useBillingCycleQuery - ? finalBinCount - : finalBinCount - 1; + let intervalOffset: number; + if (isBillingCycle) { + intervalOffset = getBCResults.gap * binMultiplier; + } else if (useBillingCycleQuery) { + // Custom ranges + intervalOffset = finalBinCount; + } else { + // Standard intervals (7d, 30d, 90d, 24h) + intervalOffset = finalBinCount - 1; + } const queryParams = { org_id: org?.id, diff --git a/shared/api/events/aggregation/eventAggregationSchema.ts b/shared/api/events/aggregation/eventAggregationSchema.ts index 2f96ae318..daac74c86 100644 --- a/shared/api/events/aggregation/eventAggregationSchema.ts +++ b/shared/api/events/aggregation/eventAggregationSchema.ts @@ -12,6 +12,10 @@ export const RangeEnum = z.enum([ export type RangeEnum = z.infer; +export const BILLING_CYCLE_INTERVALS = ["1bc", "3bc", "last_cycle"] as const; + +export type BillingCycleIntervalEnum = (typeof BILLING_CYCLE_INTERVALS)[number]; + export const BinSizeEnum = z.enum(["day", "hour"]).default("day"); export type BinSizeEnum = z.infer;