Add processors filter to customer list dropdown for filtering by Stripe, RevenueCat, or Vercel

This commit is contained in:
SirTenzin
2026-04-08 00:15:12 +00:00
parent bc8fc2f98e
commit f4ea40ce8d
13 changed files with 239 additions and 17 deletions

View File

@@ -53,6 +53,7 @@ interface SearchFilters {
status?: string[];
version?: string[];
none?: boolean;
processor?: string[];
}
export class CusSearchService {
@@ -230,6 +231,22 @@ export class CusSearchService {
ilike(customers.email, `%${search}%`),
)
: undefined,
filters.processor?.length
? or(
...filters.processor
.map((proc) => {
if (proc === "stripe")
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
if (proc === "vercel")
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
return undefined;
})
.filter((c): c is NonNullable<typeof c> => c !== undefined),
)
: undefined,
);
// Build the where clause
@@ -409,6 +426,21 @@ export class CusSearchService {
)
: undefined,
noneFilter,
filters?.processor?.length
? or(
...filters.processor
.map((proc) => {
if (proc === "stripe")
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
if (proc === "vercel")
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
return undefined;
})
.filter((c): c is NonNullable<typeof c> => c !== undefined),
)
: undefined,
);
let baseQuery;
@@ -451,6 +483,23 @@ export class CusSearchService {
)
: undefined,
noneFilter,
filters?.processor?.length
? or(
...filters.processor
.map((proc) => {
if (proc === "stripe")
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
if (proc === "vercel")
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
return undefined;
})
.filter(
(c): c is NonNullable<typeof c> => c !== undefined,
),
)
: undefined,
),
),
]);
@@ -553,6 +602,21 @@ export class CusSearchService {
ilike(customers.email, `%${search}%`),
)
: undefined,
filters?.processor?.length
? or(
...filters.processor
.map((proc) => {
if (proc === "stripe")
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
if (proc === "vercel")
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
return undefined;
})
.filter((c): c is NonNullable<typeof c> => c !== undefined),
)
: undefined,
);
// Build the where clause for base query

View File

