diff --git a/src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx b/src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx new file mode 100644 index 000000000000..4d2748bc52eb --- /dev/null +++ b/src/components/Tables/PaymentActivityTable/PaymentActivityTableRow.tsx @@ -0,0 +1,54 @@ +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; +import Table from '@components/Table'; +import TextWithTooltip from '@components/TextWithTooltip'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {PaymentActivityRowData} from '.'; + +type PaymentActivityTableRowProps = { + /** The active user shown in the row */ + item: PaymentActivityRowData; + + /** The index of the row relative to all other rows */ + rowIndex: number; +}; + +/** A read-only row showing an active user's avatar, name, and email. */ +export default function PaymentActivityTableRow({item, rowIndex}: PaymentActivityTableRowProps) { + const styles = useThemeStyles(); + + const accessibilityLabel = [item.name, item.email].filter(Boolean).join(', '); + + return ( + + + + + + + + + + ); +} diff --git a/src/components/Tables/PaymentActivityTable/index.tsx b/src/components/Tables/PaymentActivityTable/index.tsx new file mode 100644 index 000000000000..ac52770a329e --- /dev/null +++ b/src/components/Tables/PaymentActivityTable/index.tsx @@ -0,0 +1,95 @@ +import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table'; +import Table, {composeTableListHeader} from '@components/Table'; + +import useLocalize from '@hooks/useLocalize'; + +import tokenizedSearch from '@libs/tokenizedSearch'; + +import CONST from '@src/CONST'; + +import type {ListRenderItemInfo} from '@shopify/flash-list'; + +import React from 'react'; + +import PaymentActivityTableRow from './PaymentActivityTableRow'; + +type PaymentActivityTableColumnKey = 'member'; + +type PaymentActivityRowData = TableData & { + accountID: number; + + /** The active user's login (email/phone) */ + login: string; + + /** The active user's display name */ + name: string; + + /** The active user's formatted email/phone shown as the subtitle */ + email: string; +}; + +type PaymentActivityTableProps = { + /** The active users billed for the month */ + activeUsers: PaymentActivityRowData[]; + + /** Page-level content rendered above the table header inside the scrollable list */ + headerComponent?: React.ReactElement; +}; + +function PaymentActivityTable({activeUsers, headerComponent}: PaymentActivityTableProps) { + const {translate, localeCompare} = useLocalize(); + + // The count always reflects every active user, not just the ones matching the search. + const activeUsersLabel = translate('subscription.paymentHistory.activeUsers', {count: activeUsers.length}); + + const columns: Array> = [ + { + key: 'member', + label: activeUsersLabel, + sortable: false, + }, + ]; + + const compareItems: CompareItemsCallback = (item1, item2) => localeCompare(item1.name.toLowerCase(), item2.name.toLowerCase()); + + const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + const results = tokenizedSearch([item], searchValue, (option) => [option.name, option.email, option.login]); + return results.length > 0; + }; + + const renderItem = ({item, index}: ListRenderItemInfo) => ( + + ); + + // Short lists are easy to scan, so the search bar only shows once there are enough active users to search through. + const shouldShowSearchBar = activeUsers.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const tableHeaderComponent = composeTableListHeader( + headerComponent, + shouldShowSearchBar ? : undefined, + ); + + return ( + item.keyForList} + > + {tableHeaderComponent} + + + +
+ ); +} + +export default PaymentActivityTable; + +export type {PaymentActivityRowData}; diff --git a/src/languages/de.ts b/src/languages/de.ts index 04fab8db3589..f9e117d24c79 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -11628,6 +11628,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} erstattet am ${date}`, }, billedTo: 'Abgerechnet an', + activity: 'Aktivität', + findActiveUser: 'Aktiven Benutzer suchen', viewReceipt: 'Beleg anzeigen', viewInvoice: 'Rechnung anzeigen', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 1effc2a698aa..2be7a6c0216e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -11844,6 +11844,8 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Επιστράφηκαν ${amount} στις ${date}`, }, billedTo: 'Χρέωση σε', + activity: 'Δραστηριότητα', + findActiveUser: 'Εύρεση ενεργού χρήστη', viewReceipt: 'Προβολή απόδειξης', viewInvoice: 'Προβολή τιμολογίου', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 77fec2e2bbab..bbbaccf97a5d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -11897,6 +11897,8 @@ const translations = { refundedAmountOn: ({amount, date}: PaymentDetailsPartialRefundParams) => `Refunded ${amount} on ${date}`, }, billedTo: 'Billed to', + activity: 'Activity', + findActiveUser: 'Find an active user', viewReceipt: 'View receipt', viewInvoice: 'View invoice', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 67f6933ea2a0..79d1464c8ec7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -11808,6 +11808,8 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Reembolsado ${amount} el ${date}`, }, billedTo: 'Facturado a', + activity: 'Actividad', + findActiveUser: 'Buscar un usuario activo', viewReceipt: 'Ver recibo', viewInvoice: 'Ver factura', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0885acea6f58..c9558cfd65ce 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -11654,6 +11654,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} remboursé le ${date}`, }, billedTo: 'Facturé à', + activity: 'Activité', + findActiveUser: 'Rechercher un utilisateur actif', viewReceipt: 'Voir le reçu', viewInvoice: 'Voir la facture', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 45bffa78d3b0..6a83da557e7f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -11574,6 +11574,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Rimborsati ${amount} il ${date}`, }, billedTo: 'Fatturato a', + activity: 'Attività', + findActiveUser: 'Trova un utente attivo', viewReceipt: 'Visualizza ricevuta', viewInvoice: 'Visualizza fattura', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ba614e4577ed..358da21657d1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -11408,6 +11408,8 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${date} に ${amount} を返金`, }, billedTo: '請求先', + activity: 'アクティビティ', + findActiveUser: 'アクティブユーザーを検索', viewReceipt: '領収書を表示', viewInvoice: '請求書を表示', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ff7257d04879..291d1352e8a5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -11539,6 +11539,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} terugbetaald op ${date}`, }, billedTo: 'Gefactureerd aan', + activity: 'Activiteit', + findActiveUser: 'Zoek een actieve gebruiker', viewReceipt: 'Bon bekijken', viewInvoice: 'Factuur bekijken', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ddae7042d2a0..76629f690e54 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -11560,6 +11560,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `Zwrócono ${amount} ${date}`, }, billedTo: 'Obciążono', + activity: 'Aktywność', + findActiveUser: 'Znajdź aktywnego użytkownika', viewReceipt: 'Wyświetl paragon', viewInvoice: 'Wyświetl fakturę', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9087c58c1d7e..ee3863a4a0f7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -11532,6 +11532,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${amount} reembolsado em ${date}`, }, billedTo: 'Cobrado de', + activity: 'Atividade', + findActiveUser: 'Encontrar um usuário ativo', viewReceipt: 'Ver recibo', viewInvoice: 'Ver fatura', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 77c3b22319b6..2fd413e6f9dd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -11115,6 +11115,8 @@ ${reportName}`, refundedAmountOn: ({amount, date}: {amount: string; date: string}) => `${date} 已退款 ${amount}`, }, billedTo: '账单对象', + activity: '活动', + findActiveUser: '查找活跃用户', viewReceipt: '查看收据', viewInvoice: '查看发票', }, diff --git a/src/libs/PaymentHistoryUtils.ts b/src/libs/PaymentHistoryUtils.ts index bc1a67e9b1a8..595795ffdd85 100644 --- a/src/libs/PaymentHistoryUtils.ts +++ b/src/libs/PaymentHistoryUtils.ts @@ -203,6 +203,11 @@ function getActiveUserCount(purchase: Purchase): number | undefined { return emails.size > 0 ? emails.size : undefined; } +/** Returns the account IDs of the users billed for a purchase, deduped since the same person can be billed under more than one workspace. */ +function getActiveUserAccountIDs(purchase: Purchase | undefined): number[] { + return [...new Set(purchase?.activeUsers?.filter((accountID) => !!accountID))]; +} + function compareNewestFirst(left: PaymentHistoryRow, right: PaymentHistoryRow): number { if (left.created === right.created) { return right.purchaseID - left.purchaseID; @@ -370,6 +375,7 @@ function getReceiptFileName(source: string, purchaseID: number): string { export { formatDate, + getActiveUserAccountIDs, getBadgeAppearance, getBadgeText, getPaymentDetailsBadge, diff --git a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx index 8178de44ae0e..debdcb40234c 100644 --- a/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx +++ b/src/pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage.tsx @@ -4,7 +4,8 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; +import PaymentActivityTable from '@components/Tables/PaymentActivityTable'; +import type {PaymentActivityRowData} from '@components/Tables/PaymentActivityTable'; import Text from '@components/Text'; import UserPill from '@components/UserPill'; @@ -13,14 +14,23 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import {usePersonalDetail, usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {formatDate, getBadgeAppearance, getBadgeText, getPaymentDetailsBadge, getPaymentHistoryRow, getReceiptSource, isInvoiceBilling} from '@libs/PaymentHistoryUtils'; +import { + formatDate, + getActiveUserAccountIDs, + getBadgeAppearance, + getBadgeText, + getPaymentDetailsBadge, + getPaymentHistoryRow, + getReceiptSource, + isInvoiceBilling, +} from '@libs/PaymentHistoryUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -59,6 +69,8 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const billedToAccountID = purchase?.billedToAccountID; const [billedToDetails] = usePersonalDetail(billedToAccountID); const row = getPaymentHistoryRow(purchases, purchaseID); + const activeUserAccountIDs = getActiveUserAccountIDs(purchase); + const [activeUserPersonalDetails] = usePersonalDetailsByIDs(activeUserAccountIDs); if (!isBetaOn || (purchases !== undefined && !row)) { return ; @@ -76,6 +88,90 @@ function PaymentDetailsPage({route}: PaymentDetailsPageProps) { const isInvoice = isInvoiceBilling(purchases, purchaseID); const billedToName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: billedToDetails, translate, formatPhoneNumber}); + + const activeUsers: PaymentActivityRowData[] = activeUserAccountIDs.map((accountID) => { + const details = activeUserPersonalDetails?.[accountID]; + return { + keyForList: String(accountID), + accountID, + login: details?.login ?? '', + name: temporaryGetDisplayNameOrDefault({passedPersonalDetails: details, translate, formatPhoneNumber}), + email: formatPhoneNumber(details?.login ?? ''), + }; + }); + const hasActiveUsers = activeUsers.length > 0; + + const detailsContent = ( + <> + {!!row && ( + + + + {dateLabel} + + + {amountLabel} + + + + + {row.showsTax && {translate('subscription.paymentHistory.inclTax')}} + + + )} + {!!billedToName && ( + + {translate('subscription.paymentHistory.billedTo')} + + + )} + {!!receiptSource && ( + { + Navigation.navigate(ROUTES.BILLING_RECEIPT.getRoute(purchaseID)); + }} + sentryLabel={CONST.SENTRY_LABEL.SETTINGS_SUBSCRIPTION.VIEW_BILLING_RECEIPT} + accessibilityLabel={translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} + style={[styles.ph5, styles.pv4]} + > + + + + {translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} + + + + + )} + {hasActiveUsers && ( + + {translate('subscription.paymentHistory.activity')} + + )} + + ); + return ( - - {!!row && ( - - - - {dateLabel} - - - {amountLabel} - - - - - {row.showsTax && {translate('subscription.paymentHistory.inclTax')}} - - - )} - {!!billedToName && ( - - {translate('subscription.paymentHistory.billedTo')} - - - )} - {!!receiptSource && ( - { - Navigation.navigate(ROUTES.BILLING_RECEIPT.getRoute(purchaseID)); - }} - sentryLabel={CONST.SENTRY_LABEL.SETTINGS_SUBSCRIPTION.VIEW_BILLING_RECEIPT} - accessibilityLabel={translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} - style={[styles.ph5, styles.pv4]} - > - - - - - {translate(isInvoice ? 'subscription.paymentHistory.viewInvoice' : 'subscription.paymentHistory.viewReceipt')} - - - - - - )} - + + + ); diff --git a/tests/ui/PaymentDetailsPageTest.tsx b/tests/ui/PaymentDetailsPageTest.tsx new file mode 100644 index 000000000000..8445dafc4c8c --- /dev/null +++ b/tests/ui/PaymentDetailsPageTest.tsx @@ -0,0 +1,148 @@ +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; + +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import PaymentDetailsPage from '@pages/settings/Subscription/PaymentHistoryPage/PaymentDetailsPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Purchase} from '@src/types/onyx/PurchaseList'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +TestHelper.setupGlobalFetchMock(); + +// The page fetches the purchase details on mount; the tests seed that data in Onyx instead. +jest.mock('@userActions/Subscription', () => ({ + __esModule: true, + openPaymentDetailsPage: jest.fn(), +})); + +const PURCHASE_ID = 1001; +const BOB = {accountID: 2, login: 'bob@example.com', displayName: 'Bob Clyde'}; +const TODD = {accountID: 3, login: 'todd@example.com', displayName: 'Todd Clyde'}; + +// Enough extra members to reach the search bar threshold together with Bob and Todd +const OTHER_MEMBERS = Array.from({length: CONST.STANDARD_LIST_ITEM_LIMIT - 2}, (_, index) => ({ + accountID: 100 + index, + login: `member${index}@example.com`, + displayName: `Member ${index}`, +})); +const SEARCH_PLACEHOLDER = 'Find an active user'; + +const Stack = createPlatformStackNavigator(); + +function renderPage() { + return render( + + + + + + + + + , + ); +} + +async function setPurchase(activeUsers: number[], billingType?: string) { + const purchase: Purchase = { + purchaseID: PURCHASE_ID, + created: '2026-02-01 00:00:00', + amount: 1000, + currency: CONST.CURRENCY.USD, + message: {billingType}, + activeUsers, + isLoaded: true, + }; + await Onyx.merge(ONYXKEYS.PURCHASES, {[PURCHASE_ID]: purchase}); + await waitForBatchedUpdatesWithAct(); +} + +describe('PaymentDetailsPage Activity list', () => { + beforeEach(async () => { + Onyx.init({keys: ONYXKEYS}); + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.PAYMENT_HISTORY]); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, Object.fromEntries([BOB, TODD, ...OTHER_MEMBERS].map((member) => [member.accountID, member]))); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('lists each billed member once, with the deduped count under the Activity header', async () => { + // Given a month where Todd was billed under two workspaces + await setPurchase([TODD.accountID, BOB.accountID, TODD.accountID]); + + // When the details page renders + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the count matches the deduped members and each member is listed with their email + expect(await screen.findByText('Activity')).toBeOnTheScreen(); + expect(screen.getByText('2 active users')).toBeOnTheScreen(); + expect(screen.getByText(BOB.displayName)).toBeOnTheScreen(); + expect(screen.getByText(TODD.displayName)).toBeOnTheScreen(); + expect(screen.getByText(BOB.login)).toBeOnTheScreen(); + + // And the search bar is hidden because two members are quick to scan + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull(); + }); + + it('filters the list as the user searches once there are enough members to search', async () => { + // Given a month with enough billed members to show the search bar + await setPurchase([TODD.accountID, BOB.accountID, ...OTHER_MEMBERS.map((member) => member.accountID)]); + renderPage(); + await waitForBatchedUpdatesWithAct(); + await screen.findByText(BOB.displayName); + + // When the user searches for one of them + fireEvent.changeText(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), 'bob'); + + // Then only the matching member stays in the list, and the count still shows every active user + await waitFor(() => expect(screen.queryByText(TODD.displayName)).toBeNull()); + expect(screen.getByText(BOB.displayName)).toBeOnTheScreen(); + expect(screen.getByText(`${CONST.STANDARD_LIST_ITEM_LIMIT} active users`)).toBeOnTheScreen(); + + // And a search that matches nobody shows the no-results message + fireEvent.changeText(screen.getByPlaceholderText(SEARCH_PLACEHOLDER), 'zzzz'); + expect(await screen.findByText('No results found')).toBeOnTheScreen(); + expect(screen.queryByText(BOB.displayName)).toBeNull(); + }); + + it('hides the Activity section for a month with no billed members', async () => { + // Given a failed month nobody was billed for + await setPurchase([], CONST.BILLING.TYPE_FAILED); + + // When the details page renders + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the details still show, but the Activity label, the search field, and the count are all hidden + expect(await screen.findByText('$10.00')).toBeOnTheScreen(); + expect(screen.queryByText('Activity')).toBeNull(); + expect(screen.queryByPlaceholderText(SEARCH_PLACEHOLDER)).toBeNull(); + expect(screen.queryByText(/active user/)).toBeNull(); + }); +}); diff --git a/tests/unit/PaymentHistoryUtilsTest.ts b/tests/unit/PaymentHistoryUtilsTest.ts index e47a90da3777..b5343f8eec0e 100644 --- a/tests/unit/PaymentHistoryUtilsTest.ts +++ b/tests/unit/PaymentHistoryUtilsTest.ts @@ -3,6 +3,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {convertAmountToDisplayString} from '@libs/CurrencyUtils'; import { formatDate, + getActiveUserAccountIDs, getBadgeText, getPaymentDetailsBadge, getPaymentHistoryRow, @@ -430,3 +431,23 @@ describe('getReceiptFileName', () => { expect(getReceiptFileName('https://expensify.com/', 7)).toBe('receipt-7'); }); }); + +describe('getActiveUserAccountIDs', () => { + it('dedupes users billed under more than one workspace', () => { + // Given a purchase whose active users repeat, because one person was billed under two workspaces + const purchase = {...createPurchase(1, '2026-02-01'), activeUsers: [10, 20, 10, 30]}; + + // When the active users for the Activity list are read + // Then each person is listed once, so the count under the Activity header matches the list + expect(getActiveUserAccountIDs(purchase)).toEqual([10, 20, 30]); + }); + + it('returns no users when the details have not loaded or nobody was billed', () => { + // Given a missing purchase, a purchase without active users, and a purchase with an empty list + // When the active users are read + // Then the list is empty, so the page hides the Activity section + expect(getActiveUserAccountIDs(undefined)).toEqual([]); + expect(getActiveUserAccountIDs(createPurchase(1, '2026-02-01'))).toEqual([]); + expect(getActiveUserAccountIDs({...createPurchase(1, '2026-02-01'), activeUsers: []})).toEqual([]); + }); +});