From f4ea40ce8d2d52da04cbf01317e8e7a659929806 Mon Sep 17 00:00:00 2001 From: SirTenzin <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:15:12 +0000 Subject: [PATCH] Add processors filter to customer list dropdown for filtering by Stripe, RevenueCat, or Vercel --- .../internal/customers/CusSearchService.ts | 64 +++++++++++++++ server/src/internal/customers/CusService.ts | 47 ++++++++--- .../src/internal/customers/getFullCusQuery.ts | 25 ++++++ .../handlers/handleListCustomersV2.ts | 3 +- .../customers/crud/listCustomersParamsV2.ts | 8 ++ shared/models/cusModels/cusTable.ts | 6 ++ .../filter-dropdown/ProcessorSubMenu.tsx | 80 +++++++++++++++++++ .../components/filter-dropdown/SavedViews.tsx | 2 + .../customers/hooks/useCusSearchQuery.tsx | 2 + .../customers/hooks/useCustomerFilters.tsx | 6 ++ .../customers/hooks/useFullCusSearchQuery.tsx | 2 + .../CustomerListFilterButton.tsx | 7 +- .../table/customer-list/CustomerListTable.tsx | 4 +- 13 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 vite/src/views/customers/components/filter-dropdown/ProcessorSubMenu.tsx diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index 54bf456bb..5b0231984 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -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 => 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 => 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 => 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 => c !== undefined), + ) + : undefined, ); // Build the where clause for base query diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index b27933c87..7c5ea8fac 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -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 => 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, ), ); diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 563150f7c..dae50372e 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -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` `); }; diff --git a/server/src/internal/customers/handlers/handleListCustomersV2.ts b/server/src/internal/customers/handlers/handleListCustomersV2.ts index f31997cef..45b87607b 100644 --- a/server/src/internal/customers/handlers/handleListCustomersV2.ts +++ b/server/src/internal/customers/handlers/handleListCustomersV2.ts @@ -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 }; diff --git a/shared/api/customers/crud/listCustomersParamsV2.ts b/shared/api/customers/crud/listCustomersParamsV2.ts index 123208afa..181cf4e9b 100644 --- a/shared/api/customers/crud/listCustomersParamsV2.ts +++ b/shared/api/customers/crud/listCustomersParamsV2.ts @@ -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; diff --git a/shared/models/cusModels/cusTable.ts b/shared/models/cusModels/cusTable.ts index f6cf2e8f1..43a40842a 100644 --- a/shared/models/cusModels/cusTable.ts +++ b/shared/models/cusModels/cusTable.ts @@ -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(); diff --git a/vite/src/views/customers/components/filter-dropdown/ProcessorSubMenu.tsx b/vite/src/views/customers/components/filter-dropdown/ProcessorSubMenu.tsx new file mode 100644 index 000000000..cecb0457f --- /dev/null +++ b/vite/src/views/customers/components/filter-dropdown/ProcessorSubMenu.tsx @@ -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: , + }, + { + value: "revenuecat", + label: "RevenueCat", + icon: , + }, + { + value: "vercel", + label: "Vercel", + icon: , + }, + ]; + +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 ( + + + Processors + {hasSelections && ( + + {selectedProcessors.length} + + )} + + + {PROCESSORS.map(({ value, label, icon }) => { + const isActive = selectedProcessors.includes(value); + return ( + { + e.preventDefault(); + toggleProcessor(value); + }} + onSelect={(e) => e.preventDefault()} + className="flex items-center gap-2 cursor-pointer text-sm" + > + + {icon} + {label} + + ); + })} + + + ); +}; diff --git a/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx b/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx index 49d072f85..0aa6b5ef9 100644 --- a/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx +++ b/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx @@ -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`); diff --git a/vite/src/views/customers/hooks/useCusSearchQuery.tsx b/vite/src/views/customers/hooks/useCusSearchQuery.tsx index a7798542a..77fb46117 100644 --- a/vite/src/views/customers/hooks/useCusSearchQuery.tsx +++ b/vite/src/views/customers/hooks/useCusSearchQuery.tsx @@ -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, diff --git a/vite/src/views/customers/hooks/useCustomerFilters.tsx b/vite/src/views/customers/hooks/useCustomerFilters.tsx index 94c1e7ee7..555026198 100644 --- a/vite/src/views/customers/hooks/useCustomerFilters.tsx +++ b/vite/src/views/customers/hooks/useCustomerFilters.tsx @@ -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, ]); diff --git a/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx b/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx index 77a5338b4..26b6b95bc 100644 --- a/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx +++ b/vite/src/views/customers/hooks/useFullCusSearchQuery.tsx @@ -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 }, diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx index 920b762b4..c7c598215 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListFilterButton.tsx @@ -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() { + {views.length > 0 && ( diff --git a/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx b/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx index cd62c7691..a928efc55 100644 --- a/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx +++ b/vite/src/views/customers2/components/table/customer-list/CustomerListTable.tsx @@ -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)