@@ -302,10 +302,10 @@ export class CusService {
ctx: AutumnContext;
query?: Pick<
ListCustomersV2Params,
"plans" | "search" | "subscription_status"
"plans" | "search" | "subscription_status" | "processors"
>;
}): Promise<{ total_filtered_count: number }> {
if (!hasCustomerListFilters(query ?? {})) {
if (!hasCustomerListFilters({ ...query })) {
const { total_count } = await CusService.countByOrgIdAndEnv({ ctx });
return { total_filtered_count: total_count };
}
@@ -315,6 +315,23 @@ export class CusService {
? [query.subscription_status as CusProductStatus]
: ACTIVE_STATUSES;
const processorFilter =
query?.processors?.length
? or(
...query.processors
.map((proc) => {
if (proc === "stripe")
return sql`(${customers.processor}->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`;
if (proc === "vercel")
return sql`(${customers.processors}->>'vercel' IS NOT NULL)`;
return undefined;
})
.filter((c): c is NonNullable<typeof c> => c !== undefined),
)
: undefined;
if (!query?.plans?.length) {
const [result] = await ctx.db
.select({ total_filtered_count: count() })
@@ -330,6 +347,7 @@ export class CusService {
ilike(customers.email, `%${search}%`),
)
: undefined,
processorFilter,
),
);
@@ -382,17 +400,20 @@ export class CusService {
.where(
and(
inArray(customerProducts.internal_product_id, internalProductIds),
search
? and(
eq(customers.org_id, ctx.org.id),
eq(customers.env, ctx.env),
or(
ilike(customers.id, `%${search}%`),
ilike(customers.name, `%${search}%`),
ilike(customers.email, `%${search}%`),
),
)
: undefined,
or(
search
? and(
eq(customers.org_id, ctx.org.id),
eq(customers.env, ctx.env),
or(
ilike(customers.id, `%${search}%`),
ilike(customers.name, `%${search}%`),
ilike(customers.email, `%${search}%`),
),
)
: undefined,
),
processorFilter,
),
);

View File

@@ -712,16 +712,19 @@ export const hasCustomerListFilters = ({
internalCustomerIds,
inStatuses: _inStatuses,
plans,
processors,
search,
}: {
internalCustomerIds?: string[];
inStatuses?: CusProductStatus[];
plans?: ListCustomersV2Params["plans"];
processors?: ListCustomersV2Params["processors"];
search?: string;
}) => {
return Boolean(
(internalCustomerIds && internalCustomerIds.length > 0) ||
(plans && plans.length > 0) ||
(processors && processors.length > 0) ||
search?.trim(),
);
};
@@ -730,11 +733,13 @@ export const getCustomerListFilterSql = ({
internalCustomerIds,
inStatuses,
plans,
processors,
search,
}: {
internalCustomerIds?: string[];
inStatuses?: CusProductStatus[];
plans?: ListCustomersV2Params["plans"];
processors?: ListCustomersV2Params["processors"];
search?: string;
}) => {
const filters = [];
@@ -787,5 +792,25 @@ export const getCustomerListFilterSql = ({
)`);
}
if (processors && processors.length > 0) {
const processorConditions = processors
.map((proc) => {
if (proc === "stripe")
return sql`(c.processor->>'id' IS NOT NULL)`;
if (proc === "revenuecat")
return sql`(c.processors->>'revenuecat' IS NOT NULL)`;
if (proc === "vercel")
return sql`(c.processors->>'vercel' IS NOT NULL)`;
return null;
})
.filter((c): c is SQL => c !== null);
if (processorConditions.length > 0) {
filters.push(
sql`AND (${sql.join(processorConditions, sql` OR `)})`,
);
}
}
return sql.join(filters, sql` `);
};

View File

@@ -18,7 +18,7 @@ export const handleListCustomersV2 = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
const hasFilteredQuery = Boolean(body.plans?.length || body.search?.trim() || body.subscription_status);
const hasFilteredQuery = Boolean(body.plans?.length || body.search?.trim() || body.subscription_status || body.processors?.length);
const [customers, totalCount] = await Promise.all([
CusBatchService.getPage({ ctx, query: body }),
@@ -32,6 +32,7 @@ export const handleListCustomersV2 = createRoute({
plans: body.plans,
search: body.search,
subscription_status: body.subscription_status,
processors: body.processors,
},
})
: { total_filtered_count: totalCount.total_count };

View File

@@ -25,6 +25,14 @@ export const ListCustomersV2ParamsSchema = createPaginationParamsSchema({
search: z.string().optional().meta({
description: "Search customers by id, name, or email",
}),
processors: z
.array(z.enum(["stripe", "revenuecat", "vercel"]))
.optional()
.meta({
description:
"Filter by customer processor type (stripe, revenuecat, vercel)",
}),
});
export type ListCustomersV2Params = z.infer<typeof ListCustomersV2ParamsSchema>;

View File

@@ -84,6 +84,12 @@ export const customers = pgTable(
table.env,
sql`${table.created_at} DESC`,
),
index("idx_customers_processors_revenuecat").on(
sql`(${table.processors} ->> 'revenuecat')`,
),
index("idx_customers_processors_vercel").on(
sql`(${table.processors} ->> 'vercel')`,
),
],
).enableRLS();

View File

@@ -0,0 +1,80 @@
import { CoinVerticalIcon, TriangleIcon } from "@phosphor-icons/react";
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@/components/v2/dropdowns/DropdownMenu";
import { RevenueCatIcon } from "@/components/v2/icons/AutumnIcons";
import { useCustomerFilters } from "../../hooks/useCustomerFilters";
type Processor = "stripe" | "revenuecat" | "vercel";
const PROCESSORS: { value: Processor; label: string; icon: React.ReactNode }[] =
[
{
value: "stripe",
label: "Stripe",
icon: <CoinVerticalIcon size={14} weight="fill" />,
},
{
value: "revenuecat",
label: "RevenueCat",
icon: <RevenueCatIcon size={14} />,
},
{
value: "vercel",
label: "Vercel",
icon: <TriangleIcon size={14} weight="fill" />,
},
];
export const ProcessorSubMenu = () => {
const { queryStates, setFilters } = useCustomerFilters();
const selectedProcessors = queryStates.processor || [];
const hasSelections = selectedProcessors.length > 0;
const toggleProcessor = (processor: Processor) => {
const isSelected = selectedProcessors.includes(processor);
const updated = isSelected
? selectedProcessors.filter((p: string) => p !== processor)
: [...selectedProcessors, processor];
setFilters({ processor: updated });
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer">
Processors
{hasSelections && (
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
{selectedProcessors.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PROCESSORS.map(({ value, label, icon }) => {
const isActive = selectedProcessors.includes(value);
return (
<DropdownMenuItem
key={value}
onClick={(e) => {
e.preventDefault();
toggleProcessor(value);
}}
onSelect={(e) => e.preventDefault()}
className="flex items-center gap-2 cursor-pointer text-sm"
>
<Checkbox checked={isActive} className="border-border" />
{icon}
{label}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
};

View File

@@ -47,12 +47,14 @@ export const SavedViews = ({
const statusParam = params.get("status") || "";
const versionParam = params.get("version") || "";
const noneParam = params.get("none");
const processorParam = params.get("processor") || "";
setFilters({
q: params.get("q") || "",
status: statusParam ? statusParam.split(",").filter(Boolean) : [],
version: versionParam ? versionParam.split(",").filter(Boolean) : [],
none: noneParam === "true",
processor: processorParam ? processorParam.split(",").filter(Boolean) : [],
});
toast.success(`Applied filters from ${view.name} view`);

View File

@@ -18,6 +18,7 @@ export const useCusSearchQuery = () => {
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
processor: queryStates.processor,
},
page: queryStates.page,
page_size: queryStates.pageSize,
@@ -45,6 +46,7 @@ export const useCusSearchQuery = () => {
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.processor,
trimmedSearch,
]),
queryFn: fetcher,

View File

@@ -23,6 +23,7 @@ const FILTER_PARAM_KEYS = [
"status",
"version",
"none",
"processor",
"page",
"pageSize",
] as const;
@@ -31,6 +32,7 @@ type PersistedCustomerFilters = {
status: string[];
version: string[];
none: boolean;
processor: string[];
pageSize: number;
};
@@ -63,6 +65,7 @@ function buildRestoredState({
status: filters?.status?.length ? filters.status : null,
version: filters?.version?.length ? filters.version : null,
none: filters?.none ? true : null,
processor: filters?.processor?.length ? filters.processor : null,
page: null,
pageSize:
filters?.pageSize && filters.pageSize !== 50 ? filters.pageSize : null,
@@ -75,6 +78,7 @@ const queryStatesConfig = {
status: parseAsArrayOf(parseAsString).withDefault([]),
version: parseAsArrayOf(parseAsString).withDefault([]),
none: parseAsBoolean.withDefault(false),
processor: parseAsArrayOf(parseAsString).withDefault([]),
page: parseAsInteger.withDefault(1),
pageSize: parseAsInteger.withDefault(50),
lastItemId: parseAsString.withDefault(""),
@@ -155,6 +159,7 @@ export function CustomerFiltersProvider({
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
processor: queryStates.processor,
pageSize: queryStates.pageSize,
}),
);
@@ -166,6 +171,7 @@ export function CustomerFiltersProvider({
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.processor,
queryStates.pageSize,
]);

View File

@@ -21,6 +21,7 @@ export const useFullCusSearchQuery = () => {
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.processor,
queryStates.q,
]),
queryFn: async ({ signal }) => {
@@ -34,6 +35,7 @@ export const useFullCusSearchQuery = () => {
status: queryStates.status,
version: queryStates.version,
none: queryStates.none,
processor: queryStates.processor,
},
},
{ signal },

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/v2/dropdowns/DropdownMenu";
import { cn } from "@/lib/utils";
import { FilterStatusSubMenu } from "@/views/customers/components/filter-dropdown/FilterStatusSubMenu";
import { ProcessorSubMenu } from "@/views/customers/components/filter-dropdown/ProcessorSubMenu";
import { ProductsSubMenu } from "@/views/customers/components/filter-dropdown/ProductsSubMenu";
import { SaveViewPopover } from "@/views/customers/components/filter-dropdown/SavedViewPopover";
import { SavedViews } from "@/views/customers/components/filter-dropdown/SavedViews";
@@ -25,14 +26,15 @@ export function CustomerListFilterButton() {
const hasActiveFilters =
queryStates.status.length > 0 ||
queryStates.version.length > 0 ||
queryStates.none;
queryStates.none ||
queryStates.processor.length > 0;
const { data, refetch: refetchSavedViews } = useSavedViewsQuery();
const views = data?.views || [];
const clearFilters = () => {
setFilters({ status: [], version: [], none: false });
setFilters({ status: [], version: [], none: false, processor: [] });
};
const closeFilterModal = () => {
@@ -62,6 +64,7 @@ export function CustomerListFilterButton() {
<DropdownMenuGroup className="p-1">
<FilterStatusSubMenu />
<ProductsSubMenu />
<ProcessorSubMenu />
</DropdownMenuGroup>
<DropdownMenuSeparator className="m-0" />
{views.length > 0 && (

View File

@@ -56,6 +56,7 @@ export function CustomerListTable({
queryStates.status,
queryStates.version,
queryStates.none,
queryStates.processor,
queryStates.q,
]),
// Placeholder queryFn - actual fetching is done by useFullCusSearchQuery
@@ -140,7 +141,8 @@ export function CustomerListTable({
const hasFilters =
queryStates.status.length > 0 ||
queryStates.version.length > 0 ||
queryStates.none;
queryStates.none ||
queryStates.processor.length > 0;
const hasActiveFiltersOrSearch = hasSearchQuery || hasFilters;
// Only show empty state if org has NO customers (no filters/search active and no results)