Merge branch 'main' into fix/3ds-invoices-one-off

This commit is contained in:
amianthus
2025-12-18 11:24:21 +00:00
23 changed files with 747 additions and 168 deletions

View File

@@ -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

View File

@@ -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 \

View File

@@ -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,

View File

@@ -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({

View File

@@ -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({

View File

@@ -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 })) {

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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({

View File

@@ -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 *

View File

@@ -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,
// });
});
});

View File

@@ -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],
});
});
});

View File

@@ -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 {

View File

@@ -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 } },
// },
// },
// },
// },
};

View File

@@ -75,6 +75,7 @@ export function EventsBarChart({
chartConfig: any;
}) {
const { selectedInterval } = useAnalyticsContext();
const [options, setOptions] = useState<AgChartOptions>({
data: data.data,
series: chartConfig,
@@ -138,11 +139,9 @@ export function EventsBarChart({
},
legend: {
enabled: false,
item: {
label: {
color: "#52525b",
},
},
},
tooltip: {
enabled: true,
},
});

View File

@@ -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<string | null>(null);
const gridRef = useRef<AgGridReact>(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<string>();
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, string | number>) =>
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,
}}
>
<div className="flex flex-col gap-4 h-full relative w-full text-sm pb-8 max-w-5xl mx-auto px-10 pt-8">
@@ -151,13 +196,18 @@ export const AnalyticsView = () => {
)}
<div className="h-full overflow-hidden">
{events && events.data.length > 0 && (
{chartData && chartData.data.length > 0 && (
<div className="h-full overflow-hidden bg-interactive-secondary border max-h-[350px]">
<EventsBarChart data={events} chartConfig={chartConfig} />
<EventsBarChart
data={
chartData as Parameters<typeof EventsBarChart>[0]["data"]
}
chartConfig={chartConfig}
/>
</div>
)}
{!events && !queryLoading && (
{!chartData && !queryLoading && (
<div className="flex-1 px-10 pt-6">
<p className="text-t3 text-sm">
No events found. Please widen your filters.{" "}

View File

@@ -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<string, string> = {
"24h": "Last 24 hours",
@@ -23,8 +24,13 @@ export const INTERVALS: Record<string, string> = {
};
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 = () => {
</DropdownMenu>
<SelectFeatureDropdown
classNames={{
trigger: "h-full border-y-0 border-l-0 border-r-1",
trigger: "h-full border-y-0 border-l-0 border-r-0",
}}
/>
{propertyKeys && propertyKeys.length > 0 && (
<SelectGroupByDropdown
propertyKeys={propertyKeys}
classNames={{
trigger: "h-full border-y-0 border-l-0 border-r-1",
}}
/>
)}
</div>
);
};

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<IconButton
variant="secondary"
size="default"
icon={<CaretDownIcon size={12} weight="bold" />}
iconOrientation="right"
className={cn(classNames?.trigger, open && "btn-secondary-active")}
>
{currentGroupBy ? `Group: ${currentGroupBy}` : "Group By"}
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{propertyKeys.length > 5 && (
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border">
<MagnifyingGlassIcon className="size-4 text-t4" />
<input
type="text"
placeholder="Search properties..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4"
/>
</div>
)}
<div className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem
onClick={() => handleSelect({ property: null })}
className="flex items-center justify-between"
>
<span className="text-xs">No grouping</span>
{!currentGroupBy && <Check className="ml-2 h-3 w-3 text-t3" />}
</DropdownMenuItem>
{propertyKeys.length > 0 && <DropdownMenuSeparator />}
{filteredOptions.length === 0 && propertyKeys.length > 0 && (
<div className="py-4 text-center text-sm text-t4">
No properties found
</div>
)}
{filteredOptions.map((property) => (
<DropdownMenuItem
key={property}
onClick={() => handleSelect({ property })}
className="flex items-center justify-between"
>
<span className="text-xs font-mono">{property}</span>
{currentGroupBy === property && (
<Check className="ml-2 h-3 w-3 text-t3" />
)}
</DropdownMenuItem>
))}
{/* Filter section - only shown when a groupBy is selected */}
{currentGroupBy && availableGroupValues.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-t4 font-normal">
Filter by value
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setGroupFilter(null)}
className="flex items-center justify-between"
>
<span className="text-xs">All values</span>
{!groupFilter && <Check className="ml-2 h-3 w-3 text-t3" />}
</DropdownMenuItem>
{availableGroupValues.map((value) => (
<DropdownMenuItem
key={value}
onClick={() => setGroupFilter(value)}
className="flex items-center justify-between"
>
<span className="text-xs font-mono truncate max-w-[150px]">
{value}
</span>
{groupFilter === value && (
<Check className="ml-2 h-3 w-3 text-t3 shrink-0" />
)}
</DropdownMenuItem>
))}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -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,
};
};

View File

@@ -0,0 +1,69 @@
/**
* Raw event structure from the analytics API
*/
interface RawEvent {
timestamp: string;
event_name: string;
value: number;
properties: Record<string, unknown> | 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<string>();
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, unknown> | string | undefined,
): Record<string, unknown> | null {
if (!properties) return null;
if (typeof properties === "string") {
try {
return JSON.parse(properties);
} catch {
return null;
}
}
return properties;
}

View File

@@ -0,0 +1,226 @@
import type { Feature } from "@autumn/shared";
import { FeatureType } from "@autumn/shared";
/**
* Row data from the events API
*/
type EventRow = Record<string, string | number>;
/**
* 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<string>();
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<string, string | number>
>();
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;
}

View File

@@ -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);

View File

@@ -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 (
<div className="flex h-4 items-center">
@@ -48,9 +48,9 @@ export const CustomerPageDetails = () => {
</div>
)}
{appliedCoupon && (
<div className="py-0.5 px-1.5 bg-secondary rounded-lg text-t3 text-sm flex items-center gap-1 h-6 max-w-48 truncate">
<Ticket size={12} className="shrink-0" />
<span className="truncate">{appliedCoupon.name}</span>
<div className={mutedDivClassName}>
<TicketIcon size={13} className="shrink-0" />
<span className="truncate">{appliedCoupon.coupon}</span>
</div>
)}
<CustomerActions />