diff --git a/scripts/testGroups/g3.sh b/scripts/testGroups/g3.sh index 35540289b..694385e4f 100755 --- a/scripts/testGroups/g3.sh +++ b/scripts/testGroups/g3.sh @@ -25,5 +25,6 @@ BUN_PARALLEL_COMPACT \ 'server/tests/interval/upgrade' \ 'server/tests/interval/multiSub' \ 'server/tests/billing/cancel' \ + 'server/tests/billing/new-billing-subscription' \ --max=6 diff --git a/scripts/testGroups/g5.sh b/scripts/testGroups/g5.sh index 259991913..ee8f0cadb 100755 --- a/scripts/testGroups/g5.sh +++ b/scripts/testGroups/g5.sh @@ -4,16 +4,19 @@ source "$(dirname "$0")/config.sh" BUN_PARALLEL_COMPACT \ - 'server/tests/merged/separate' \ 'server/tests/merged/downgrade' \ + 'server/tests/merged/separate' \ 'server/tests/merged/add' \ - 'server/tests/merged/group' \ - 'server/tests/merged/prepaid' \ - 'server/tests/merged/upgrade' \ - 'server/tests/merged/addOn' \ - 'server/tests/merged/trial' \ - 'server/tests/core/cancel' \ - --max=6 + # 'server/tests/merged/group' \ + # 'server/tests/merged/prepaid' \ + # 'server/tests/merged/upgrade' \ + # 'server/tests/merged/addOn' \ + # 'server/tests/merged/trial' \ + # 'server/tests/core/cancel' \ + --max=6 \ + + + diff --git a/server/src/internal/analytics/AnalyticsService.ts b/server/src/internal/analytics/AnalyticsService.ts index 9c561c301..ff1a1a694 100644 --- a/server/src/internal/analytics/AnalyticsService.ts +++ b/server/src/internal/analytics/AnalyticsService.ts @@ -1,7 +1,3 @@ -/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */ - -/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */ - import { ErrCode, type FullCustomer, diff --git a/server/src/internal/analytics/internalAnalyticsRouter.ts b/server/src/internal/analytics/internalAnalyticsRouter.ts index aad51eeca..ac1ac9bea 100644 --- a/server/src/internal/analytics/internalAnalyticsRouter.ts +++ b/server/src/internal/analytics/internalAnalyticsRouter.ts @@ -4,6 +4,7 @@ import { FeatureType, type FullCusProduct, type FullCustomer, + type RangeEnum, } from "@autumn/shared"; import { Router } from "express"; import { StatusCodes } from "http-status-codes"; @@ -12,6 +13,7 @@ import RecaseError from "@/utils/errorUtils.js"; import type { ExtendedRequest } from "@/utils/models/Request.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { CusService } from "../customers/CusService.js"; +import { EventsAggregationService } from "../events/EventsAggregationService.js"; import { AnalyticsService } from "./AnalyticsService.js"; export const analyticsRouter = Router(); @@ -111,7 +113,7 @@ analyticsRouter.post("/events", async (req: any, res: any) => handler: async () => { AnalyticsService.handleEarlyExit(); const { db, org, env, features } = req; - let { interval, event_names, customer_id } = req.body; + let { interval, event_names, customer_id, group_by } = req.body; let topEvents: { featureIds: string[]; eventNames: string[] } | undefined; @@ -159,15 +161,29 @@ analyticsRouter.post("/events", async (req: any, res: any) => event_names = event_names.filter((name: string) => name !== ""); } - const events = await AnalyticsService.getTimeseriesEvents({ - req, + // const events = await AnalyticsService.getTimeseriesEvents({ + // req, + // params: { + // customer_id, + // interval, + // event_names, + // }, + // customer, + // aggregateAll, + // }); + + const binSize = interval === "24h" ? "hour" : "day"; + + const events = await EventsAggregationService.getTimeseriesEvents({ + ctx: req, params: { customer_id, - interval, + interval: interval as RangeEnum, event_names, + bin_size: binSize, + aggregateAll, + group_by: group_by, }, - customer, - aggregateAll, }); res.status(200).json({ diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index c6cbaa098..25391c474 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -340,7 +340,9 @@ export const createFullCusProduct = async ({ if ( (isOneOff(prices) || (isFreeProduct(prices) && product.is_add_on)) && notNullish(existingCusProduct) && - !attachParams.isCustom + !attachParams.isCustom && + !existingCusProduct.is_custom && + product.version === existingCusProduct.product.version ) { await updateOneTimeCusProduct({ db, @@ -453,24 +455,27 @@ export const createFullCusProduct = async ({ quantity: productOptions?.quantity ?? undefined, }); - // Expire previous product if not one off and add on...? - if (isFreeProduct(prices) && product.is_add_on) { - const { curSameProduct } = getExistingCusProducts({ - product, - cusProducts: attachParams.cusProducts!, - internalEntityId: attachParams.internalEntityId, - }); + // // Expire previous add on product if not one off... + // if (product.is_add_on && !isOneOff(prices)) { + // const { curSameProduct } = getExistingCusProducts({ + // product, + // cusProducts: attachParams.cusProducts!, + // internalEntityId: attachParams.internalEntityId, + // }); - if (curSameProduct) { - await CusProductService.update({ - db, - cusProductId: curSameProduct.id, - updates: { - status: CusProductStatus.Expired, - }, - }); - } - } + // const curPrices = curSameProduct + // ? cusProductToPrices({ cusProduct: curSameProduct }) + // : []; + // if (curSameProduct && !isOneOff(curPrices) && !isFreeProduct(curPrices)) { + // await CusProductService.update({ + // db, + // cusProductId: curSameProduct.id, + // updates: { + // status: CusProductStatus.Expired, + // }, + // }); + // } + // } if (!isOneOff(prices) && !product.is_add_on) { await expireOrDeleteCusProduct({ diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts index a3b733bc1..19f287364 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/handleUpgradeFlow.ts @@ -134,6 +134,15 @@ export const handleUpgradeFlow = async ({ fromCreate: attachParams.products.length === 0, // just for now, if no products, it comes from cancel product... }); + // // Renew sub + // console.log("Sub is canceled!", subIsCanceled({ sub: res.updatedSub })); + // if (subIsCanceled({ sub: res.updatedSub })) { + // await attachParams.stripeCli.subscriptions.update(res.updatedSub.id, { + // cancel_at_period_end: false, + // cancel_at: null, + // }); + // } + if (res?.latestInvoice) { logger.info(`UPGRADE FLOW: inserting invoice ${res.latestInvoice.id}`); await insertInvoiceFromAttach({ @@ -200,7 +209,6 @@ export const handleUpgradeFlow = async ({ if (attachParams.products.length > 0) { logger.info(`UPGRADE FLOW: creating new cus product`); const anchorToUnix = sub ? getEarliestPeriodEnd({ sub }) * 1000 : undefined; - console.log("Sub status:", sub?.status); let canceledAt: number | undefined; if (sub && subIsCanceled({ sub })) { diff --git a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts index ebbe50034..7e3f6cc28 100644 --- a/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts +++ b/server/src/internal/customers/attach/attachFunctions/upgradeFlow/updateStripeSub2.ts @@ -13,6 +13,7 @@ import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/free import { SubService } from "@/internal/subscriptions/SubService.js"; import { nullish } from "@/utils/genUtils.js"; import type { ItemSet } from "@/utils/models/ItemSet.js"; +import { subIsCanceled } from "../../../../../external/stripe/stripeSubUtils.js"; import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js"; import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js"; import { createAndFilterContUseItems } from "../../attachUtils/getContUseItems/createContUseInvoiceItems.js"; @@ -88,7 +89,9 @@ export const updateStripeSub2 = async ({ expand: ["latest_invoice"], - cancel_at_period_end: false, + // cancel_at_period_end: false, + // TODO: will error if sub managed by a schedule + cancel_at_period_end: subIsCanceled({ sub: curSub }) ? false : undefined, }); let latestInvoice = updatedSub.latest_invoice as Stripe.Invoice | null; diff --git a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts index 58df5a8c4..52bc914d7 100644 --- a/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts +++ b/server/src/internal/customers/attach/attachUtils/getAttachBranch.ts @@ -16,6 +16,7 @@ import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePric import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js"; import { isFreeProduct, + isOneOff, isProductUpgrade, } from "@/internal/products/productUtils.js"; import { notNullish } from "@/utils/genUtils.js"; @@ -321,7 +322,10 @@ export const getAttachBranch = async ({ }); // 3. Same product - if (curSameProduct) { + const sameProductOneOff = + curSameProduct && + isOneOff(cusProductToPrices({ cusProduct: curSameProduct })); + if (curSameProduct && !sameProductOneOff) { return await getSameProductBranch({ attachParams, fromPreview }); } diff --git a/server/src/internal/customers/cancel/cancelRouter.ts b/server/src/internal/customers/cancel/cancelRouter.ts index 1607285a2..1ab2bf49f 100644 --- a/server/src/internal/customers/cancel/cancelRouter.ts +++ b/server/src/internal/customers/cancel/cancelRouter.ts @@ -27,6 +27,7 @@ cancelRouter.post("", async (req, res) => entity_id, cancel_immediately, prorate: bodyProrate, + customer_product_id, } = req.body; const expireImmediately = cancel_immediately || false; @@ -53,13 +54,17 @@ cancelRouter.post("", async (req, res) => const cusProducts = fullCus.customer_products; const entity = fullCus.entity; - const cusProduct = cusProducts.find( - (cusProduct: FullCusProduct) => - cusProduct.product.id === product_id && - (entity - ? cusProduct.internal_entity_id === entity.internal_id - : nullish(cusProduct.internal_entity_id)), - ); + const cusProduct = cusProducts.find((cusProduct: FullCusProduct) => { + const productIdMatch = cusProduct.product.id === product_id; + const entityMatch = entity + ? cusProduct.internal_entity_id === entity.internal_id + : nullish(cusProduct.internal_entity_id); + + const cusProductIdMatch = customer_product_id + ? cusProduct.id === customer_product_id + : true; + return productIdMatch && entityMatch && cusProductIdMatch; + }); if (!cusProduct) { throw new CusProductNotFoundError({ diff --git a/server/src/internal/events/EventsAggregationService.ts b/server/src/internal/events/EventsAggregationService.ts index 669285cc9..58bf0a3a8 100644 --- a/server/src/internal/events/EventsAggregationService.ts +++ b/server/src/internal/events/EventsAggregationService.ts @@ -236,6 +236,47 @@ export class EventsAggregationService { const groupBy = getGroupByClause(); const groupByFieldName = groupBy.fieldName; + // Validate distinct group count if group_by is provided + if (params.group_by && groupBy.groupBy) { + const propertyPath = params.group_by.replace("properties.", ""); + const pathSegments = propertyPath.split("."); + const escapedPathArgs = pathSegments + .map((seg) => `'${seg.replace(/'/g, "''")}'`) + .join(", "); + const groupField = `JSONExtractString(e.properties, ${escapedPathArgs})`; + + const distinctCountQuery = ` + SELECT COUNT(DISTINCT ${groupField}) as distinct_count + FROM org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) e + ${params.aggregateAll ? "" : "WHERE e.customer_id = {customer_id:String}"} + `; + + const distinctResult = await (clickhouseClient as ClickHouseClient).query( + { + query: distinctCountQuery, + query_params: { + org_id: org?.id, + env: env, + customer_id: params.customer_id, + }, + format: "JSON", + }, + ); + + const distinctJson = (await distinctResult.json()) as ClickHouseResult<{ + distinct_count: string; + }>; + const distinctCount = Number(distinctJson.data[0]?.distinct_count ?? 0); + + if (distinctCount > 30) { + throw new RecaseError({ + message: `Too many distinct group values (${distinctCount}). Maximum allowed is 30. Please choose a property with fewer unique values.`, + code: ErrCode.InvalidInputs, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + } + const query = ` with customer_events as ( select * diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index b39909698..705806772 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -5,28 +5,15 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js"; -import { - constructProduct, - constructRawProduct, -} from "@/utils/scriptUtils/createTestProducts.js"; +import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js"; import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js"; import { CusService } from "../../src/internal/customers/CusService"; - -const paidAddOn = constructRawProduct({ - id: "addOn", - isAddOn: true, - items: [ - constructFeatureItem({ - featureId: TestFeature.Messages, - includedUsage: 500, - }), - ], -}); +import { initCustomerV3 } from "../../src/utils/scriptUtils/testUtils/initCustomerV3"; const free = constructProduct({ type: "free", isDefault: false, - isAddOn: true, + // isAddOn: true, items: [ constructFeatureItem({ featureId: TestFeature.Messages, @@ -43,13 +30,33 @@ const pro = constructProduct({ featureId: TestFeature.Messages, includedUsage: 100, }), - // constructFeatureItem({ - // featureId: TestFeature.Workflows, - // includedUsage: 10, - // }), ], }); +const premium = constructProduct({ + type: "premium", + items: [ + constructFeatureItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + }), + ], +}); + +// const oneOffCredits = constructRawProduct({ +// id: "one_off_credits", +// items: [ +// constructPrepaidItem({ +// featureId: TestFeature.Credits, +// billingUnits: 100, +// price: 10, +// isOneOff: true, +// resetUsageWhenEnabled: false, +// }), +// ], +// isAddOn: true, +// }); + const testCase = "temp"; describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { @@ -64,22 +71,31 @@ describe(`${chalk.yellowBright("temp: temporary script for testing")}`, () => { env: ctx.env, }); - // const result = await initCustomerV3({ - // ctx, - // customerId, - // withTestClock: true, - // attachPm: "success", - // }); + await initCustomerV3({ + ctx, + customerId, + withTestClock: true, + attachPm: "success", + }); await initProductsV0({ ctx, - products: [free], + products: [free, pro, premium], prefix: testCase, }); + await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + await autumnV1.attach({ + customer_id: customerId, + product_id: free.id, + }); // await autumnV1.attach({ // customer_id: customerId, - // product_id: free.id, + // product_id: pro.id, // }); }); }); diff --git a/server/tests/attach/addOn/addOn1.test.ts b/server/tests/attach/addOn/addOn1.test.ts index c282efcb0..3e30d0697 100644 --- a/server/tests/attach/addOn/addOn1.test.ts +++ b/server/tests/attach/addOn/addOn1.test.ts @@ -102,7 +102,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f }), }); - test("should update add on product", async () => { + test("should attach new free add on product", async () => { const preview = await autumn.attachPreview({ customer_id: customerId, product_id: addOn.id, @@ -131,7 +131,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing free add on, and updating f ...addOn, items: customItems, }, - otherProducts: [pro], + otherProducts: [pro, addOn], }); }); }); diff --git a/server/tests/merged/downgrade/mergedDowngrade2.test.ts b/server/tests/merged/downgrade/mergedDowngrade2.test.ts index 514cb9309..1ccd4c524 100644 --- a/server/tests/merged/downgrade/mergedDowngrade2.test.ts +++ b/server/tests/merged/downgrade/mergedDowngrade2.test.ts @@ -5,13 +5,13 @@ import { LegacyVersion, type Organization, } from "@autumn/shared"; -import chalk from "chalk"; -import type { Stripe } from "stripe"; import { TestFeature } from "@tests/setup/v2Features.js"; import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js"; import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js"; import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import chalk from "chalk"; +import type { Stripe } from "stripe"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { diff --git a/shared/api/_openapi2.0_/coreOpenApi.ts b/shared/api/_openapi2.0_/coreOpenApi.ts index cfcfa3062..dc480062d 100644 --- a/shared/api/_openapi2.0_/coreOpenApi.ts +++ b/shared/api/_openapi2.0_/coreOpenApi.ts @@ -1,6 +1,6 @@ import { AttachBodyV0Schema, - AttachResponseV0Schema, + AttachResponseV1Schema, CancelBodySchema, CancelResultSchema, CheckoutParamsV0Schema, @@ -26,7 +26,6 @@ import { } from "../common/jsDocs.js"; import { GetBillingPortalBodySchema, - GetBillingPortalQuerySchema, GetBillingPortalResponseSchema, } from "../customers/customerOpModels.js"; @@ -60,7 +59,7 @@ export const coreOps: ZodOpenApiPathsObject = { description: "Product attached successfully", content: { "application/json": { - schema: AttachResponseV0Schema, + schema: AttachResponseV1Schema, }, }, }, @@ -173,26 +172,7 @@ export const coreOps: ZodOpenApiPathsObject = { }, }, }, - // "/billing_portal": { - // post: { - // summary: "Create Billing Portal Session", - // description: billingPortalJsDoc, - // tags: ["core"], - // requestBody: { - // content: { - // "application/json": { schema: BillingPortalParamsSchema }, - // }, - // }, - // responses: { - // "200": { - // description: "200 OK", - // content: { - // "application/json": { schema: BillingPortalResultSchema }, - // }, - // }, - // }, - // }, - // }, + "/customers/{customer_id}/billing_portal": { post: { summary: "Create Billing Portal Session", @@ -202,7 +182,7 @@ export const coreOps: ZodOpenApiPathsObject = { path: z.object({ customer_id: z.string(), }), - query: GetBillingPortalQuerySchema, + // query: GetBillingPortalQuerySchema, }, requestBody: { content: { @@ -223,23 +203,4 @@ export const coreOps: ZodOpenApiPathsObject = { }, }, }, - - // "/usage": { - // post: { - // summary: "Set Usage", - // description: setUsageJsDoc, - // tags: ["core"], - // requestBody: { - // content: { - // "application/json": { schema: SetUsageParamsSchema }, - // }, - // }, - // responses: { - // "200": { - // description: "200 OK", - // content: { "application/json": { schema: SuccessResponseSchema } }, - // }, - // }, - // }, - // }, }; diff --git a/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx b/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx index 5cb925730..ff010e400 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsGraph.tsx @@ -75,6 +75,7 @@ export function EventsBarChart({ chartConfig: any; }) { const { selectedInterval } = useAnalyticsContext(); + const [options, setOptions] = useState({ data: data.data, series: chartConfig, @@ -138,11 +139,9 @@ export function EventsBarChart({ }, legend: { enabled: false, - item: { - label: { - color: "#52525b", - }, - }, + }, + tooltip: { + enabled: true, }, }); diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index 8df135b2d..710fe27d8 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -1,7 +1,7 @@ -import { ErrCode, type Feature, FeatureType } from "@autumn/shared"; +import { ErrCode } from "@autumn/shared"; import { ChartBarIcon, DatabaseIcon } from "@phosphor-icons/react"; import type { AgGridReact } from "ag-grid-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; @@ -14,6 +14,11 @@ import { useAnalyticsData, useRawAnalyticsData, } from "./hooks/useAnalyticsData"; +import { extractPropertyKeys } from "./utils/extractPropertyKeys"; +import { + generateChartConfig, + transformGroupedData, +} from "./utils/transformGroupedChartData"; export const AnalyticsView = () => { const [searchParams] = useSearchParams(); @@ -25,6 +30,7 @@ export const AnalyticsView = () => { const [currentPage, setCurrentPage] = useState(0); const [totalPages, setTotalPages] = useState(0); const [totalRows, setTotalRows] = useState(0); + const [groupFilter, setGroupFilter] = useState(null); const gridRef = useRef(null); const navigate = useNavigate(); @@ -39,43 +45,78 @@ export const AnalyticsView = () => { bcExclusionFlag, topEventsLoading, topEvents, + groupBy, } = useAnalyticsData({ hasCleared }); + // Clear the filter when groupBy changes + useEffect(() => { + setGroupFilter(null); + }, [groupBy]); + + // Extract unique group values from events data for filtering + const availableGroupValues = useMemo(() => { + if (!groupBy || !events?.data) { + return []; + } + + const groupByColumn = `properties.${groupBy}`; + const uniqueValues = new Set(); + + for (const row of events.data) { + const value = row[groupByColumn]; + if (value !== undefined && value !== null && value !== "") { + uniqueValues.add(String(value)); + } + } + + return Array.from(uniqueValues).sort(); + }, [groupBy, events?.data]); + const { rawEvents, queryLoading: rawQueryLoading } = useRawAnalyticsData(); - const chartConfig = events?.meta - .filter((x: { name: string }) => x.name !== "period") - .map((x: { name: string }, index: number) => { - if (x.name !== "period") { - const colorIndex = index % colors.length; + // Extract property keys from raw events for the group by dropdown + const propertyKeys = useMemo(() => { + return extractPropertyKeys({ rawEvents: rawEvents?.data }); + }, [rawEvents?.data]); - return { - xKey: "period", - yKey: x.name, - type: "bar", - stacked: true, - yName: - features.find((feature: Feature) => { - const eventName = x.name.replace("_count", ""); + // Transform and configure chart data + const { chartData, chartConfig } = useMemo(() => { + if (!events) { + return { chartData: null, chartConfig: null }; + } - // console.log("Feature: ", feature, eventName); + // Apply frontend filter if a group filter is selected + let filteredEvents = events; + if (groupBy && groupFilter) { + const groupByColumn = `properties.${groupBy}`; + const filteredData = events.data.filter( + (row: Record) => + String(row[groupByColumn]) === groupFilter, + ); + filteredEvents = { + ...events, + data: filteredData, + rows: filteredData.length, + }; + } - if (feature.type === FeatureType.Boolean) return false; - - if (feature.id === eventName) { - return true; - } - - if (feature.event_names && feature.event_names.length > 0) { - return feature.event_names.includes(eventName); - } - return false; - })?.name || x.name.replace("_count", ""), - fill: colors[colorIndex], - }; - } else return null; + // Transform data for grouped display (pivots rows into columns per group) + const transformed = transformGroupedData({ + events: filteredEvents, + groupBy, }); + // Generate chart config with different colors per group + const config = generateChartConfig({ + events: transformed, + features, + groupBy, + originalColors: colors, + }); + + return { chartData: transformed, chartConfig: config }; + }, [events, features, groupBy, groupFilter]); + useEffect(() => { if (error?.response?.data?.code === ErrCode.ClickHouseDisabled) { setClickHouseDisabled(true); @@ -131,6 +172,10 @@ export const AnalyticsView = () => { totalRows, setTotalRows, topEvents, + propertyKeys, + groupFilter, + setGroupFilter, + availableGroupValues, }} >
@@ -151,13 +196,18 @@ export const AnalyticsView = () => { )}
- {events && events.data.length > 0 && ( + {chartData && chartData.data.length > 0 && (
- + [0]["data"] + } + chartConfig={chartConfig} + />
)} - {!events && !queryLoading && ( + {!chartData && !queryLoading && (

No events found. Please widen your filters.{" "} diff --git a/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx b/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx index 45125bdbb..419ee8363 100644 --- a/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx +++ b/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx @@ -12,6 +12,7 @@ import { IconButton } from "@/components/v2/buttons/IconButton"; import { useAnalyticsContext } from "../AnalyticsContext"; import { CustomerComboBox } from "./CustomerComboBox"; import { SelectFeatureDropdown } from "./SelectFeatureDropdown"; +import { SelectGroupByDropdown } from "./SelectGroupByDropdown"; export const INTERVALS: Record = { "24h": "Last 24 hours", @@ -23,8 +24,13 @@ export const INTERVALS: Record = { }; export const QueryTopbar = () => { - const { customer, selectedInterval, setSelectedInterval, bcExclusionFlag } = - useAnalyticsContext(); + const { + customer, + selectedInterval, + setSelectedInterval, + bcExclusionFlag, + propertyKeys, + } = useAnalyticsContext(); const navigate = useNavigate(); const location = useLocation(); @@ -80,9 +86,17 @@ export const QueryTopbar = () => { + {propertyKeys && propertyKeys.length > 0 && ( + + )}

); }; diff --git a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx new file mode 100644 index 000000000..9ffbd7fde --- /dev/null +++ b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx @@ -0,0 +1,154 @@ +import { CaretDownIcon, MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { Check } from "lucide-react"; +import { useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/v2/dropdowns/DropdownMenu"; +import { cn } from "@/lib/utils"; +import { useAnalyticsContext } from "../AnalyticsContext"; + +export const SelectGroupByDropdown = ({ + propertyKeys, + classNames, +}: { + propertyKeys: string[]; + classNames?: { + trigger?: string; + }; +}) => { + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const location = useLocation(); + + const { groupFilter, setGroupFilter, availableGroupValues } = + useAnalyticsContext(); + + const currentGroupBy = searchParams.get("group_by") || ""; + + const updateQueryParams = ({ groupBy }: { groupBy: string | null }) => { + const params = new URLSearchParams(location.search); + + if (groupBy) { + params.set("group_by", groupBy); + } else { + params.delete("group_by"); + } + + navigate(`${location.pathname}?${params.toString()}`); + }; + + const filteredOptions = propertyKeys.filter((key) => + key.toLowerCase().includes(searchValue.toLowerCase()), + ); + + const handleSelect = ({ property }: { property: string | null }) => { + updateQueryParams({ groupBy: property }); + setOpen(false); + }; + + const displayValue = currentGroupBy || "No grouping"; + + return ( + + + } + iconOrientation="right" + className={cn(classNames?.trigger, open && "btn-secondary-active")} + > + {currentGroupBy ? `Group: ${currentGroupBy}` : "Group By"} + + + + {propertyKeys.length > 5 && ( +
+ + setSearchValue(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4" + /> +
+ )} + +
+ handleSelect({ property: null })} + className="flex items-center justify-between" + > + No grouping + {!currentGroupBy && } + + + {propertyKeys.length > 0 && } + + {filteredOptions.length === 0 && propertyKeys.length > 0 && ( +
+ No properties found +
+ )} + + {filteredOptions.map((property) => ( + handleSelect({ property })} + className="flex items-center justify-between" + > + {property} + {currentGroupBy === property && ( + + )} + + ))} + + {/* Filter section - only shown when a groupBy is selected */} + {currentGroupBy && availableGroupValues.length > 0 && ( + <> + + + Filter by value + + setGroupFilter(null)} + className="flex items-center justify-between" + > + All values + {!groupFilter && } + + {availableGroupValues.map((value) => ( + setGroupFilter(value)} + className="flex items-center justify-between" + > + + {value} + + {groupFilter === value && ( + + )} + + ))} + + )} +
+
+
+ ); +}; + diff --git a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx index 9e466a731..be6265b0e 100644 --- a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx +++ b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx @@ -19,6 +19,7 @@ export const useAnalyticsData = ({ const featureIds = searchParams.get("feature_ids")?.split(","); const eventNames = searchParams.get("event_names")?.split(","); const interval = searchParams.get("interval"); + const groupBy = searchParams.get("group_by"); const { topEvents, isLoading: topEventsLoading } = useTopEventNames(); @@ -31,6 +32,9 @@ export const useAnalyticsData = ({ const { features: featuresData, isLoading: featuresLoading } = useFeaturesQuery(); + // Format group_by for API (must be prefixed with "properties.") + const formattedGroupBy = groupBy ? `properties.${groupBy}` : undefined; + // Create a simple queryKey with the actual values that change const queryKey = [ customerId, @@ -38,6 +42,7 @@ export const useAnalyticsData = ({ ...(eventNames || []).sort(), ...(featureIds || []).sort(), org?.slug, + groupBy, ]; const { @@ -50,6 +55,7 @@ export const useAnalyticsData = ({ customer_id: customerId || null, interval: interval || "30d", event_names: [...(eventNames || []), ...(featureIds || [])], + group_by: formattedGroupBy, }, queryKey: ["query-events", ...queryKey], options: { @@ -72,6 +78,7 @@ export const useAnalyticsData = ({ error: error?.code === ErrCode.ClickHouseDisabled ? null : error, bcExclusionFlag: data?.bcExclusionFlag ?? false, topEventsLoading, + groupBy, }; }; diff --git a/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts b/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts new file mode 100644 index 000000000..aa3d9a9d6 --- /dev/null +++ b/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts @@ -0,0 +1,69 @@ +/** + * Raw event structure from the analytics API + */ +interface RawEvent { + timestamp: string; + event_name: string; + value: number; + properties: Record | string; +} + +/** + * Internal properties to exclude from the group by dropdown + */ +const EXCLUDED_PROPERTIES = new Set(["value"]); + +/** + * Maximum number of events to scan for property keys + */ +const MAX_EVENTS_TO_SCAN = 1000; + +/** + * Extracts unique property keys from raw events for use in group by dropdown. + * Scans up to 1000 events and filters out internal properties. + */ +export function extractPropertyKeys({ + rawEvents, +}: { + rawEvents: RawEvent[] | undefined; +}): string[] { + if (!rawEvents || rawEvents.length === 0) { + return []; + } + + const propertyKeys = new Set(); + const eventsToScan = rawEvents.slice(0, MAX_EVENTS_TO_SCAN); + + for (const event of eventsToScan) { + const properties = parseProperties(event.properties); + if (!properties) continue; + + for (const key of Object.keys(properties)) { + if (!EXCLUDED_PROPERTIES.has(key)) { + propertyKeys.add(key); + } + } + } + + return Array.from(propertyKeys).sort(); +} + +/** + * Parses properties which may be a string (JSON) or already an object + */ +function parseProperties( + properties: Record | string | undefined, +): Record | null { + if (!properties) return null; + + if (typeof properties === "string") { + try { + return JSON.parse(properties); + } catch { + return null; + } + } + + return properties; +} + diff --git a/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts b/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts new file mode 100644 index 000000000..3e729b9ab --- /dev/null +++ b/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts @@ -0,0 +1,226 @@ +import type { Feature } from "@autumn/shared"; +import { FeatureType } from "@autumn/shared"; + +/** + * Row data from the events API + */ +type EventRow = Record; + +/** + * Events data structure from the API + */ +interface EventsData { + meta: Array<{ name: string }>; + rows: number; + data: EventRow[]; +} + +/** + * Chart series configuration + */ +interface ChartSeriesConfig { + xKey: string; + yKey: string; + type: "bar"; + stacked: boolean; + yName: string; + fill: string; +} + +/** + * Chart colors palette - more distinct colors for groups + */ +const CHART_COLORS = [ + "#9c5aff", // purple + "#27a7ff", // blue + "#10b981", // green + "#f59e0b", // orange + "#ef4444", // red + "#ec4899", // pink + "#06b6d4", // cyan + "#8b5cf6", // violet + "#14b8a6", // teal + "#f97316", // orange-dark +]; + +/** + * Gets feature name for a given event/feature key + */ +function getFeatureName({ + key, + features, +}: { + key: string; + features: Feature[]; +}): string { + const eventName = key.replace("_count", ""); + + const feature = features.find((f) => { + if (f.type === FeatureType.Boolean) return false; + if (f.id === eventName) return true; + if (f.event_names && f.event_names.length > 0) { + return f.event_names.includes(eventName); + } + return false; + }); + + return feature?.name || eventName; +} + +/** + * Transforms grouped data from backend format to chart-ready format. + * + * Backend returns (when group_by is used): + * [ + * { period: "2024-01-01", "properties.platform": "ios", messages_count: 5 }, + * { period: "2024-01-01", "properties.platform": "android", messages_count: 3 }, + * ] + * + * Chart needs: + * [ + * { period: "2024-01-01", "messages_count__ios": 5, "messages_count__android": 3 }, + * ] + */ +export function transformGroupedData({ + events, + groupBy, +}: { + events: EventsData; + groupBy: string | null; +}): EventsData { + if (!groupBy) { + return events; + } + + const groupByColumn = `properties.${groupBy}`; + + // Check if data has the group_by column + const hasGroupColumn = events.meta.some((m) => m.name === groupByColumn); + if (!hasGroupColumn) { + return events; + } + + // Get feature columns (exclude period and group_by column) + const featureColumns = events.meta + .filter((m) => m.name !== "period" && m.name !== groupByColumn) + .map((m) => m.name); + + // Collect all unique group values + const groupValues = new Set(); + for (const row of events.data) { + const groupValue = row[groupByColumn]; + if (groupValue !== undefined && groupValue !== null && groupValue !== "") { + groupValues.add(String(groupValue)); + } + } + + // Pivot data: group by period and create columns for each group value + const pivotedMap = new Map< + string | number, + Record + >(); + + for (const row of events.data) { + const period = row.period; + const groupValue = String(row[groupByColumn] || "unknown"); + + if (!pivotedMap.has(period)) { + pivotedMap.set(period, { period }); + } + + const pivotedRow = pivotedMap.get(period)!; + + // Add each feature value with the group suffix + for (const featureCol of featureColumns) { + const newKey = `${featureCol}__${groupValue}`; + pivotedRow[newKey] = row[featureCol] ?? 0; + } + } + + // Ensure all group combinations exist (fill with 0) + for (const pivotedRow of pivotedMap.values()) { + for (const featureCol of featureColumns) { + for (const groupValue of groupValues) { + const key = `${featureCol}__${groupValue}`; + if (pivotedRow[key] === undefined) { + pivotedRow[key] = 0; + } + } + } + } + + // Build new meta + const newMeta: Array<{ name: string }> = [{ name: "period" }]; + for (const featureCol of featureColumns) { + for (const groupValue of groupValues) { + newMeta.push({ name: `${featureCol}__${groupValue}` }); + } + } + + return { + meta: newMeta, + rows: pivotedMap.size, + data: Array.from(pivotedMap.values()), + }; +} + +/** + * Generates chart configuration with different colors per group. + */ +export function generateChartConfig({ + events, + features, + groupBy, + originalColors, +}: { + events: EventsData; + features: Feature[]; + groupBy: string | null; + originalColors: string[]; +}): ChartSeriesConfig[] { + const colorsToUse = groupBy ? CHART_COLORS : originalColors; + + if (!groupBy) { + // Non-grouped: original behavior + return events.meta + .filter((m) => m.name !== "period") + .map((m, index) => ({ + xKey: "period", + yKey: m.name, + type: "bar" as const, + stacked: true, + yName: getFeatureName({ key: m.name, features }), + fill: colorsToUse[index % colorsToUse.length], + })); + } + + // Grouped: create series for each feature__group combination + const config: ChartSeriesConfig[] = []; + let colorIndex = 0; + + for (const meta of events.meta) { + if (meta.name === "period") continue; + + // Parse feature__groupValue format + const parts = meta.name.split("__"); + if (parts.length < 2) continue; + + const featureKey = parts.slice(0, -1).join("__"); // Handle feature names with underscores + const groupValue = parts[parts.length - 1]; + + const featureName = getFeatureName({ key: featureKey, features }); + + config.push({ + xKey: "period", + yKey: meta.name, + type: "bar", + stacked: true, + yName: `${featureName} (${groupValue})`, + fill: colorsToUse[colorIndex % colorsToUse.length], + }); + + colorIndex++; + } + + return config; +} diff --git a/vite/src/views/customers2/components/table/customer-products/CancelProductDialog.tsx b/vite/src/views/customers2/components/table/customer-products/CancelProductDialog.tsx index 4e6894205..90bd2d2e2 100644 --- a/vite/src/views/customers2/components/table/customer-products/CancelProductDialog.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CancelProductDialog.tsx @@ -46,6 +46,7 @@ export const CancelProductDialog = ({ entity_id: entity?.id || entity?.internal_id, cancel_immediately: cancelImmediately, prorate: false, + customer_product_id: cusProduct.id, }); await refetch(); setOpen(false); diff --git a/vite/src/views/customers2/customer/CustomerPageDetails.tsx b/vite/src/views/customers2/customer/CustomerPageDetails.tsx index bbf6ca437..c18c104b1 100644 --- a/vite/src/views/customers2/customer/CustomerPageDetails.tsx +++ b/vite/src/views/customers2/customer/CustomerPageDetails.tsx @@ -1,11 +1,11 @@ -import { FingerprintIcon, Ticket } from "@phosphor-icons/react"; +import { FingerprintIcon, TicketIcon } from "@phosphor-icons/react"; import { CopyButton } from "@/components/v2/buttons/CopyButton"; import { useCusReferralQuery } from "@/views/customers/customer/hooks/useCusReferralQuery"; import { CustomerActions } from "./CustomerActions"; import { useCustomerContext } from "./CustomerContext"; const mutedDivClassName = - "py-0.5 px-1.5 rounded-lg text-t3 text-tiny flex items-center gap-1 h-6 max-w-48 truncate "; + "py-0.5 px-1.5 rounded-lg text-t3 text-tiny flex items-center gap-2 h-6 max-w-48 truncate bg-muted text-tiny-id"; const placeholderText = "NULL"; @@ -13,7 +13,7 @@ export const CustomerPageDetails = () => { const { customer } = useCustomerContext(); const { stripeCus } = useCusReferralQuery(); - const appliedCoupon = stripeCus?.discount?.coupon; + const appliedCoupon = stripeCus?.discount?.source; return (
@@ -48,9 +48,9 @@ export const CustomerPageDetails = () => {
)} {appliedCoupon && ( -
- - {appliedCoupon.name} +
+ + {appliedCoupon.coupon}
)}