diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index c41ee9e2905..76f3a0d2726 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -63,6 +63,10 @@ const features: Feature[] = [{ title: 'Smarter Counts', description: 'Use optimized COUNT queries for API pagination when safe', flag: 'smarterCounts' +}, { + title: 'Multiple subscriptions filter', + description: 'Show a members notification and filter for multiple active Stripe subscriptions', + flag: 'multipleSubsFilter' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index 9ba05416c38..6ada7e3fcd6 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useState} from 'react'; import {AddLabelModal, DeleteModal, ImportMembersModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals'; import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components'; import {type ImportResponse} from './bulk-action-modals/import-members/state'; -import {LucideIcon} from '@tryghost/shade/utils'; +import {LucideIcon, formatNumber} from '@tryghost/shade/utils'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {buildMemberOperationParams} from '../member-query-params'; import {buildMembersUrl} from '../member-route'; @@ -230,25 +230,25 @@ const MembersActions: React.FC = ({ {hasFilterOrSearch - ? `Export ${memberCount.toLocaleString()} members` + ? `Export ${formatNumber(memberCount)} members` : 'Export all members'} setShowAddLabelModal(true)}> - Add label to {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'} + Add label to {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'} setShowRemoveLabelModal(true)}> - Remove label from {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'} + Remove label from {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'} setShowUnsubscribeModal(true)} > - Unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'} + Unsubscribe {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'} = ({ onClick={() => setShowDeleteModal(true)} > - Delete {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'} + Delete {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'} )} diff --git a/apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx b/apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx index 864d1d4be66..ae6848dc9da 100644 --- a/apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx +++ b/apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx @@ -57,6 +57,52 @@ describe('useMembersFilterState', () => { expect(result.current.hasFilterOrSearch).toBe(false); }); + it('preserves the multiple active Stripe customers raw filter', async () => { + const {result} = renderHook(() => { + const state = useMembersFilterState('UTC', { + preserveMultipleActiveStripeCustomersFilter: true + }); + const [searchParams] = useSearchParams(); + + return { + ...state, + query: searchParams.toString() + }; + }, { + wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1') + }); + + await waitFor(() => { + expect(result.current.nql).toBe('count.active_stripe_customers:>1'); + }); + + expect(result.current.filters).toEqual([]); + expect(result.current.query).toBe('filter=count.active_stripe_customers%3A%3E1'); + expect(result.current.hasFilterOrSearch).toBe(true); + }); + + it('drops the multiple active Stripe customers raw filter when preservation is disabled', async () => { + const {result} = renderHook(() => { + const state = useMembersFilterState('UTC'); + const [searchParams] = useSearchParams(); + + return { + ...state, + query: searchParams.toString() + }; + }, { + wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1') + }); + + await waitFor(() => { + expect(result.current.query).toBe(''); + }); + + expect(result.current.filters).toEqual([]); + expect(result.current.nql).toBeUndefined(); + expect(result.current.hasFilterOrSearch).toBe(false); + }); + it('retains supported filters and rewrites mixed URLs canonically', async () => { const {result} = renderHook(() => { const state = useMembersFilterState('UTC'); diff --git a/apps/posts/src/views/members/hooks/use-members-filter-state.ts b/apps/posts/src/views/members/hooks/use-members-filter-state.ts index 6eaa774f0fe..6ab70190c48 100644 --- a/apps/posts/src/views/members/hooks/use-members-filter-state.ts +++ b/apps/posts/src/views/members/hooks/use-members-filter-state.ts @@ -1,5 +1,6 @@ import {Filter} from '@tryghost/shade/patterns'; import {hasTimezoneSensitiveMemberFilter, parseMemberFilter, serializeMemberFilters} from '../member-filter-query'; +import {isMultipleActiveStripeCustomersFilter} from '../multiple-active-stripe-customers'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useSearchParams} from 'react-router'; @@ -23,6 +24,11 @@ interface ToSearchParamsOptions { filters: Filter[]; search: string; timezone: string; + rawFilter?: string; +} + +interface UseMembersFilterStateOptions { + preserveMultipleActiveStripeCustomersFilter?: boolean; } export function shouldDelayMembersDateFilterHydration( @@ -33,9 +39,9 @@ export function shouldDelayMembersDateFilterHydration( return Boolean(filterParam) && isSettingsLoading && !hasResolvedTimezone && hasTimezoneSensitiveMemberFilter(filterParam); } -function toSearchParams({baseSearchParams, filters, search, timezone}: ToSearchParamsOptions): URLSearchParams { +function toSearchParams({baseSearchParams, filters, search, timezone, rawFilter}: ToSearchParamsOptions): URLSearchParams { const params = new URLSearchParams(baseSearchParams); - const filter = serializeMemberFilters(filters, timezone); + const filter = rawFilter || serializeMemberFilters(filters, timezone); params.delete('filter'); params.delete('search'); @@ -51,24 +57,34 @@ function toSearchParams({baseSearchParams, filters, search, timezone}: ToSearchP return params; } -export function useMembersFilterState(timezone: string): UseMembersFilterStateReturn { +export function useMembersFilterState(timezone: string, hookOptions: UseMembersFilterStateOptions = {}): UseMembersFilterStateReturn { const [searchParams, setSearchParams] = useSearchParams(); const lastWrittenQueryRef = useRef(null); const filterParam = useMemo(() => searchParams.get('filter') ?? undefined, [searchParams]); const currentQuery = useMemo(() => searchParams.toString(), [searchParams]); + const preserveMultipleActiveStripeCustomersFilter = hookOptions.preserveMultipleActiveStripeCustomersFilter === true; const parsedFilters = useMemo(() => { return parseMemberFilter(filterParam, timezone); }, [filterParam, timezone]); const [filters, setDraftFilters] = useState(parsedFilters); + const preservedRawFilter = useMemo(() => { + return preserveMultipleActiveStripeCustomersFilter && filters.length === 0 && isMultipleActiveStripeCustomersFilter(filterParam) + ? filterParam + : undefined; + }, [filterParam, filters.length, preserveMultipleActiveStripeCustomersFilter]); const search = useMemo(() => { return searchParams.get('search') ?? ''; }, [searchParams]); const nql = useMemo(() => { + if (preservedRawFilter) { + return preservedRawFilter; + } + return serializeMemberFilters(filters, timezone); - }, [filters, timezone]); + }, [filters, preservedRawFilter, timezone]); useEffect(() => { if (currentQuery !== lastWrittenQueryRef.current) { @@ -86,7 +102,8 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe baseSearchParams: searchParams, filters, search, - timezone + timezone, + rawFilter: preservedRawFilter }); const nextQuery = nextParams.toString(); @@ -94,7 +111,7 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe lastWrittenQueryRef.current = nextQuery; setSearchParams(nextParams, {replace: true}); } - }, [currentQuery, filters, search, searchParams, setSearchParams, timezone]); + }, [currentQuery, filters, preservedRawFilter, search, searchParams, setSearchParams, timezone]); const setFilters = useCallback((nextFilters: Filter[], options: SetFiltersOptions = {}) => { const replace = options.replace ?? true; @@ -116,12 +133,13 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe baseSearchParams: searchParams, filters, search: nextSearch, - timezone + timezone, + rawFilter: preservedRawFilter }); lastWrittenQueryRef.current = nextParams.toString(); setSearchParams(nextParams, {replace}); - }, [filters, searchParams, setSearchParams, timezone]); + }, [filters, preservedRawFilter, searchParams, setSearchParams, timezone]); const clearFilters = useCallback(({replace = true}: SetFiltersOptions = {}) => { const nextParams = toSearchParams({ diff --git a/apps/posts/src/views/members/members.tsx b/apps/posts/src/views/members/members.tsx index f9237bbf3db..08bf9fdd585 100644 --- a/apps/posts/src/views/members/members.tsx +++ b/apps/posts/src/views/members/members.tsx @@ -6,32 +6,42 @@ import MembersHeaderSearch from './components/members-header-search'; import MembersHelpCards from './components/members-help-cards'; import MembersList from './components/members-list'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Button, EmptyIndicator, LoadingIndicator} from '@tryghost/shade/components'; +import {Banner, Button, EmptyIndicator, LoadingIndicator} from '@tryghost/shade/components'; +import {Config, useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {FilterBar, PageHeader} from '@tryghost/shade/patterns'; import {ListPage} from '@tryghost/shade/page-templates'; import {LucideIcon, cn, formatNumber} from '@tryghost/shade/utils'; +import {MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER, buildUserWithDismissedMultipleActiveStripeCustomersBanner, getMultipleActiveStripeCustomersBannerPreference, isMultipleActiveStripeCustomersFilter} from './multiple-active-stripe-customers'; +import {Setting, checkStripeEnabled, getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; import {buildMemberListSearchParams, getMemberActiveColumns} from './member-query-params'; +import {buildMembersUrl} from './member-route'; import {canBulkDeleteMembers, shouldShowMembersLoading} from './members-view-state'; -import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; +import {canManageMembers, useEditUser} from '@tryghost/admin-x-framework/api/users'; import {getSiteTimezone} from '@src/utils/get-site-timezone'; import {shouldDelayMembersDateFilterHydration, useMembersFilterState} from './hooks/use-members-filter-state'; +import {toast} from 'sonner'; import {useActiveMemberView, useMemberViews} from './hooks/use-member-views'; -import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; -import {useBrowseMembersInfinite} from '@tryghost/admin-x-framework/api/members'; +import {useBrowseMembers, useBrowseMembersInfinite} from '@tryghost/admin-x-framework/api/members'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; import {useDebounce} from 'use-debounce'; -import {useLocation, useSearchParams} from 'react-router'; +import {useLocation, useNavigate, useSearchParams} from 'react-router'; const SEARCH_DEBOUNCE_MS = 250; const MEMBERS_HELP_CARDS_LIMIT = 6; -const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ({timezone, membershipsEnabled}) => { +const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean; settings: Setting[]; config: Config}> = ({timezone, membershipsEnabled, settings, config}) => { const headerRef = useRef(null); const setHeaderContentRef = useCallback((node: HTMLDivElement | null) => { headerRef.current = node?.closest('[data-list-page="header"]') as HTMLDivElement | null; }, []); - const {filters, nql, search, setFilters, setSearch, hasFilterOrSearch, clearAll} = useMembersFilterState(timezone); + const multipleSubsFilterEnabled = config.labs?.multipleSubsFilter === true; + const {filters, nql, search, setFilters, setSearch, hasFilterOrSearch, clearAll} = useMembersFilterState(timezone, { + preserveMultipleActiveStripeCustomersFilter: multipleSubsFilterEnabled + }); const location = useLocation(); - const {data: configData} = useBrowseConfig(); + const navigate = useNavigate(); + const {data: currentUser} = useCurrentUser(); + const {mutateAsync: editUser, isLoading: isDismissingMultipleActiveStripeCustomersBanner} = useEditUser(); const savedViews = useMemberViews(); const activeView = useActiveMemberView(savedViews, nql); const [showMobileSearch, setShowMobileSearch] = useState(false); @@ -39,7 +49,43 @@ const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ( const [searchInput, setSearchInput] = useState(search); const [debouncedSearch] = useDebounce(searchInput, SEARCH_DEBOUNCE_MS); - const emailAnalyticsEnabled = configData?.config?.emailAnalytics === true; + const emailAnalyticsEnabled = config.emailAnalytics === true; + const hasStripeEnabled = checkStripeEnabled(settings, config); + const canManageMemberList = currentUser ? canManageMembers(currentUser) : false; + const isViewingMultipleActiveStripeCustomersFilter = isMultipleActiveStripeCustomersFilter(nql); + const shouldConsiderMultipleActiveStripeCustomersBanner = !search && (!nql || isViewingMultipleActiveStripeCustomersFilter); + + const [optimisticDismissedMultipleActiveStripeCustomersCount, setOptimisticDismissedMultipleActiveStripeCustomersCount] = useState(null); + + const { + data: multipleActiveStripeCustomersData + } = useBrowseMembers({ + searchParams: { + filter: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER, + limit: '1', + fields: 'id', + order: 'id' + }, + defaultErrorHandler: false, + enabled: multipleSubsFilterEnabled && canManageMemberList && hasStripeEnabled && shouldConsiderMultipleActiveStripeCustomersBanner, + refetchOnMount: 'always', + staleTime: 0 + }); + + const multipleActiveStripeCustomersCount = multipleActiveStripeCustomersData?.meta?.pagination?.total ?? 0; + const multipleActiveStripeCustomersBannerPreference = useMemo(() => { + return getMultipleActiveStripeCustomersBannerPreference(currentUser?.accessibility); + }, [currentUser?.accessibility]); + const dismissedMultipleActiveStripeCustomersCount = optimisticDismissedMultipleActiveStripeCustomersCount + ?? multipleActiveStripeCustomersBannerPreference.dismissedCount + ?? 0; + const shouldShowMultipleActiveStripeCustomersBanner = multipleSubsFilterEnabled + && shouldConsiderMultipleActiveStripeCustomersBanner + && ( + isViewingMultipleActiveStripeCustomersFilter + || multipleActiveStripeCustomersCount > dismissedMultipleActiveStripeCustomersCount + ); + const canDismissMultipleActiveStripeCustomersBanner = !isViewingMultipleActiveStripeCustomersFilter; const activeColumns = useMemo(() => { return getMemberActiveColumns(filters); @@ -91,6 +137,45 @@ const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ( } }, [debouncedSearch, search, setSearch]); + useEffect(() => { + const dismissedCount = multipleActiveStripeCustomersBannerPreference.dismissedCount; + + if ( + !currentUser + || optimisticDismissedMultipleActiveStripeCustomersCount !== null + || isDismissingMultipleActiveStripeCustomersBanner + || dismissedCount === undefined + || multipleActiveStripeCustomersData === undefined + || multipleActiveStripeCustomersCount >= dismissedCount + ) { + return; + } + + setOptimisticDismissedMultipleActiveStripeCustomersCount(multipleActiveStripeCustomersCount); + + editUser(buildUserWithDismissedMultipleActiveStripeCustomersBanner( + currentUser, + multipleActiveStripeCustomersCount, + multipleActiveStripeCustomersBannerPreference.dismissedAt ?? new Date().toISOString() + )).then(() => { + setOptimisticDismissedMultipleActiveStripeCustomersCount(null); + }).catch((error) => { + setOptimisticDismissedMultipleActiveStripeCustomersCount(null); + // This keeps the preference in sync opportunistically; failing to sync should not interrupt the member list. + // eslint-disable-next-line no-console + console.log('Unable to update multiple active Stripe customers banner dismissed count', error); + }); + }, [ + currentUser, + editUser, + isDismissingMultipleActiveStripeCustomersBanner, + multipleActiveStripeCustomersBannerPreference.dismissedAt, + multipleActiveStripeCustomersBannerPreference.dismissedCount, + multipleActiveStripeCustomersCount, + multipleActiveStripeCustomersData, + optimisticDismissedMultipleActiveStripeCustomersCount + ]); + const handleMobileSearchToggle = () => { if (showMobileSearch) { setShowMobileSearch(false); @@ -102,6 +187,32 @@ const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ( setShowMobileSearch(true); }; + const handleViewMultipleActiveStripeCustomers = () => { + navigate(buildMembersUrl({filter: MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER})); + }; + + const handleDismissMultipleActiveStripeCustomersBanner = () => { + if (!currentUser || isDismissingMultipleActiveStripeCustomersBanner) { + return; + } + + const dismissedCount = multipleActiveStripeCustomersCount; + const previousDismissedCount = optimisticDismissedMultipleActiveStripeCustomersCount; + + setOptimisticDismissedMultipleActiveStripeCustomersCount(dismissedCount); + + editUser(buildUserWithDismissedMultipleActiveStripeCustomersBanner( + currentUser, + dismissedCount, + new Date().toISOString() + )).then(() => { + setOptimisticDismissedMultipleActiveStripeCustomersCount(null); + }).catch(() => { + setOptimisticDismissedMultipleActiveStripeCustomersCount(previousDismissedCount); + toast.error('Unable to dismiss notification. Please try again.'); + }); + }; + const filtersClassName = 'flex-col gap-4 lg:flex-row lg:items-center sidebar:gap-6 lg:gap-6'; return ( @@ -186,6 +297,29 @@ const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ( )} )} + {shouldShowMultipleActiveStripeCustomersBanner && ( + +
+
+ {formatNumber(multipleActiveStripeCustomersCount)} {multipleActiveStripeCustomersCount === 1 ? 'member' : 'members'} with active subscriptions across multiple Stripe customers were found. +
+ {canDismissMultipleActiveStripeCustomersBanner && ( + + )} +
+
+ )} @@ -252,11 +386,12 @@ const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ( const Members: React.FC = () => { const [searchParams] = useSearchParams(); const {data: settingsData, isLoading: isSettingsLoading} = useBrowseSettings({}); + const {data: configData, isLoading: isConfigLoading} = useBrowseConfig(); const filterParam = searchParams.get('filter') ?? undefined; const hasResolvedSettings = Boolean(settingsData?.settings); const shouldDelayHydration = shouldDelayMembersDateFilterHydration(filterParam, hasResolvedSettings, isSettingsLoading); - if (isSettingsLoading || !settingsData?.settings || shouldDelayHydration) { + if (isSettingsLoading || isConfigLoading || !settingsData?.settings || !configData?.config || shouldDelayHydration) { return ( @@ -284,7 +419,7 @@ const Members: React.FC = () => { const membersSignupAccess = getSettingValue(settingsData.settings, 'members_signup_access'); const membershipsEnabled = membersSignupAccess !== 'none'; - return ; + return ; }; export default Members; diff --git a/apps/posts/src/views/members/multiple-active-stripe-customers.test.ts b/apps/posts/src/views/members/multiple-active-stripe-customers.test.ts new file mode 100644 index 00000000000..4878c16caf7 --- /dev/null +++ b/apps/posts/src/views/members/multiple-active-stripe-customers.test.ts @@ -0,0 +1,66 @@ +import { + MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER, + buildDismissedMultipleActiveStripeCustomersPreference, + getMultipleActiveStripeCustomersBannerPreference, + isMultipleActiveStripeCustomersFilter, + parseAccessibilityPreferences +} from './multiple-active-stripe-customers'; +import {describe, expect, it} from 'vitest'; + +describe('multiple active Stripe customers helpers', () => { + it('matches only the exact raw filter used by the banner', () => { + expect(isMultipleActiveStripeCustomersFilter(MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER)).toBe(true); + expect(isMultipleActiveStripeCustomersFilter(`${MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER}+status:paid`)).toBe(false); + expect(isMultipleActiveStripeCustomersFilter(undefined)).toBe(false); + }); + + it('parses invalid accessibility JSON as empty preferences', () => { + expect(parseAccessibilityPreferences('{invalid json')).toEqual({}); + expect(getMultipleActiveStripeCustomersBannerPreference('{invalid json')).toEqual({}); + }); + + it('ignores invalid dismissal preference values', () => { + const accessibility = JSON.stringify({ + multipleActiveStripeCustomersBanner: { + dismissedCount: 'abc', + dismissedAt: 123, + customFutureKey: 'preserved' + } + }); + + expect(getMultipleActiveStripeCustomersBannerPreference(accessibility)).toEqual({ + dismissedCount: undefined, + dismissedAt: undefined, + customFutureKey: 'preserved' + }); + }); + + it('preserves unknown accessibility keys when writing dismissal state', () => { + const accessibility = JSON.stringify({ + nightShift: true, + onboarding: { + checklistState: 'started' + }, + multipleActiveStripeCustomersBanner: { + dismissedCount: 1, + customFutureKey: 'preserved' + } + }); + + expect(JSON.parse(buildDismissedMultipleActiveStripeCustomersPreference( + accessibility, + 3, + '2026-05-28T12:34:56.000Z' + ))).toEqual({ + nightShift: true, + onboarding: { + checklistState: 'started' + }, + multipleActiveStripeCustomersBanner: { + dismissedCount: 3, + dismissedAt: '2026-05-28T12:34:56.000Z', + customFutureKey: 'preserved' + } + }); + }); +}); diff --git a/apps/posts/src/views/members/multiple-active-stripe-customers.ts b/apps/posts/src/views/members/multiple-active-stripe-customers.ts new file mode 100644 index 00000000000..c7a6c8125e1 --- /dev/null +++ b/apps/posts/src/views/members/multiple-active-stripe-customers.ts @@ -0,0 +1,73 @@ +import {z} from 'zod'; +import type {User} from '@tryghost/admin-x-framework/api/users'; + +export const MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER = 'count.active_stripe_customers:>1'; + +const MultipleActiveStripeCustomersBannerPreferenceSchema = z.looseObject({ + dismissedCount: z.number().finite().optional().catch(undefined), + dismissedAt: z.string().optional().catch(undefined) +}); + +const AccessibilityPreferencesSchema = z.looseObject({ + multipleActiveStripeCustomersBanner: MultipleActiveStripeCustomersBannerPreferenceSchema.optional().catch(undefined) +}); + +export type AccessibilityPreferences = z.infer; +export type MultipleActiveStripeCustomersBannerPreference = z.infer; + +export function isMultipleActiveStripeCustomersFilter(nql: string | undefined): boolean { + return nql === MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER; +} + +export function parseAccessibilityPreferences(accessibility: string | null | undefined): AccessibilityPreferences { + if (!accessibility) { + return AccessibilityPreferencesSchema.parse({}); + } + + try { + const parsed = JSON.parse(accessibility); + return AccessibilityPreferencesSchema.parse(parsed); + } catch { + return AccessibilityPreferencesSchema.parse({}); + } +} + +export function getMultipleActiveStripeCustomersBannerPreference( + accessibility: string | null | undefined +): MultipleActiveStripeCustomersBannerPreference { + const preferences = parseAccessibilityPreferences(accessibility); + return preferences.multipleActiveStripeCustomersBanner ?? {}; +} + +export function buildDismissedMultipleActiveStripeCustomersPreference( + accessibility: string | null | undefined, + dismissedCount: number, + dismissedAt: string +): string { + const preferences = parseAccessibilityPreferences(accessibility); + const currentBannerPreference = getMultipleActiveStripeCustomersBannerPreference(accessibility); + + return JSON.stringify({ + ...preferences, + multipleActiveStripeCustomersBanner: { + ...currentBannerPreference, + dismissedCount, + dismissedAt + } + }); +} + +export function buildUserWithDismissedMultipleActiveStripeCustomersBanner( + user: User, + dismissedCount: number, + dismissedAt: string +): User { + return { + ...user, + accessibility: buildDismissedMultipleActiveStripeCustomersPreference( + user.accessibility, + dismissedCount, + dismissedAt + ) + }; +} diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/members.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/members.js index eddf700a908..e2d23c2494e 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/members.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/members.js @@ -1,7 +1,12 @@ const _ = require('lodash'); +const errors = require('@tryghost/errors'); const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:input:members'); const mapNQLKeyValues = require('@tryghost/nql').utils.mapKeyValues; +const ACTIVE_STRIPE_CUSTOMERS_COUNT_FILTER = 'count.active_stripe_customers'; +const MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER = `${ACTIVE_STRIPE_CUSTOMERS_COUNT_FILTER}:>1`; +const ACTIVE_STRIPE_CUSTOMERS_COUNT_FILTER_PATTERN = /(^|[+(,])count\.active_stripe_customers(?=:)/; + function defaultRelations(frame) { if (frame.options.withRelated) { return; @@ -31,6 +36,27 @@ function mapSubscribedFlagToNewsletterRelation(frame) { }); } +function extractActiveStripeCustomersCountFilter(frame) { + const filterString = frame.options.filter; + + if (!filterString || !filterString.includes(ACTIVE_STRIPE_CUSTOMERS_COUNT_FILTER)) { + return; + } + + if (!ACTIVE_STRIPE_CUSTOMERS_COUNT_FILTER_PATTERN.test(filterString)) { + return; + } + + if (filterString !== MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER) { + throw new errors.BadRequestError({ + message: `The ${ACTIVE_STRIPE_CUSTOMERS_COUNT_FILTER} filter only supports ${MULTIPLE_ACTIVE_STRIPE_CUSTOMERS_FILTER}.` + }); + } + + frame.options.activeStripeCustomersCount = true; + delete frame.options.filter; +} + module.exports = { all(_apiConfig, frame) { if (!frame.options.withRelated) { @@ -48,6 +74,7 @@ module.exports = { browse(apiConfig, frame) { debug('browse'); defaultRelations(frame); + extractActiveStripeCustomersCountFilter(frame); mapSubscribedFlagToNewsletterRelation(frame); if (!frame.options.order) { diff --git a/ghost/core/core/server/models/member.js b/ghost/core/core/server/models/member.js index e7f18ea7e8f..2d8701fb14e 100644 --- a/ghost/core/core/server/models/member.js +++ b/ghost/core/core/server/models/member.js @@ -198,6 +198,25 @@ const Member = ghostBookshelf.Model.extend({ offers: 'offers' }, + applyCustomQuery(options) { + if (!options.activeStripeCustomersCount) { + return; + } + + this.query((qb) => { + qb.innerJoin(function () { + this + .select('members_stripe_customers.member_id') + .from('members_stripe_customers') + .innerJoin('members_stripe_customers_subscriptions', 'members_stripe_customers_subscriptions.customer_id', 'members_stripe_customers.customer_id') + .where('members_stripe_customers_subscriptions.status', 'active') + .groupBy('members_stripe_customers.member_id') + .havingRaw('COUNT(DISTINCT members_stripe_customers_subscriptions.customer_id) > 1') + .as('multiple_active_stripe_customers'); + }, 'multiple_active_stripe_customers.member_id', 'members.id'); + }); + }, + productEvents() { return this.hasMany('MemberProductEvent', 'member_id', 'id') .query('orderBy', 'created_at', 'DESC'); @@ -460,7 +479,7 @@ const Member = ghostBookshelf.Model.extend({ let options = ghostBookshelf.Model.permittedOptions.call(this, methodName); if (['findPage', 'findAll'].includes(methodName)) { - options = options.concat(['search']); + options = options.concat(['search', 'activeStripeCustomersCount']); } return options; diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 183a0b3a705..a2592a4909b 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -53,7 +53,8 @@ const PRIVATE_FEATURES = [ 'themeTranslation', 'indexnow', 'pictureImageFormats', - 'smarterCounts' + 'smarterCounts', + 'multipleSubsFilter' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index 8005dc80823..e42ca3bfff1 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -27,6 +27,7 @@ Object { "indexnow": true, "lexicalIndicators": true, "members": true, + "multipleSubsFilter": true, "pictureImageFormats": true, "smarterCounts": true, "stripeAutomaticTax": true, diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index e911436e802..d2337c6428c 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -78,6 +78,41 @@ async function createGiftMember(data) { return member; } +async function createStripeCustomerWithSubscription(member, customerId, subscriptionId, status = 'active') { + const now = new Date(); + + await knex('members_stripe_customers').insert({ + id: ObjectId().toHexString(), + member_id: member.id, + customer_id: customerId, + email: member.get('email'), + created_at: now, + updated_at: now + }); + + await knex('members_stripe_customers_subscriptions').insert({ + id: ObjectId().toHexString(), + customer_id: customerId, + subscription_id: subscriptionId, + status, + current_period_end: now, + start_date: now, + created_at: now, + updated_at: now, + plan_id: 'plan_test', + plan_nickname: 'Test plan', + plan_interval: 'month', + plan_amount: 500, + plan_currency: 'usd' + }); +} + +async function deleteMembersWithStripeData(emails, customerIds) { + await knex('members_stripe_customers_subscriptions').whereIn('customer_id', customerIds).del(); + await knex('members_stripe_customers').whereIn('customer_id', customerIds).del(); + await knex('members').whereIn('email', emails).del(); +} + const newsletterSnapshot = { id: anyObjectId }; @@ -649,6 +684,80 @@ describe('Members API', function () { }); }); + it('Can filter members with active subscriptions across multiple Stripe customers', async function () { + const emails = [ + 'multiple-active-stripe-customers@example.com', + 'same-active-stripe-customer@example.com', + 'trialing-stripe-customers@example.com' + ]; + const customerIds = ['cus_matching_1', 'cus_matching_2', 'cus_same_1', 'cus_trialing_1', 'cus_trialing_2']; + + try { + const matchingMember = await createMember({ + email: emails[0], + status: 'free' + }); + await createStripeCustomerWithSubscription(matchingMember, 'cus_matching_1', 'sub_matching_1'); + await createStripeCustomerWithSubscription(matchingMember, 'cus_matching_2', 'sub_matching_2'); + + const sameCustomerMember = await createMember({ + email: emails[1], + status: 'free' + }); + await createStripeCustomerWithSubscription(sameCustomerMember, 'cus_same_1', 'sub_same_1'); + await knex('members_stripe_customers_subscriptions').insert({ + id: ObjectId().toHexString(), + customer_id: 'cus_same_1', + subscription_id: 'sub_same_2', + status: 'active', + current_period_end: new Date(), + start_date: new Date(), + created_at: new Date(), + updated_at: new Date(), + plan_id: 'plan_test', + plan_nickname: 'Test plan', + plan_interval: 'month', + plan_amount: 500, + plan_currency: 'usd' + }); + + const trialingMember = await createMember({ + email: emails[2], + status: 'free' + }); + await createStripeCustomerWithSubscription(trialingMember, 'cus_trialing_1', 'sub_trialing_1', 'trialing'); + await createStripeCustomerWithSubscription(trialingMember, 'cus_trialing_2', 'sub_trialing_2', 'trialing'); + + const filter = encodeURIComponent('count.active_stripe_customers:>1'); + const res = await agent + .get(`/members/?filter=${filter}&limit=1&fields=id,email&order=id`) + .expectStatus(200); + + assert.equal(res.body.meta.pagination.total, 1); + assert.equal(res.body.members.length, 1); + assert.equal(res.body.members[0].email, emails[0]); + } finally { + await deleteMembersWithStripeData(emails, customerIds); + } + }); + + it('Returns a bad request for unsupported active Stripe customer count filters', async function () { + const nullFilter = encodeURIComponent('count.active_stripe_customers:null'); + await agent + .get(`/members/?filter=${nullFilter}`) + .expectStatus(400); + + const combinedFilter = encodeURIComponent('count.active_stripe_customers:>1+name:~\'Combined\''); + await agent + .get(`/members/?filter=${combinedFilter}`) + .expectStatus(400); + + const orFilter = encodeURIComponent('status:paid,count.active_stripe_customers:>1'); + await agent + .get(`/members/?filter=${orFilter}`) + .expectStatus(400); + }); + it('Can filter by signup attribution', async function () { await agent .get('/members/?filter=signup:' + fixtureManager.get('posts', 0).id)