From 4873eb48dcd060b1cd42c8e9f1d081efdb804f2d Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Wed, 23 Sep 2026 21:12:12 -0400 Subject: [PATCH 01/14] Show and action Bill Pay reports in New Expensify --- contributingGuides/BILL_PAY.md | 24 +++ src/CONST/index.ts | 7 +- src/ONYXKEYS.ts | 3 + src/ROUTES.ts | 1 + src/SCREENS.ts | 1 + src/components/BillPaymentButton.tsx | 112 +++++++++++++ .../PayPrimaryAction.tsx | 5 + .../PayActionButton.tsx | 5 + .../MoneyRequestReportPreview/index.tsx | 4 + .../ListItem/ActionCell/PayActionCell.tsx | 11 ++ .../ListItem/GroupChildrenContent.tsx | 2 +- .../SearchList/ListItem/GroupHeader.tsx | 2 +- .../ListItem/TransactionGroupListItem.tsx | 4 +- src/components/Search/SearchTableHeader.tsx | 5 +- .../Search/hooks/useSearchSnapshot.ts | 2 +- src/components/Search/index.tsx | 5 +- src/hooks/useAdvancedSearchFilters.ts | 2 +- src/hooks/useBillPayCounts.ts | 32 ++++ src/hooks/useSearchTypeMenuSections.ts | 4 + src/hooks/useTodoCounts.ts | 12 +- src/languages/de.ts | 15 ++ src/languages/el.ts | 15 ++ src/languages/en.ts | 15 ++ src/languages/es.ts | 15 ++ src/languages/fr.ts | 15 ++ src/languages/it.ts | 15 ++ src/languages/ja.ts | 14 ++ src/languages/nl.ts | 15 ++ src/languages/pl.ts | 14 ++ src/languages/pt-BR.ts | 15 ++ src/languages/zh-hans.ts | 14 ++ src/libs/API/parameters/CreateBillParams.ts | 15 ++ src/libs/API/parameters/PayBillParams.ts | 9 ++ src/libs/API/parameters/index.ts | 2 + src/libs/API/types.ts | 4 + src/libs/BillPayUtils.ts | 43 +++++ src/libs/DebugUtils.ts | 12 ++ src/libs/Formula.ts | 2 +- .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 1 + src/libs/ReportPrimaryActionUtils.ts | 9 ++ src/libs/ReportUtils.ts | 15 +- src/libs/SearchKeyUtils.ts | 1 + src/libs/SearchQueryUtils.ts | 2 +- src/libs/SearchSuggestionUtils.ts | 57 +++++++ src/libs/SearchUIUtils.ts | 52 ++++-- src/libs/actions/BillPay.ts | 95 +++++++++++ src/libs/actions/IOU/ReportWorkflow.ts | 7 +- src/libs/actions/Search.ts | 9 +- src/pages/Search/EmptySearchView.tsx | 1 + src/pages/bills/CreateBillPage.tsx | 153 ++++++++++++++++++ .../Admins/DomainAdminsSettingsPage.tsx | 2 + src/pages/home/ForYouSection/index.tsx | 9 ++ .../FABPopoverContent/FABPopoverMenu.tsx | 1 + .../menuItems/CreateBillMenuItem.tsx | 32 ++++ .../FloatingActionButtonAndPopover.tsx | 2 + src/selectors/Report.ts | 6 + src/types/form/CreateBillForm.ts | 15 ++ src/types/form/index.ts | 1 + src/types/onyx/Report.ts | 7 + src/types/onyx/SearchResults.ts | 2 +- src/types/utils/whitelistedReportKeys.ts | 6 + tests/ui/components/PayActionCellTest.tsx | 37 +---- tests/unit/BillPayTest.ts | 122 ++++++++++++++ tests/unit/Search/SearchUIUtilsTest.ts | 3 + tests/unit/SidebarFilterTest.ts | 4 +- tests/unit/pages/HomePage.test.tsx | 2 +- tests/unit/useTodoCountsTest.tsx | 2 + 69 files changed, 1087 insertions(+), 62 deletions(-) create mode 100644 contributingGuides/BILL_PAY.md create mode 100644 src/components/BillPaymentButton.tsx create mode 100644 src/hooks/useBillPayCounts.ts create mode 100644 src/libs/API/parameters/CreateBillParams.ts create mode 100644 src/libs/API/parameters/PayBillParams.ts create mode 100644 src/libs/BillPayUtils.ts create mode 100644 src/libs/actions/BillPay.ts create mode 100644 src/pages/bills/CreateBillPage.tsx create mode 100644 src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateBillMenuItem.tsx create mode 100644 src/types/form/CreateBillForm.ts create mode 100644 tests/unit/BillPayTest.ts diff --git a/contributingGuides/BILL_PAY.md b/contributingGuides/BILL_PAY.md new file mode 100644 index 000000000000..5155d68f6c7d --- /dev/null +++ b/contributingGuides/BILL_PAY.md @@ -0,0 +1,24 @@ +# Bill Pay + +[Release 0.1](https://github.com/Expensify/Expensify/issues/684654) uses the existing Classic approval, reimbursement, and export flows. + +`BillPayUtils` defines which reports belong in Bills and which users can approve or pay them. Spend, Home, and Inbox share these rules. Auth also scopes `type:bill` searches to the receiver. + +Bills and received standalone invoices use report rows with Date, Status, Report title, From, To, Total, and Action. The Bills section appears only when the user has matching content. + +`CreateBillPage` accepts manual details and an optional PDF. `BillPaymentButton` offers ACH and Mark as paid without needing a parent room. Both actions use optimistic Onyx updates with failure handling. + +Auth's `isHiddenForBillReceiver` hides linked invoices from navigation and previews. It does not block opening an invoice URL or responding to a comment notification. + +## Manual checks + +1. Open a workspace with existing Classic bills and confirm that Spend shows Bills. +2. Search for a vendor and confirm that the receiver sees the bill without its linked invoice. +3. Approve a bill and confirm that the authorized payer sees it in Ready to pay, Home, and the Inbox. +4. Sign in as another workspace admin and confirm that the bill does not appear in their Ready to pay list. +5. Create a bill with a PDF and confirm that the typed details remain and neither report gains a parent room. +6. Pay an approved bill and confirm that its invoice updates to paid. +7. Open the linked invoice URL and confirm that comments remain available. +8. Open domain settings and confirm that the primary contact hint includes the domain's Bill Pay address. + +Deploy Auth and Web-Expensify before App. No beta controls this feature. diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d816013a9837..b4e6cb78829a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2017,13 +2017,13 @@ const CONST = { TYPE: { CHAT: 'chat', EXPENSE: 'expense', + BILL: 'bill', IOU: 'iou', TASK: 'task', INVOICE: 'invoice', }, UNSUPPORTED_TYPE: { PAYCHECK: 'paycheck', - BILL: 'bill', }, CHAT_TYPE: chatTypes, WORKSPACE_CHAT_ROOMS: { @@ -2239,6 +2239,7 @@ const CONST = { RHP_ENTER_OFFSET_PX_WEB: 60, }, FAB_MENU_ITEM_IDS: { + CREATE_BILL: 'create-bill', QUICK_ACTION: 'quick-action', EXPENSE: 'expense', TRACK_DISTANCE: 'track-distance', @@ -7292,6 +7293,7 @@ const CONST = { DATA_TYPES: { EXPENSE: 'expense', EXPENSE_REPORT: 'expense-report', + BILL: 'bill', INVOICE: 'invoice', TASK: 'task', TRIP: 'trip', @@ -8180,6 +8182,9 @@ const CONST = { SEARCH_KEYS: { EXPENSES: 'expenses', REPORTS: 'reports', + BILLS: 'bills', + BILLS_APPROVE: 'billsApprove', + BILLS_PAY: 'billsPay', SUBMIT: 'submit', APPROVE: 'approve', PAY: 'pay', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index aa1fffc6b9c9..d944733e3aa5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1086,6 +1086,8 @@ const ONYXKEYS = { /** List of Form ids */ FORMS: { + CREATE_BILL_FORM: 'createBillForm', + CREATE_BILL_FORM_DRAFT: 'createBillFormDraft', ADD_PAYMENT_CARD_FORM: 'addPaymentCardForm', ADD_PAYMENT_CARD_FORM_DRAFT: 'addPaymentCardFormDraft', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', @@ -1442,6 +1444,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAX_CODE_FORM]: FormTypes.WorkspaceTaxCodeForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; [ONYXKEYS.FORMS.WORKSPACE_INVOICES_COMPANY_NAME_FORM]: FormTypes.WorkspaceInvoicesCompanyNameForm; + [ONYXKEYS.FORMS.CREATE_BILL_FORM]: FormTypes.CreateBillForm; [ONYXKEYS.FORMS.WORKSPACE_INVOICES_COMPANY_WEBSITE_FORM]: FormTypes.WorkspaceInvoicesCompanyWebsiteForm; [ONYXKEYS.FORMS.WORKSPACE_TIME_TRACKING_DEFAULT_RATE_FORM]: FormTypes.WorkspaceTimeTrackingDefaultRateForm; [ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6338375d029..b63fcc417e9e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2052,6 +2052,7 @@ const DYNAMIC_ROUTES = { } as const satisfies DynamicRoutes; const ROUTES = { + CREATE_BILL: 'create-bill', ...PUBLIC_SCREENS_ROUTES, // This route renders the list of reports. INBOX: 'inbox', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1a650658647b..c6308e0b16a0 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -337,6 +337,7 @@ const SCREENS = { MONEY_REQUEST: { CREATE: 'Money_Request_Create', + CREATE_BILL: 'Money_Request_Create_Bill', DYNAMIC_HOLD_REASON: 'Dynamic_Money_Request_Hold_Reason', REJECT: 'Money_Request_Reject_Reason', REPORT_REJECT: 'Money_Request_Report_Reject', diff --git a/src/components/BillPaymentButton.tsx b/src/components/BillPaymentButton.tsx new file mode 100644 index 000000000000..97260fa0c05f --- /dev/null +++ b/src/components/BillPaymentButton.tsx @@ -0,0 +1,112 @@ +/** Pays bills and received invoices without depending on a parent room. */ + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import {payBill} from '@libs/actions/BillPay'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from './DelegateNoAccessModalProvider'; +import {useLockedAccountActions, useLockedAccountState} from './LockedAccountModalProvider'; +import OfflineWithFeedback from './OfflineWithFeedback'; +import {useSearchQueryContext} from './Search/SearchContext'; + +type BillPaymentButtonProps = { + report: OnyxEntry; + isDisabled?: boolean; + searchHash?: number; +}; + +function BillPaymentButton({report, isDisabled = false, searchHash}: BillPaymentButtonProps) { + const {currentSearchKey} = useSearchQueryContext(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Bank', 'Cash']); + const [bankAccounts] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const pay = (paymentMethod: PaymentMethodType, bankAccountID?: number) => { + if (isAccountLocked) { + showLockedAccountModal(); + return; + } + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + return; + } + payBill(report, paymentMethod, bankAccountID, searchHash, currentSearchKey); + }; + const bankOptions = Object.values(bankAccounts ?? {}) + .filter( + (bank) => + bank.accountData?.allowDebit && + bank.accountData.state === CONST.BANK_ACCOUNT.STATE.OPEN && + bank.bankCurrency === CONST.CURRENCY.USD && + !bank.isExpensifyCardSettlementAccount, + ) + .map((bank) => ({ + text: bank.title ?? bank.description ?? '', + description: bank.description, + icon: icons.Bank, + onSelected: () => pay(CONST.IOU.PAYMENT_TYPE.EXPENSIFY, bank.accountData?.bankAccountID ?? bank.methodID), + })); + + return ( + + {}} + options={[ + { + text: translate('billPay.payByACH'), + value: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, + icon: icons.Bank, + disabled: report?.currency !== CONST.CURRENCY.USD, + subMenuItems: [ + ...bankOptions, + { + text: translate('bankAccount.addBankAccount'), + icon: icons.Bank, + onSelected: () => + Navigation.navigate( + report?.type === CONST.REPORT.TYPE.BILL && report.policyID + ? ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID: report.policyID}) + : ROUTES.SETTINGS_ADD_BANK_ACCOUNT.getRoute(), + ), + }, + ], + }, + { + text: translate('billPay.markAsPaid'), + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + icon: icons.Cash, + onSelected: () => pay(CONST.IOU.PAYMENT_TYPE.ELSEWHERE), + }, + ]} + /> + + ); +} + +export default BillPaymentButton; diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index bf3a55de2a84..3ed2f78a3357 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -1,3 +1,4 @@ +import BillPaymentButton from '@components/BillPaymentButton'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; @@ -201,6 +202,10 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { } }; + if (moneyRequestReport?.type === CONST.REPORT.TYPE.BILL || isInvoiceReport) { + return ; + } + return ( ; + } + return ( + ); + } + return ( (CONST.TRANSACTION.RESULTS_PAGE_SIZE); diff --git a/src/components/Search/SearchList/ListItem/GroupHeader.tsx b/src/components/Search/SearchList/ListItem/GroupHeader.tsx index a30dadc0bdb1..e5bd64adba8b 100644 --- a/src/components/Search/SearchList/ListItem/GroupHeader.tsx +++ b/src/components/Search/SearchList/ListItem/GroupHeader.tsx @@ -111,7 +111,7 @@ function GroupHeader({ const isVendorColumnAvailable = useIsVendorColumnAvailable(); const groupItem = item; - const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || searchType === CONST.SEARCH.DATA_TYPES.BILL; const oneTransactionItem = groupItem.isOneTransactionReport ? groupItem.transactions.at(0) : undefined; const oneTransactionReportID = getNonEmptyStringOnyxID(oneTransactionItem?.reportID); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 1e17f785ecce..aa1e844f7ba8 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -128,7 +128,7 @@ function TransactionGroupListItemImpl({ const [transactionsSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${groupItem.transactionsQueryJSON?.hash}`); - const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isExpenseReportType = searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || searchType === CONST.SEARCH.DATA_TYPES.BILL; const reportGroupID = isExpenseReportType ? (groupItem as TransactionReportGroupListItemType).reportID : undefined; const snapshotActions = reportGroupID ? Object.values(currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportGroupID}`] ?? {}) : []; @@ -463,7 +463,7 @@ function TransactionGroupListItemImpl({ ), }; - if (searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + if (searchType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || searchType === CONST.SEARCH.DATA_TYPES.BILL) { return ( , icons: case CONST.SEARCH.DATA_TYPES.TASK: return taskHeaders; case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT: + case CONST.SEARCH.DATA_TYPES.BILL: return getExpenseReportHeaders(icons.Profile); case CONST.SEARCH.DATA_TYPES.CHAT: default: @@ -590,7 +591,9 @@ function SearchTableHeader({ const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; // Only load Profile icon when it's needed for EXPENSE_REPORT type or grouped transactions - const icons = useMemoizedLazyExpensifyIcons(type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || !!groupBy ? ['Profile', 'Bank', 'CreditCard'] : []) satisfies SearchHeaderIcons; + const icons = useMemoizedLazyExpensifyIcons( + type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.BILL || !!groupBy ? ['Profile', 'Bank', 'CreditCard'] : [], + ) satisfies SearchHeaderIcons; const shouldShowColumn = useCallback( (columnName: SearchColumnType) => { diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index dba8eee36d7e..389623c8d78c 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -153,7 +153,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans const validGroupBy = getValidGroupBy(groupBy); const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; - const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.BILL; // Only CHAT/TASK sections read reportAttributesDerivedValue. It's a derived value that gets a new // reference whenever any report changes (e.g. on every step of a PAY), so keeping it in the deps for diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index aea1479aff19..026b2931e701 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -183,7 +183,7 @@ function Search({ const isActionLoadingSet = useActionLoadingReportIDs(); const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.BILL; const {policyForMovingExpenses} = usePolicyForMovingExpenses(); // getSections only needs the boolean (it gates attendees on unreported transactions for the @@ -1248,7 +1248,8 @@ function Search({ // Transaction lists (expense, invoice, trip) render through the flat or grouped view depending on groupBy; // chat, expense-report and task each have their own dedicated view. Every view composes BaseSearchList // directly, and the snapshot, lifecycle and selection providers stay here so the data layer runs once. - const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isTransactionListView = + type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && type !== CONST.SEARCH.DATA_TYPES.BILL; let searchTablePaddingRightStyle; if (!isTask) { diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index 54ff2aa0531a..725eacd312b8 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -326,7 +326,7 @@ function useAdvancedSearchFilters(type: SearchDataTypes | undefined) { const shouldDisplayTaxFilter = shouldDisplayFilter(policyDerived?.hasAnyTaxRates ? 1 : 0, policyDerived?.areTaxEnabled ?? false); const shouldDisplayWorkspaceFilter = workspaces.some((section) => section.data.length > 1); - let currentType = type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; + let currentType = type === CONST.SEARCH.DATA_TYPES.BILL ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : (type ?? CONST.SEARCH.DATA_TYPES.EXPENSE); if (!(currentType in typeFiltersKeys)) { currentType = CONST.SEARCH.DATA_TYPES.EXPENSE; diff --git a/src/hooks/useBillPayCounts.ts b/src/hooks/useBillPayCounts.ts new file mode 100644 index 000000000000..94d17f63a1ff --- /dev/null +++ b/src/hooks/useBillPayCounts.ts @@ -0,0 +1,32 @@ +/** Derives Bills navigation and counters from the same reports used by the Inbox. */ +import {canApproveBill, canPayBill, isBillPayReport} from '@libs/BillPayUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useOnyx from './useOnyx'; + +function useBillPayCounts() { + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [session] = useOnyx(ONYXKEYS.SESSION); + const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + let hasBills = false; + let billsApprove = 0; + let billsPay = 0; + for (const report of Object.values(reports ?? {})) { + if (!report || !isBillPayReport(report, accountID)) { + continue; + } + hasBills = true; + if (canApproveBill(report, accountID)) { + billsApprove++; + } + if (canPayBill(report, policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`], accountID, session?.email ?? '')) { + billsPay++; + } + } + return {hasBills, billsApprove, billsPay}; +} + +export default useBillPayCounts; diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 499204402ded..53a9650dcfac 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -12,6 +12,7 @@ import {defaultExpensifyCardSelector} from '@selectors/Card'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import {useCallback, useEffect, useMemo, useState} from 'react'; +import useBillPayCounts from './useBillPayCounts'; import useCardFeedsForDisplay from './useCardFeedsForDisplay'; import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; import useHasReportAwaitingApproval from './useHasReportAwaitingApproval'; @@ -59,6 +60,7 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ * reliably, so this hook never depends on a navigation context itself. */ const useSearchTypeMenuSections = (isScreenFocused = true) => { + const {hasBills} = useBillPayCounts(); const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); const {defaultCardFeed, cardFeedsByPolicy, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); @@ -122,6 +124,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { draftTransactionIDs, isTrackIntentUser: isTrackIntentUser ?? false, hasReportAwaitingApproval, + hasBills, policyCategories: allPolicyCategories, }), [ @@ -137,6 +140,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { draftTransactionIDs, isTrackIntentUser, hasReportAwaitingApproval, + hasBills, allPolicyCategories, ], ); diff --git a/src/hooks/useTodoCounts.ts b/src/hooks/useTodoCounts.ts index c6d0eef5f00b..ca2829f97bdd 100644 --- a/src/hooks/useTodoCounts.ts +++ b/src/hooks/useTodoCounts.ts @@ -8,6 +8,7 @@ import {useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {useOnyx} from 'react-native-onyx'; +import useBillPayCounts from './useBillPayCounts'; import {useAllPersonalDetailsWithoutSnapshots} from './usePersonalDetails'; type TodoCounts = { @@ -15,6 +16,8 @@ type TodoCounts = { [CONST.SEARCH.SEARCH_KEYS.APPROVE]: number; [CONST.SEARCH.SEARCH_KEYS.PAY]: number; [CONST.SEARCH.SEARCH_KEYS.EXPORT]: number; + [CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]?: number; + [CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]?: number; }; type TodoSingleReportIDs = { @@ -35,6 +38,7 @@ const TODO_KEYS = [CONST.SEARCH.SEARCH_KEYS.SUBMIT, CONST.SEARCH.SEARCH_KEYS.APP * last computed result, so e.g. an unfocused screen stops recomputing to-do counts on background Onyx writes. */ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: TodoSingleReportIDs} { + const {billsApprove, billsPay} = useBillPayCounts(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); @@ -77,6 +81,8 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To [CONST.SEARCH.SEARCH_KEYS.APPROVE]: reportsToApprove.length, [CONST.SEARCH.SEARCH_KEYS.PAY]: reportsToPay.length, [CONST.SEARCH.SEARCH_KEYS.EXPORT]: reportsToExport.length, + [CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]: billsApprove, + [CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]: billsPay, }; const singleReportIDs: TodoSingleReportIDs = { @@ -90,7 +96,11 @@ function useTodoCounts(enabled = true): {counts: TodoCounts; singleReportIDs: To // Capture the latest result so it can be returned verbatim once the consumer freezes. Only re-store when a // count or single-report ID actually changes, so the setState-during-render can't loop. - const hasChanged = !frozen || TODO_KEYS.some((key) => frozen.counts[key] !== counts[key] || frozen.singleReportIDs[key] !== singleReportIDs[key]); + const hasChanged = + !frozen || + frozen.counts.billsApprove !== billsApprove || + frozen.counts.billsPay !== billsPay || + TODO_KEYS.some((key) => frozen.counts[key] !== counts[key] || frozen.singleReportIDs[key] !== singleReportIDs[key]); if (hasChanged) { setFrozen(value); return value; diff --git a/src/languages/de.ts b/src/languages/de.ts index 98c4c3d8d97a..a325629e3f4a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -34,6 +34,21 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Eingangsrechnungen', + createBill: 'Eingangsrechnung erstellen', + vendorEmail: 'E-Mail des Lieferanten', + domain: 'Empfangende Domain', + invoiceDetails: 'Rechnungsdetails', + attachPDF: 'PDF anhängen', + payByACH: 'Per ACH bezahlen', + markAsPaid: 'Als bezahlt markieren', + paymentFailed: 'Diese Rechnung konnte nicht bezahlt werden. Bitte versuche es erneut.', + createFailed: 'Diese Rechnung konnte nicht erstellt werden. Bitte versuche es erneut.', + payBills: ({count}) => ({one: '1 Rechnung bezahlen', other: `${count} Rechnungen bezahlen`}), + primaryContactHint: (domain) => + `Der Hauptkontakt ist die zentrale Kontaktperson für ${domain} und erhält Rechnungen, die an die Rechnungsadresse dieser Domain ${domain}@expensify.cash gesendet werden.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 Tag`, diff --git a/src/languages/el.ts b/src/languages/el.ts index bb06f389c27e..b2648db6c00f 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -31,6 +31,21 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Λογαριασμοί', + createBill: 'Δημιουργία λογαριασμού', + vendorEmail: 'Email προμηθευτή', + domain: 'Τομέας παραλήπτη', + invoiceDetails: 'Στοιχεία τιμολογίου', + attachPDF: 'Επισύναψη PDF', + payByACH: 'Πληρωμή μέσω ACH', + markAsPaid: 'Σήμανση ως πληρωμένο', + paymentFailed: 'Δεν μπορέσαμε να πληρώσουμε αυτόν τον λογαριασμό. Δοκιμάστε ξανά.', + createFailed: 'Δεν μπορέσαμε να δημιουργήσουμε αυτόν τον λογαριασμό. Δοκιμάστε ξανά.', + payBills: ({count}) => ({one: 'Πληρωμή 1 λογαριασμού', other: `Πληρωμή ${count} λογαριασμών`}), + primaryContactHint: (domain) => + `Η κύρια επαφή είναι το βασικό σημείο επικοινωνίας για το ${domain} και θα λαμβάνει λογαριασμούς που αποστέλλονται στη διεύθυνση πληρωμών του τομέα ${domain}@expensify.cash.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 ημέρα`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 66a041a63f90..c56c01c2e4ec 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -36,6 +36,21 @@ type States = Record; type AllCountries = Record; const translations = { + billPay: { + bills: 'Bills', + createBill: 'Create bill', + vendorEmail: 'Vendor email', + domain: 'Receiving domain', + invoiceDetails: 'Invoice details', + attachPDF: 'Attach PDF', + payByACH: 'Pay by ACH', + markAsPaid: 'Mark as paid', + paymentFailed: 'We couldn’t pay this bill. Please try again.', + createFailed: 'We couldn’t create this bill. Please try again.', + payBills: ({count}: {count: number}) => ({one: 'Pay 1 bill', other: `Pay ${count} bills`}), + primaryContactHint: (domain: string) => + `The primary contact is the main point of contact for ${domain} and will receive bills sent to this domain's bill pay address ${domain}@expensify.cash`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: '1 day', diff --git a/src/languages/es.ts b/src/languages/es.ts index e4b5f48f814a..675cb0996759 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -20,6 +20,21 @@ import type en from './en'; import type {AllConnectionName, PolicyConnectionSyncStage} from './TranslationTypes'; import type {TranslationDeepObject} from './types'; const translations: TranslationDeepObject = { + billPay: { + bills: 'Facturas por pagar', + createBill: 'Crear factura por pagar', + vendorEmail: 'Correo del proveedor', + domain: 'Dominio receptor', + invoiceDetails: 'Detalles de la factura', + attachPDF: 'Adjuntar PDF', + payByACH: 'Pagar por ACH', + markAsPaid: 'Marcar como pagada', + paymentFailed: 'No pudimos pagar esta factura. Inténtalo de nuevo.', + createFailed: 'No pudimos crear esta factura. Inténtalo de nuevo.', + payBills: ({count}) => ({one: 'Pagar 1 factura', other: `Pagar ${count} facturas`}), + primaryContactHint: (domain) => + `El contacto principal es el punto de contacto de ${domain} y recibirá las facturas enviadas a la dirección de pago de facturas del dominio ${domain}@expensify.cash.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 día`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e134bdbd6dc7..54a8fedfeba6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -34,6 +34,21 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Factures à payer', + createBill: 'Créer une facture à payer', + vendorEmail: 'E-mail du fournisseur', + domain: 'Domaine destinataire', + invoiceDetails: 'Détails de la facture', + attachPDF: 'Joindre un PDF', + payByACH: 'Payer par ACH', + markAsPaid: 'Marquer comme payée', + paymentFailed: 'Nous n’avons pas pu payer cette facture. Veuillez réessayer.', + createFailed: 'Nous n’avons pas pu créer cette facture. Veuillez réessayer.', + payBills: ({count}) => ({one: 'Payer 1 facture', other: `Payer ${count} factures`}), + primaryContactHint: (domain) => + `Le contact principal est l’interlocuteur de ${domain} et recevra les factures envoyées à l’adresse de paiement des factures du domaine ${domain}@expensify.cash.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `${count} jour`, diff --git a/src/languages/it.ts b/src/languages/it.ts index a8a4651a443b..d2d8fdf235c5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -34,6 +34,21 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Fatture da pagare', + createBill: 'Crea fattura da pagare', + vendorEmail: 'Email del fornitore', + domain: 'Dominio destinatario', + invoiceDetails: 'Dettagli della fattura', + attachPDF: 'Allega PDF', + payByACH: 'Paga tramite ACH', + markAsPaid: 'Segna come pagata', + paymentFailed: 'Non è stato possibile pagare questa fattura. Riprova.', + createFailed: 'Non è stato possibile creare questa fattura. Riprova.', + payBills: ({count}) => ({one: 'Paga 1 fattura', other: `Paga ${count} fatture`}), + primaryContactHint: (domain) => + `Il contatto principale è il referente di ${domain} e riceverà le fatture inviate all’indirizzo per il pagamento delle fatture del dominio ${domain}@expensify.cash.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 giorno`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2f1660e46af6..b7badcee6416 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -34,6 +34,20 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: '支払請求書', + createBill: '支払請求書を作成', + vendorEmail: '取引先のメールアドレス', + domain: '受信ドメイン', + invoiceDetails: '請求書の詳細', + attachPDF: 'PDFを添付', + payByACH: 'ACHで支払う', + markAsPaid: '支払済みにする', + paymentFailed: 'この請求書を支払えませんでした。もう一度お試しください。', + createFailed: 'この請求書を作成できませんでした。もう一度お試しください。', + payBills: ({count}) => ({one: '1件の請求書を支払う', other: `${count}件の請求書を支払う`}), + primaryContactHint: (domain) => `主な連絡先は${domain}の窓口となり、このドメインの請求書支払い用アドレス${domain}@expensify.cashに送信された請求書を受け取ります。`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 日`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 416aed362a28..352fa688a100 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -34,6 +34,21 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Te betalen facturen', + createBill: 'Te betalen factuur maken', + vendorEmail: 'E-mailadres leverancier', + domain: 'Ontvangend domein', + invoiceDetails: 'Factuurgegevens', + attachPDF: 'PDF bijvoegen', + payByACH: 'Betalen via ACH', + markAsPaid: 'Markeren als betaald', + paymentFailed: 'We konden deze factuur niet betalen. Probeer het opnieuw.', + createFailed: 'We konden deze factuur niet maken. Probeer het opnieuw.', + payBills: ({count}) => ({one: '1 factuur betalen', other: `${count} facturen betalen`}), + primaryContactHint: (domain) => + `De primaire contactpersoon is het aanspreekpunt voor ${domain} en ontvangt facturen die naar het factuuradres van dit domein ${domain}@expensify.cash worden gestuurd.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 dag`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8a04e87cbe79..17bef2c9dbb8 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -34,6 +34,20 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Rachunki', + createBill: 'Utwórz rachunek', + vendorEmail: 'E-mail dostawcy', + domain: 'Domena odbiorcy', + invoiceDetails: 'Szczegóły faktury', + attachPDF: 'Załącz PDF', + payByACH: 'Zapłać przez ACH', + markAsPaid: 'Oznacz jako opłacone', + paymentFailed: 'Nie udało się opłacić tego rachunku. Spróbuj ponownie.', + createFailed: 'Nie udało się utworzyć tego rachunku. Spróbuj ponownie.', + payBills: ({count}) => ({one: 'Opłać 1 rachunek', few: `Opłać ${count} rachunki`, many: `Opłać ${count} rachunków`, other: `Opłać ${count} rachunku`}), + primaryContactHint: (domain) => `Główna osoba kontaktowa odpowiada za kontakt w domenie ${domain} i otrzyma rachunki wysłane na adres płatności tej domeny ${domain}@expensify.cash.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 dzień`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 283c4ecb5cf4..cab20ec1bfe9 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -34,6 +34,21 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: 'Contas a pagar', + createBill: 'Criar conta a pagar', + vendorEmail: 'E-mail do fornecedor', + domain: 'Domínio destinatário', + invoiceDetails: 'Detalhes da fatura', + attachPDF: 'Anexar PDF', + payByACH: 'Pagar por ACH', + markAsPaid: 'Marcar como paga', + paymentFailed: 'Não foi possível pagar esta conta. Tente novamente.', + createFailed: 'Não foi possível criar esta conta. Tente novamente.', + payBills: ({count}) => ({one: 'Pagar 1 conta', other: `Pagar ${count} contas`}), + primaryContactHint: (domain) => + `O contato principal é o ponto de contato de ${domain} e receberá as contas enviadas ao endereço de pagamento de contas deste domínio ${domain}@expensify.cash.`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `${count} dia`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2fc7a5ca7435..b846757481dc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -34,6 +34,20 @@ type StateValue = { type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { + billPay: { + bills: '账单', + createBill: '创建账单', + vendorEmail: '供应商邮箱', + domain: '接收域名', + invoiceDetails: '发票详情', + attachPDF: '附加 PDF', + payByACH: '通过 ACH 支付', + markAsPaid: '标记为已支付', + paymentFailed: '无法支付此账单。请重试。', + createFailed: '无法创建此账单。请重试。', + payBills: ({count}) => ({one: '支付 1 笔账单', other: `支付 ${count} 笔账单`}), + primaryContactHint: (domain) => `主要联系人是 ${domain} 的主要联络人,并将收到发送至此域名账单支付地址 ${domain}@expensify.cash 的账单。`, + }, common: { durationDays: ({count}: {count: number}) => ({ one: `1 天`, diff --git a/src/libs/API/parameters/CreateBillParams.ts b/src/libs/API/parameters/CreateBillParams.ts new file mode 100644 index 000000000000..3e72b564791a --- /dev/null +++ b/src/libs/API/parameters/CreateBillParams.ts @@ -0,0 +1,15 @@ +import type {FileObject} from '@src/types/utils/Attachment'; + +type CreateBillParams = { + reportID: string; + invoiceReportID: string; + domain: string; + vendorEmail: string; + merchant: string; + amount: number; + currency: string; + date: string; + file?: FileObject; +}; + +export default CreateBillParams; diff --git a/src/libs/API/parameters/PayBillParams.ts b/src/libs/API/parameters/PayBillParams.ts new file mode 100644 index 000000000000..d49c7828fb37 --- /dev/null +++ b/src/libs/API/parameters/PayBillParams.ts @@ -0,0 +1,9 @@ +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; + +type PayBillParams = { + reportID: string; + paymentMethodType: PaymentMethodType; + bankAccountID?: number; +}; + +export default PayBillParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index c81eb85df926..06276e36fa2d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -386,6 +386,8 @@ export type {default as SearchParams} from './Search'; export type {default as GetTransactionsConvertedAmountParams} from './GetTransactionsConvertedAmount'; export type {default as SendInvoiceParams} from './SendInvoiceParams'; export type {default as PayInvoiceParams} from './PayInvoiceParams'; +export type {default as PayBillParams} from './PayBillParams'; +export type {default as CreateBillParams} from './CreateBillParams'; export type {default as MarkAsCashParams} from './MarkAsCashParams'; export type {default as MergeDuplicatesParams} from './MergeDuplicatesParams'; export type {default as MergeTransactionParams} from './MergeTransactionParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 0c2c603ca395..a4afd086aee3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -400,6 +400,8 @@ const WRITE_COMMANDS = { DISMISS_VIOLATION: 'DismissViolation', SEND_INVOICE: 'SendInvoice', PAY_INVOICE: 'PayInvoice', + PAY_BILL: 'PayBill', + CREATE_BILL: 'CreateBill', MARK_AS_CASH: 'MarkAsCash', MERGE_DUPLICATES: 'MergeDuplicates', RESOLVE_DUPLICATES: 'ResolveDuplicates', @@ -1132,6 +1134,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DISMISS_VIOLATION]: Parameters.DismissViolationParams; [WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams; [WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams; + [WRITE_COMMANDS.PAY_BILL]: Parameters.PayBillParams; + [WRITE_COMMANDS.CREATE_BILL]: Parameters.CreateBillParams; [WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams; [WRITE_COMMANDS.DUPLICATE_POLICY]: Parameters.DuplicateWorkspaceParams; [WRITE_COMMANDS.COPY_POLICY_SETTINGS]: Parameters.CopyPolicySettingsParams; diff --git a/src/libs/BillPayUtils.ts b/src/libs/BillPayUtils.ts new file mode 100644 index 000000000000..0648a70652f8 --- /dev/null +++ b/src/libs/BillPayUtils.ts @@ -0,0 +1,43 @@ +/** Shared eligibility rules for Bills, Home, Inbox, and report actions. */ +import CONST from '@src/CONST'; +import type {Policy, Report} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +function isBillReport(report: OnyxEntry): boolean { + return report?.type === CONST.REPORT.TYPE.BILL; +} + +function isBillPayReport(report: OnyxEntry, accountID: number): boolean { + if (!report || report.isHiddenForBillReceiver) { + return false; + } + return isBillReport(report) || (report.type === CONST.REPORT.TYPE.INVOICE && (report.isBillPayReport ?? (!report.billID && report.managerID === accountID))); +} + +function canApproveBill(report: OnyxEntry, accountID: number): boolean { + return isBillReport(report) && report?.managerID === accountID && report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED; +} + +function canPayBill(report: OnyxEntry, policy: OnyxEntry | null, accountID: number, email: string): boolean { + if ( + !isBillPayReport(report, accountID) || + !report || + report.isWaitingOnBankAccount || + (report.total ?? 0) >= 0 || + !!policy?.archivedDate || + policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE + ) { + return false; + } + if (report.type === CONST.REPORT.TYPE.INVOICE) { + return report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED; + } + if (report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || (report.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED && report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED)) { + return false; + } + const reimburser = policy?.reimburser ?? policy?.achAccount?.reimburser ?? policy?.owner; + return reimburser ? reimburser === email : policy?.role === CONST.POLICY.ROLE.ADMIN; +} + +export {isBillReport, isBillPayReport, canApproveBill, canPayBill}; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8610a08d0bfe..710611ad26ae 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -449,6 +449,8 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa throw SyntaxError('debug.missingValue'); } switch (key) { + case 'billID': + case 'invoiceID': case 'avatarUrl': case 'created': case 'submitted': @@ -486,6 +488,8 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa case 'creditedCurrency': return validateString(value); case 'hasOutstandingChildRequest': + case 'isBillPayReport': + case 'isHiddenForBillReceiver': case 'hasOutstandingChildTask': case 'isOwnPolicyExpenseChat': case 'isPinned': @@ -502,6 +506,8 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa case 'exportFailedTime': case 'lastReadSequenceNumber': case 'managerID': + case 'billSenderAccountID': + case 'billReceiverAccountID': case 'lastActorAccountID': case 'ownerAccountID': case 'total': @@ -620,6 +626,12 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa return validateConstantEnum(value, CONST.RED_BRICK_ROAD_PENDING_ACTION); case 'pendingFields': return validateObject>(value, { + billID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + invoiceID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + billSenderAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + billReceiverAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isBillPayReport: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isHiddenForBillReceiver: CONST.RED_BRICK_ROAD_PENDING_ACTION, description: CONST.RED_BRICK_ROAD_PENDING_ACTION, privateNotes: CONST.RED_BRICK_ROAD_PENDING_ACTION, currency: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/Formula.ts b/src/libs/Formula.ts index d214500907c4..b83db3b7ad21 100644 --- a/src/libs/Formula.ts +++ b/src/libs/Formula.ts @@ -673,7 +673,7 @@ function formatType(type: string | undefined): string { [CONST.REPORT.TYPE.EXPENSE]: 'Expense Report', [CONST.REPORT.TYPE.INVOICE]: 'Invoice', [CONST.REPORT.TYPE.CHAT]: 'Chat', - [CONST.REPORT.UNSUPPORTED_TYPE.BILL]: 'Bill', + [CONST.REPORT.TYPE.BILL]: 'Bill', [CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK]: 'Paycheck', [CONST.REPORT.TYPE.IOU]: 'IOU', [CONST.REPORT.TYPE.TASK]: 'Task', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 072b67281f64..af1d53b51dfa 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -191,6 +191,7 @@ function createModalStackNavigator(screens: Scr } const MoneyRequestModalStackNavigator = createModalStackNavigator({ + [SCREENS.MONEY_REQUEST.CREATE_BILL]: () => require('../../../../pages/bills/CreateBillPage').default, [SCREENS.MONEY_REQUEST.START]: () => require('../../../../pages/iou/request/IOURequestRedirectToStartPage').default, [SCREENS.MONEY_REQUEST.CREATE]: () => require('../../../../pages/iou/request/IOURequestStartPage').default, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: () => require('../../../../pages/iou/request/step/IOURequestStepConfirmation').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6e0f4d7f3bb4..a2e469135630 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1817,6 +1817,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: { screens: { + [SCREENS.MONEY_REQUEST.CREATE_BILL]: ROUTES.CREATE_BILL, [SCREENS.MONEY_REQUEST.START]: ROUTES.MONEY_REQUEST_START.route, [SCREENS.MONEY_REQUEST.CREATE]: { path: ROUTES.MONEY_REQUEST_CREATE.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 30f52b7a98c5..202256b8887c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2262,6 +2262,7 @@ type RoomMembersNavigatorParamList = { }; type MoneyRequestNavigatorParamList = { + [SCREENS.MONEY_REQUEST.CREATE_BILL]: undefined; [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SEND_FROM]: { action: IOUAction; iouType: IOUType; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 0c43b8a05b9d..ff6e15440678 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -5,6 +5,7 @@ import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, Repo import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {canPayBill, isBillReport} from './BillPayUtils'; import { arePaymentsEnabled as arePaymentsEnabledUtils, canMemberWrite, @@ -228,6 +229,14 @@ function isPrimaryPayAction({ isSecondaryAction, canNonPayerAdminPay, }: IsPrimaryPayActionParams) { + if (report.isHiddenForBillReceiver) { + return false; + } + + if (isBillReport(report) || report.isBillPayReport) { + return canPayBill(report, policy, currentUserAccountID, currentUserLogin ?? ''); + } + const isExpenseReport = isExpenseReportUtils(report); if (isPayBlockedByArchivedState(report, policy, isArchivedReport(reportNameValuePairs) || !!isChatReportArchived)) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3c1faf0222e1..42dc78cdb2ac 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -112,6 +112,7 @@ import hasCreditBankAccount from './actions/ReimbursementAccount/hasCreditBankAc import {isAnonymousUser as isAnonymousUserSession} from './actions/Session'; import {getOnboardingMessages} from './actions/Welcome/OnboardingFlow'; import {convertAttendeesToArray, normalizeAttendees} from './AttendeeUtils'; +import {canPayBill} from './BillPayUtils'; import {getCategoryGLCode} from './CategoryUtils'; import {convertToDisplayStringEnLocale} from './CurrencyUtils'; import DateUtils from './DateUtils'; @@ -1472,7 +1473,7 @@ function isReportIDApproved(reportID: string | undefined) { */ function isExpenseReport(reportOrID: OnyxInputOrEntry | string): boolean { const report = typeof reportOrID === 'string' ? (getReport(reportOrID, deprecatedAllReports) ?? null) : reportOrID; - return report?.type === CONST.REPORT.TYPE.EXPENSE; + return report?.type === CONST.REPORT.TYPE.EXPENSE || report?.type === CONST.REPORT.TYPE.BILL; } /** @@ -4554,6 +4555,14 @@ function getReasonAndReportActionThatRequiresAttention( return null; } + const billPolicy = (policiesParam ?? allPolicies)?.[`${ONYXKEYS.COLLECTION.POLICY}${optionOrReport.policyID}`]; + if (canPayBill(optionOrReport, billPolicy, currentUserAccountID, currentUserLogin)) { + return { + reason: CONST.REQUIRES_ATTENTION_REASONS.IS_WAITING_FOR_ASSIGNEE_TO_COMPLETE_ACTION, + actionBadge: CONST.REPORT.ACTION_BADGE.PAY, + }; + } + if (isJoinRequestInAdminRoom(optionOrReport, currentUserLogin)) { return { reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_JOIN_REQUEST, @@ -10403,6 +10412,10 @@ function reasonForReportToBeInOptionList({ derivedIsEmptyReport, hasGuidesEmails, }: ShouldReportBeInOptionListParams): ValueOf | null { + if (report?.isHiddenForBillReceiver) { + return null; + } + const isInDefaultMode = !isInFocusMode; // Include the currently viewed report. If we excluded the currently viewed report, then there diff --git a/src/libs/SearchKeyUtils.ts b/src/libs/SearchKeyUtils.ts index f8c1b5f010fa..eaca7598f387 100644 --- a/src/libs/SearchKeyUtils.ts +++ b/src/libs/SearchKeyUtils.ts @@ -8,6 +8,7 @@ type SearchKey = ValueOf | `${typeof CONST.SEAR const DATA_TYPE_TO_SEARCH_KEY: Partial> = { [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, + [CONST.SEARCH.DATA_TYPES.BILL]: CONST.SEARCH.SEARCH_KEYS.BILLS, }; function savedSearchIDToSearchKey(id: string): SearchKey { diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 4644eea2eb3d..40fb6992d58e 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -935,7 +935,7 @@ function isSearchDatePreset(date: string | undefined): date is SearchDatePreset * Returns whether a given search filter is supported in a given search data type */ function isFilterSupported(filter: SearchAdvancedFiltersKey, type: SearchDataTypes) { - const supportedTypeFilters = ALLOWED_TYPE_FILTERS[type]; + const supportedTypeFilters = ALLOWED_TYPE_FILTERS[type === CONST.SEARCH.DATA_TYPES.BILL ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : type]; if (!supportedTypeFilters) { return false; } diff --git a/src/libs/SearchSuggestionUtils.ts b/src/libs/SearchSuggestionUtils.ts index bc1e8229e471..52cbcf3e86b0 100644 --- a/src/libs/SearchSuggestionUtils.ts +++ b/src/libs/SearchSuggestionUtils.ts @@ -188,6 +188,63 @@ function getSuggestedSearches( return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; }, }, + [CONST.SEARCH.SEARCH_KEYS.BILLS]: { + key: CONST.SEARCH.SEARCH_KEYS.BILLS, + translationPath: 'billPay.bills', + type: CONST.SEARCH.DATA_TYPES.BILL, + icon: 'Document', + searchQuery: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.BILL}), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + get recentSearchHash() { + return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, + [CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]: { + key: CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE, + translationPath: 'search.tabs.approve', + type: CONST.SEARCH.DATA_TYPES.BILL, + icon: 'ThumbsUp', + searchQuery: buildQueryStringFromFilterFormValues({type: CONST.SEARCH.DATA_TYPES.BILL, action: CONST.SEARCH.ACTION_FILTERS.APPROVE}), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + get recentSearchHash() { + return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, + [CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]: { + key: CONST.SEARCH.SEARCH_KEYS.BILLS_PAY, + translationPath: 'search.tabs.pay', + type: CONST.SEARCH.DATA_TYPES.BILL, + icon: 'MoneyBag', + searchQuery: buildQueryStringFromFilterFormValues({type: CONST.SEARCH.DATA_TYPES.BILL, action: CONST.SEARCH.ACTION_FILTERS.PAY, payer: accountID?.toString()}), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + get recentSearchHash() { + return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: { key: CONST.SEARCH.SEARCH_KEYS.SUBMIT, translationPath: 'search.tabs.submit', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b7ab817d7d38..49a6facc7766 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -793,6 +793,9 @@ function getSuggestedSearchesVisibility( visibility: { [CONST.SEARCH.SEARCH_KEYS.EXPENSES]: true, [CONST.SEARCH.SEARCH_KEYS.REPORTS]: true, + [CONST.SEARCH.SEARCH_KEYS.BILLS]: false, + [CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]: false, + [CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]: false, [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: shouldShowSubmitSuggestion, [CONST.SEARCH.SEARCH_KEYS.PAY]: shouldShowPaySuggestion, [CONST.SEARCH.SEARCH_KEYS.APPROVE]: shouldShowApproveSuggestion, @@ -2771,7 +2774,14 @@ function getReportSections({ const mergedPersonalDetails = mergePersonalDetailsLists(onyxPersonalDetailsList, data.personalDetailsList); for (const key of orderedKeys) { - if (isReportEntry(key) && (data[key].type === CONST.REPORT.TYPE.IOU || data[key].type === CONST.REPORT.TYPE.EXPENSE || data[key].type === CONST.REPORT.TYPE.INVOICE)) { + if ( + isReportEntry(key) && + !data[key].isHiddenForBillReceiver && + (data[key].type === CONST.REPORT.TYPE.IOU || + data[key].type === CONST.REPORT.TYPE.EXPENSE || + data[key].type === CONST.REPORT.TYPE.INVOICE || + data[key].type === CONST.REPORT.TYPE.BILL) + ) { const reportItem = {...data[key]} as OnyxTypes.Report; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`; const transactions = reportIDToTransactions[reportKey]?.transactions ?? []; @@ -2804,19 +2814,23 @@ function getReportSections({ allReportTransactions, ); + const fromAccountID = reportItem.billSenderAccountID ?? reportItem.ownerAccountID; + const toAccountID = reportItem.billReceiverAccountID ?? reportItem.managerID; const fromDetails = - mergedPersonalDetails?.[reportItem.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? - getPersonalDetailsForAccountID(reportItem.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID) ?? - emptyPersonalDetails; - const toDetails = !shouldShowBlankTo && reportItem.managerID ? mergedPersonalDetails?.[reportItem.managerID] : emptyPersonalDetails; + mergedPersonalDetails?.[fromAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? getPersonalDetailsForAccountID(fromAccountID ?? CONST.DEFAULT_NUMBER_ID) ?? emptyPersonalDetails; + const toDetails = !shouldShowBlankTo && toAccountID ? mergedPersonalDetails?.[toAccountID] : emptyPersonalDetails; // First approver/approved come from the earliest APPROVED/FORWARDED report action; blank when the report has no approval. const firstApprovedAction = getFirstApprovedAction(firstApprovedActionByReportID.get(reportItem.reportID), actions); const firstApproverAccountID = firstApprovedAction?.actorAccountID; const firstApproverDetails = firstApproverAccountID ? mergedPersonalDetails?.[firstApproverAccountID] : undefined; const firstApproved = firstApprovedAction?.created ?? ''; - const formattedFrom = temporaryGetDisplayNameOrDefault({passedPersonalDetails: fromDetails, translate, formatPhoneNumber}); - const formattedTo = !shouldShowBlankTo ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: toDetails, translate, formatPhoneNumber}) : ''; + const formattedFrom = + currentQueryJSON?.type === CONST.SEARCH.DATA_TYPES.BILL + ? (fromDetails?.login ?? '') + : temporaryGetDisplayNameOrDefault({passedPersonalDetails: fromDetails, translate, formatPhoneNumber}); + const toDisplayName = !shouldShowBlankTo ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: toDetails, translate, formatPhoneNumber}) : ''; + const formattedTo = currentQueryJSON?.type === CONST.SEARCH.DATA_TYPES.BILL ? (toDetails?.login ?? '') : toDisplayName; const formattedFirstApprover = firstApproverAccountID ? temporaryGetDisplayNameOrDefault({passedPersonalDetails: firstApproverDetails, translate, formatPhoneNumber}) : ''; // The paid-by user is the actor on the latest payment action. It stays blank until the report is paid. @@ -3682,7 +3696,7 @@ function getSections({ return [...getTaskSections(data, formatPhoneNumber, translate, conciergeReportID, reportNameValuePairs, reportAttributesDerivedValue, queryJSON), false]; } - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.BILL) { return getReportSections({ data, currentSearch, @@ -3821,7 +3835,7 @@ function getSortedSections( if (type === CONST.SEARCH.DATA_TYPES.TASK) { return getSortedTaskData(data as TaskListItemType[], localeCompare, sortBy, sortOrder); } - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.BILL) { return getSortedReportData(data as TransactionReportGroupListItemType[], localeCompare, translate, sortBy, sortOrder); } @@ -4278,7 +4292,7 @@ function isSearchResultsEmpty(searchResults: SearchResults, groupBy?: SearchGrou return !Object.keys(searchResults?.data).some((key) => isGroupEntry(key)); } - if (searchResults?.search?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + if (searchResults?.search?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || searchResults?.search?.type === CONST.SEARCH.DATA_TYPES.BILL) { return !Object.keys(searchResults?.data).some( (key) => isReportEntry(key) && (searchResults?.data[key as keyof typeof searchResults.data] as OnyxTypes.Report)?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); @@ -4317,6 +4331,7 @@ function getCustomColumns(value?: SearchDataTypes | SearchGroupBy): SearchCustom case CONST.SEARCH.DATA_TYPES.EXPENSE: return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE); case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT: + case CONST.SEARCH.DATA_TYPES.BILL: return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE_REPORT); case CONST.SEARCH.DATA_TYPES.INVOICE: return Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.INVOICE); @@ -4358,6 +4373,7 @@ function getCustomColumnDefault(value?: SearchDataTypes | SearchGroupBy): Search case CONST.SEARCH.DATA_TYPES.EXPENSE: return CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE; case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT: + case CONST.SEARCH.DATA_TYPES.BILL: return CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE_REPORT; case CONST.SEARCH.DATA_TYPES.INVOICE: return CONST.SEARCH.TYPE_DEFAULT_COLUMNS.INVOICE; @@ -4679,6 +4695,7 @@ type TypeMenuSectionsParams = { draftTransactionIDs: string[] | undefined; isTrackIntentUser: boolean; hasReportAwaitingApproval?: boolean; + hasBills?: boolean; policyCategories?: OnyxCollection; }; @@ -4696,6 +4713,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS draftTransactionIDs, isTrackIntentUser, hasReportAwaitingApproval = false, + hasBills = false, policyCategories, } = params; const typeMenuSections: SearchTypeMenuSection[] = []; @@ -4771,6 +4789,13 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS } } + if (hasBills) { + typeMenuSections.push({ + translationPath: 'billPay.bills', + menuItems: [suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS], suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE], suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]], + }); + } + // Accounting section { const accountingSection: SearchTypeMenuSection = { @@ -4885,6 +4910,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS const SAVED_SEARCH_TYPE_TO_ICON_NAME = { [CONST.SEARCH.DATA_TYPES.EXPENSE]: 'ReceiptBookmark', [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: 'DocumentBookmark', + [CONST.SEARCH.DATA_TYPES.BILL]: 'InvoiceBookmark', [CONST.SEARCH.DATA_TYPES.CHAT]: 'CommentBubbleBookmark', [CONST.SEARCH.DATA_TYPES.INVOICE]: 'InvoiceBookmark', [CONST.SEARCH.DATA_TYPES.TRIP]: 'LuggageBookmark', @@ -4987,6 +5013,7 @@ function getStatusOptions(translate: LocalizedTranslate, type: SearchDataTypes) case CONST.SEARCH.DATA_TYPES.TASK: return getTaskStatusOptions(translate); case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT: + case CONST.SEARCH.DATA_TYPES.BILL: return getExpenseReportedStatusOptions(translate); case CONST.SEARCH.DATA_TYPES.EXPENSE: default: @@ -5820,6 +5847,9 @@ function getDisplayValue( if (filterValue === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { return translate('common.expenseReport'); } + if (filterValue === CONST.SEARCH.DATA_TYPES.BILL) { + return translate('billPay.bills'); + } return filterValue ? translate(`common.${filterValue}`) : undefined; } @@ -6353,7 +6383,7 @@ function getColumnsToShow({ return !!getReportCustomColumnValue(column, reportToCheck); }; - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.BILL) { const defaultReportColumns: SearchColumnType[] = [ CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.DATE, diff --git a/src/libs/actions/BillPay.ts b/src/libs/actions/BillPay.ts new file mode 100644 index 000000000000..6ada1fb426ff --- /dev/null +++ b/src/libs/actions/BillPay.ts @@ -0,0 +1,95 @@ +/** Creates and pays bills through Classic's intake and reimbursement flows. */ +import {write} from '@libs/API'; +import type {CreateBillParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {generateReportID} from '@libs/ReportUtils'; +import type {SearchKey} from '@libs/SearchKeyUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Report} from '@src/types/onyx'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type {OnyxData} from '@src/types/onyx/Request'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import mergeAdditionalPayOnyxData from './IOU/mergeAdditionalPayOnyxData'; +import {getSearchPayOnyxData} from './Search'; + +function payBill(report: OnyxEntry, paymentMethodType: PaymentMethodType, bankAccountID?: number, searchHash?: number, searchKey?: SearchKey) { + if (!report) { + return; + } + const key = `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}` as const; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key, + value: { + stateNum: paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE ? CONST.REPORT.STATE_NUM.APPROVED : CONST.REPORT.STATE_NUM.BILLING, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + pendingFields: {reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {reimbursed: null}, + }, + }, + ], + successData: [{onyxMethod: Onyx.METHOD.MERGE, key, value: {pendingFields: {reimbursed: null}}}], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key, + value: { + stateNum: report.stateNum, + statusNum: report.statusNum, + pendingFields: {reimbursed: null}, + errorFields: {reimbursed: getMicroSecondOnyxErrorWithTranslationKey('billPay.paymentFailed')}, + }, + }, + ], + }; + const searchData = searchHash === undefined ? undefined : getSearchPayOnyxData(searchHash, report.reportID, searchKey); + write(WRITE_COMMANDS.PAY_BILL, {reportID: report.reportID, paymentMethodType, bankAccountID}, mergeAdditionalPayOnyxData(onyxData, searchData)); +} + +function createBill(params: Omit, accountID: number) { + const reportID = generateReportID(); + const invoiceReportID = generateReportID(); + const key = `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; + write( + WRITE_COMMANDS.CREATE_BILL, + {...params, reportID, invoiceReportID}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key, + value: { + reportID, + invoiceID: invoiceReportID, + type: CONST.REPORT.TYPE.BILL, + reportName: params.merchant, + ownerAccountID: accountID, + total: -params.amount, + currency: params.currency, + created: params.date, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + }, + ], + successData: [{onyxMethod: Onyx.METHOD.MERGE, key, value: {pendingFields: {createChat: null}}}], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key, value: {pendingFields: {createChat: null}, errors: getMicroSecondOnyxErrorWithTranslationKey('billPay.createFailed')}}], + }, + ); + Navigation.dismissModal(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); +} + +export {createBill, payBill}; diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index ef707dcbcf9c..886a728cea3d 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -13,6 +13,7 @@ import type { UnapproveExpenseReportParams, } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {canPayBill, isBillReport} from '@libs/BillPayUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getIsOffline} from '@libs/NetworkState'; @@ -222,10 +223,14 @@ function canIOUBePaid( const isChatReportArchived = isArchivedReport(reportNameValuePairs); const iouSettled = isSettled(iouReport); - if (isEmptyObject(iouReport)) { + if (isEmptyObject(iouReport) || iouReport.isHiddenForBillReceiver) { return false; } + if (isBillReport(iouReport) || iouReport?.isBillPayReport) { + return !isChatReportArchived && canPayBill(iouReport, policy, currentUserAccountID, currentUserLogin); + } + if (getReimbursementChoice(policy) === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) { if (!onlyShowPayElsewhere) { return false; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 38d0600a4de0..7c20fb53b93e 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -590,11 +590,16 @@ function getSearchActionOnyxData(hash: number, reportID: string, currentSearchKe } function getSearchPayOnyxData(hash: number, reportID: string, currentSearchKey?: SearchKey): AdditionalPayOnyxData { - return getSearchActionOnyxData(hash, reportID, currentSearchKey, [CONST.SEARCH.SEARCH_KEYS.PAY]); + return getSearchActionOnyxData(hash, reportID, currentSearchKey, [CONST.SEARCH.SEARCH_KEYS.PAY, CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]); } function getSearchApproveOnyxData(hash: number, reportID: string, currentSearchKey?: SearchKey): AdditionalPayOnyxData { - return getSearchActionOnyxData(hash, reportID, currentSearchKey, [CONST.SEARCH.SEARCH_KEYS.APPROVE, CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH, CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]); + return getSearchActionOnyxData(hash, reportID, currentSearchKey, [ + CONST.SEARCH.SEARCH_KEYS.APPROVE, + CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE, + CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH, + CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD, + ]); } type GetPayActionCallbackParams = { diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 7ff8db3414e5..37738b8c92da 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -278,6 +278,7 @@ function EmptySearchViewContent({ }; break; case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT: + case CONST.SEARCH.DATA_TYPES.BILL: if (!isFilteredWorkspaceAccessible) { content = { ...defaultViewItemHeader.folder, diff --git a/src/pages/bills/CreateBillPage.tsx b/src/pages/bills/CreateBillPage.tsx new file mode 100644 index 000000000000..5acd27d9c7ad --- /dev/null +++ b/src/pages/bills/CreateBillPage.tsx @@ -0,0 +1,153 @@ +/** Manual bill intake. Attachments keep the typed invoice details and never enter SmartScan. */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import TextInput from '@components/TextInput'; +import UploadFile from '@components/UploadFile'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {createBill} from '@libs/actions/BillPay'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {isValidDate, isValidEmailWithTLD} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +function CreateBillPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [files, setFiles] = useState([]); + const [fileError, setFileError] = useState(''); + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + if (!isValidEmailWithTLD(`bill@${values.domain.trim()}`)) { + errors.domain = translate('common.error.email'); + } + if (!isValidEmailWithTLD(values.vendorEmail.trim())) { + errors.vendorEmail = translate('common.error.email'); + } + if (!values.merchant.trim()) { + errors.merchant = translate('common.error.fieldRequired'); + } + const amount = Number(values.amount.replace(',', '.')); + if (!Number.isFinite(amount) || convertToBackendAmount(amount) <= 0) { + errors.amount = translate('common.error.invalidAmount'); + } + if (!/^[A-Z]{3}$/.test(values.currency.toUpperCase())) { + errors.currency = translate('common.error.pleaseCompleteForm'); + } + if (!isValidDate(values.date)) { + errors.date = translate('common.error.dateInvalid'); + } + return errors; + }; + + return ( + + Navigation.goBack()} + /> + + + createBill( + { + domain: values.domain.trim().toLowerCase(), + vendorEmail: values.vendorEmail.trim().toLowerCase(), + merchant: values.merchant.trim(), + amount: convertToBackendAmount(Number(values.amount.replace(',', '.'))), + currency: values.currency.toUpperCase(), + date: values.date, + file: files.at(0), + }, + session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + ) + } + submitButtonText={translate('billPay.createBill')} + enabledWhenOffline + > + + + + + + + + setFiles([])} + acceptedFileTypes={['pdf']} + fileLimit={1} + maxFileSize={CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE} + errorText={fileError} + setError={setFileError} + /> + + + + + ); +} + +export default CreateBillPage; diff --git a/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx b/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx index 8c289b5b6676..90ab70e644c1 100644 --- a/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx +++ b/src/pages/domain/Admins/DomainAdminsSettingsPage.tsx @@ -1,6 +1,7 @@ import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -54,6 +55,7 @@ function DomainAdminsSettingsPage({route}: DomainAdminsSettingsPageProps) { value={technicalContactSettings?.technicalContactEmail} /> + {translate('billPay.primaryContactHint', domainName ?? '')} interceptAnonymousUser(() => Navigation.navigate(ROUTES.CREATE_BILL))} + shouldCallAfterModalHide={shouldUseNarrowLayout} + /> + ); +} + +export default CreateBillMenuItem; diff --git a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx index 3d70c91acd3b..1c91fe7ba413 100644 --- a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx @@ -12,6 +12,7 @@ import {View} from 'react-native'; import FABButtons from './FABPopoverContent/FABButtons'; import FABPopoverMenu from './FABPopoverContent/FABPopoverMenu'; +import CreateBillMenuItem from './FABPopoverContent/menuItems/CreateBillMenuItem'; import CreateReportMenuItem from './FABPopoverContent/menuItems/CreateReportMenuItem'; import ExpenseMenuItem from './FABPopoverContent/menuItems/ExpenseMenuItem'; import InvoiceMenuItem from './FABPopoverContent/menuItems/InvoiceMenuItem'; @@ -77,6 +78,7 @@ function FloatingActionButtonAndPopover() { + diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 7ad840fcf476..e53930abf16c 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -318,6 +318,12 @@ function getStableReportSelector(report: OnyxEntry) { type: report.type, visibility: report.visibility, invoiceReceiver: report.invoiceReceiver, + billID: report.billID, + invoiceID: report.invoiceID, + billSenderAccountID: report.billSenderAccountID, + billReceiverAccountID: report.billReceiverAccountID, + isBillPayReport: report.isBillPayReport, + isHiddenForBillReceiver: report.isHiddenForBillReceiver, transactionCount: report.transactionCount, parentReportID: report.parentReportID, parentReportActionID: report.parentReportActionID, diff --git a/src/types/form/CreateBillForm.ts b/src/types/form/CreateBillForm.ts new file mode 100644 index 000000000000..8f75fd9f07c0 --- /dev/null +++ b/src/types/form/CreateBillForm.ts @@ -0,0 +1,15 @@ +import type Form from './Form'; + +type CreateBillForm = Form< + 'domain' | 'vendorEmail' | 'merchant' | 'amount' | 'currency' | 'date', + { + domain: string; + vendorEmail: string; + merchant: string; + amount: string; + currency: string; + date: string; + } +>; + +export default CreateBillForm; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 47b94b46c3e7..fa435d520507 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -54,6 +54,7 @@ export type {WorkspaceTaxValueForm} from './WorkspaceTaxValueForm'; export type {WorkspaceTaxCustomName} from './WorkspaceTaxCustomName'; export type {WorkspaceTaxCodeForm} from './WorkspaceTaxCodeForm'; export type {WorkspaceInvoicesCompanyNameForm} from './WorkspaceInvoicesCompanyNameForm'; +export type {default as CreateBillForm} from './CreateBillForm'; export type {WorkspaceInvoicesCompanyWebsiteForm} from './WorkspaceInvoicesCompanyWebsiteForm'; export type {PolicyCreateDistanceRateForm} from './PolicyCreateDistanceRateForm'; export type {PolicyDistanceRateTaxReclaimableOnEditForm} from './PolicyDistanceRateTaxReclaimableOnEditForm'; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index d9446a6f9f89..1874761fb1b2 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -170,6 +170,13 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Invoice room receiver data */ invoiceReceiver?: InvoiceReceiver; + billID?: string; + invoiceID?: string; + billSenderAccountID?: number; + billReceiverAccountID?: number; + isBillPayReport?: boolean; + isHiddenForBillReceiver?: boolean; + /** Number of transactions in the report */ transactionCount?: number; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 81ca399c9948..b3363199f134 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -32,7 +32,7 @@ type ListItemDataType(value: NonNullable | undefined): UseOnyxResult Mark as paid". const mockOnPressHolder: {current?: (params: PaymentActionParams) => void} = {current: undefined}; +jest.mock('@components/BillPaymentButton', () => ({__esModule: true, default: jest.fn(() => null)})); jest.mock('@components/SettlementButton', () => ({ __esModule: true, default: (props: {onPress?: (params: PaymentActionParams) => void}) => { @@ -151,8 +153,7 @@ describe('PayActionCell', () => { mockedUseOnyx.mockImplementation(() => createOnyxResult(undefined)); }); - it('calls payInvoice with the chatReport supplied as a prop (the flat `type:invoice columns:...` transaction row now resolves and passes it)', () => { - // Given an invoice report row whose invoice chat is resolved and passed down as a prop + it('offers the bill payment flow for an invoice with a parent room', () => { render( { />, ); - // When the user confirms the payment from the cell's settlement button - act(() => { - mockOnPressHolder.current?.({ - paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - payAsBusiness: false, - }); - }); - - // Then the invoice payment should use the supplied chat report because paying an invoice needs the real invoice chat data - expect(mockedPayInvoice).toHaveBeenCalledWith( - expect.objectContaining({ - paymentMethodType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - chatReport, - invoiceReport, - }), - ); + expect(jest.mocked(BillPaymentButton)).toHaveBeenCalledWith(expect.objectContaining({report: invoiceReport, searchHash: TEST_HASH}), undefined); + expect(mockedPayInvoice).not.toHaveBeenCalled(); }); - it('does not call payInvoice when no chatReport prop is supplied', () => { - // Given an invoice report row whose invoice chat is not loaded (no chatReport prop) + it('offers the same bill payment flow without a parent room', () => { render( { />, ); - // When the user confirms the payment from the cell's settlement button - act(() => { - mockOnPressHolder.current?.({ - paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - payAsBusiness: false, - }); - }); - - // Then no invoice payment should happen and the drop should be logged because a fallback chat report is not safe for invoices + expect(jest.mocked(BillPaymentButton)).toHaveBeenCalledWith(expect.objectContaining({report: invoiceReport, searchHash: TEST_HASH}), undefined); expect(mockedPayInvoice).not.toHaveBeenCalled(); - expect(mockLogInfo).toHaveBeenCalledWith('[SearchPay] Dropping invoice row pay: chat report is not loaded', false, {reportID: TEST_INVOICE_REPORT_ID}); }); it('pays a money request with a fallback chat report when no chatReport prop is supplied', () => { diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts new file mode 100644 index 000000000000..af97062cdd12 --- /dev/null +++ b/tests/unit/BillPayTest.ts @@ -0,0 +1,122 @@ +import {canApproveBill, canPayBill, isBillPayReport} from '@libs/BillPayUtils'; +import {translate} from '@libs/Localize'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {getSuggestedSearches} from '@libs/SearchSuggestionUtils'; +import {getSections, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, SearchResults} from '@src/types/onyx'; + +import createMock from '../utils/createMock'; + +describe('Bill Pay', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + + const accountID = 123; + const email = 'payer@example.com'; + const policy: Policy = { + id: 'workspace', + name: 'Bills', + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: email, + reimburser: email, + outputCurrency: CONST.CURRENCY.USD, + }; + const bill: Report = { + reportID: '100', + type: CONST.REPORT.TYPE.BILL, + policyID: policy.id, + ownerAccountID: accountID, + managerID: accountID, + total: -125, + currency: CONST.CURRENCY.USD, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + + it('moves a bill from approval to payment only after final approval', () => { + expect(canApproveBill(bill, accountID)).toBe(true); + expect(canPayBill(bill, policy, accountID, email)).toBe(false); + const approved = {...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; + expect(canApproveBill(approved, accountID)).toBe(false); + expect(canPayBill(approved, policy, accountID, email)).toBe(true); + expect(canPayBill({...approved, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}, policy, accountID, email)).toBe(false); + expect(canPayBill({...approved, isWaitingOnBankAccount: true}, policy, accountID, email)).toBe(false); + }); + + it('only offers payment to the authorized payer, even when another user is an admin', () => { + const approved = {...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; + expect(canPayBill(approved, policy, 456, 'admin@example.com')).toBe(false); + expect(canPayBill(approved, {...policy, reimburser: undefined}, accountID, email)).toBe(true); + }); + + it('includes received standalone invoices without requiring a room', () => { + const invoice: Report = {...bill, type: CONST.REPORT.TYPE.INVOICE, ownerAccountID: 456}; + expect(isBillPayReport(invoice, accountID)).toBe(true); + expect(canPayBill(invoice, undefined, accountID, email)).toBe(true); + expect(isBillPayReport(invoice, 456)).toBe(false); + expect(isBillPayReport({...invoice, billID: bill.reportID, isHiddenForBillReceiver: true}, accountID)).toBe(false); + }); + + it('keeps business invoices visible to receivers identified by Auth', () => { + const invoice: Report = {...bill, type: CONST.REPORT.TYPE.INVOICE, ownerAccountID: 456, managerID: 789, isBillPayReport: true}; + expect(isBillPayReport(invoice, accountID)).toBe(true); + expect(canPayBill(invoice, undefined, accountID, email)).toBe(true); + }); + + it('builds distinct Bills searches with the payer filter on Ready to pay', () => { + const searches = getSuggestedSearches(accountID); + const bills = buildSearchQueryJSON(searches[CONST.SEARCH.SEARCH_KEYS.BILLS].searchQuery); + const approval = buildSearchQueryJSON(searches[CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE].searchQuery); + const payment = buildSearchQueryJSON(searches[CONST.SEARCH.SEARCH_KEYS.BILLS_PAY].searchQuery); + expect(bills?.type).toBe(CONST.SEARCH.DATA_TYPES.BILL); + expect(approval?.type).toBe(CONST.SEARCH.DATA_TYPES.BILL); + expect(payment?.type).toBe(CONST.SEARCH.DATA_TYPES.BILL); + expect(searches[CONST.SEARCH.SEARCH_KEYS.BILLS_PAY].searchQuery).toContain(`payer:${accountID}`); + expect(new Set([bills?.hash, approval?.hash, payment?.hash]).size).toBe(3); + }); + + it('renders the bill with sender and receiver emails and suppresses its invoice', () => { + const vendorAccountID = 456; + const approved: Report = { + ...bill, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + billSenderAccountID: 456, + billReceiverAccountID: accountID, + }; + const data = createMock({ + personalDetailsList: { + [accountID]: {accountID, login: email, displayName: 'Receiver'}, + [vendorAccountID]: {accountID: vendorAccountID, login: 'billing@vendor.com', displayName: 'Vendor'}, + }, + }); + data[`${ONYXKEYS.COLLECTION.REPORT}100`] = approved; + data[`${ONYXKEYS.COLLECTION.REPORT}200`] = {...approved, reportID: '200', type: CONST.REPORT.TYPE.INVOICE, isHiddenForBillReceiver: true}; + data[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; + const [sections] = getSections({ + type: CONST.SEARCH.DATA_TYPES.BILL, + data, + currentAccountID: accountID, + currentUserEmail: email, + queryJSON: buildSearchQueryJSON('type:bill'), + translate: (key: TPath, ...parameters: TranslationParameters) => translate(CONST.LOCALES.EN, key, ...parameters), + formatPhoneNumber: (phone) => phone, + convertToDisplayString: (amount) => String(amount), + bankAccountList: {}, + rules: undefined, + conciergeReportID: undefined, + dateFnsLocale: undefined, + reportAttributesDerivedValue: {}, + }); + const reports = sections.filter(isTransactionReportGroupListItemType); + expect(reports).toHaveLength(1); + expect(reports.at(0)).toMatchObject({reportID: '100', formattedFrom: 'billing@vendor.com', formattedTo: email, action: CONST.SEARCH.ACTION_TYPES.PAY}); + }); +}); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 5ce6efa63406..1fbb7eab4aa2 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -14679,6 +14679,9 @@ describe('getLastSearchQuery', () => { const savedSearchQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`; const searchFilters: OnyxTypes.SearchFilters = { + [CONST.SEARCH.SEARCH_KEYS.BILLS]: {query: 'type:bill', timestamp: '2026-09-23 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]: {query: 'type:bill action:approve', timestamp: '2026-09-23 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]: {query: 'type:bill action:pay', timestamp: '2026-09-23 00:00:00.000'}, [submitKey]: {query: submitQuery, timestamp: '2026-08-21 00:00:00.000'}, [savedSearchKey]: {query: savedSearchQuery, timestamp: '2026-08-21 00:00:00.000'}, [CONST.SEARCH.SEARCH_KEYS.EXPENSES]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`, timestamp: '2026-08-21 00:00:00.000'}, diff --git a/tests/unit/SidebarFilterTest.ts b/tests/unit/SidebarFilterTest.ts index de053c2b1a36..58f529ea5ccb 100644 --- a/tests/unit/SidebarFilterTest.ts +++ b/tests/unit/SidebarFilterTest.ts @@ -279,14 +279,14 @@ xdescribe('Sidebar', () => { ); }); - it('filter paycheck and bill report', () => { + it('filters paycheck reports and reports that are not found', () => { const report1: Report = { ...LHNTestUtils.getFakeReport(), type: CONST.REPORT.UNSUPPORTED_TYPE.PAYCHECK, }; const report2: Report = { ...LHNTestUtils.getFakeReport(), - type: CONST.REPORT.UNSUPPORTED_TYPE.BILL, + type: CONST.REPORT.TYPE.BILL, errorFields: { notFound: { error: 'Report not found', diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index d599714508c6..3f049d586cd0 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -147,7 +147,7 @@ jest.mock('@pages/home/ForYouSection', () => { return MockForYouSection; }); jest.mock('@pages/home/UpcomingTravelSection', () => mockSection('UpcomingTravelSection')); -jest.mock('@pages/home/RecentlyAddedSection', () => mockSection('RecentlyAddedSection'), {virtual: true}); +jest.mock('@pages/home/RecentlyAddedSection', () => mockSection('RecentlyAddedSection')); jest.mock('@pages/home/YourSpendSection', () => mockSection('YourSpendSection')); jest.mock('@pages/home/InsightsSection', () => mockSection('InsightsSection')); jest.mock('@pages/home/DiscoverSection', () => mockSection('DiscoverSection')); diff --git a/tests/unit/useTodoCountsTest.tsx b/tests/unit/useTodoCountsTest.tsx index d9b73bb5ffc5..c5444722a0eb 100644 --- a/tests/unit/useTodoCountsTest.tsx +++ b/tests/unit/useTodoCountsTest.tsx @@ -149,6 +149,8 @@ describe('useTodoCounts', () => { const {result} = await renderTodoCounts(); expect(result.current.counts).toEqual({ + [CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]: 0, + [CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]: 0, [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: 0, [CONST.SEARCH.SEARCH_KEYS.APPROVE]: 0, [CONST.SEARCH.SEARCH_KEYS.PAY]: 0, From f4ec3bdf3828bf8db9d2034498a2dfdc3b75be99 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Thu, 24 Sep 2026 09:32:18 -0400 Subject: [PATCH 02/14] Reuse Classic bill create and payment commands --- src/libs/API/parameters/MarkReimbursedParams.ts | 6 ++++++ src/libs/API/parameters/PayBillParams.ts | 9 --------- src/libs/API/parameters/ReimburseReportParams.ts | 6 ++++++ src/libs/API/parameters/index.ts | 3 ++- src/libs/API/types.ts | 8 +++++--- src/libs/BillPayUtils.ts | 2 +- src/libs/actions/BillPay.ts | 8 ++++++-- tests/unit/BillPayTest.ts | 1 + 8 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 src/libs/API/parameters/MarkReimbursedParams.ts delete mode 100644 src/libs/API/parameters/PayBillParams.ts create mode 100644 src/libs/API/parameters/ReimburseReportParams.ts diff --git a/src/libs/API/parameters/MarkReimbursedParams.ts b/src/libs/API/parameters/MarkReimbursedParams.ts new file mode 100644 index 000000000000..d44d96e18fcb --- /dev/null +++ b/src/libs/API/parameters/MarkReimbursedParams.ts @@ -0,0 +1,6 @@ +type MarkReimbursedParams = { + reportID: string; + comment: string; +}; + +export default MarkReimbursedParams; diff --git a/src/libs/API/parameters/PayBillParams.ts b/src/libs/API/parameters/PayBillParams.ts deleted file mode 100644 index d49c7828fb37..000000000000 --- a/src/libs/API/parameters/PayBillParams.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; - -type PayBillParams = { - reportID: string; - paymentMethodType: PaymentMethodType; - bankAccountID?: number; -}; - -export default PayBillParams; diff --git a/src/libs/API/parameters/ReimburseReportParams.ts b/src/libs/API/parameters/ReimburseReportParams.ts new file mode 100644 index 000000000000..a0f54b7a67a6 --- /dev/null +++ b/src/libs/API/parameters/ReimburseReportParams.ts @@ -0,0 +1,6 @@ +type ReimburseReportParams = { + reportID: string; + bankAccountID: number; +}; + +export default ReimburseReportParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 06276e36fa2d..3c1634b08b5b 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -386,8 +386,9 @@ export type {default as SearchParams} from './Search'; export type {default as GetTransactionsConvertedAmountParams} from './GetTransactionsConvertedAmount'; export type {default as SendInvoiceParams} from './SendInvoiceParams'; export type {default as PayInvoiceParams} from './PayInvoiceParams'; -export type {default as PayBillParams} from './PayBillParams'; export type {default as CreateBillParams} from './CreateBillParams'; +export type {default as ReimburseReportParams} from './ReimburseReportParams'; +export type {default as MarkReimbursedParams} from './MarkReimbursedParams'; export type {default as MarkAsCashParams} from './MarkAsCashParams'; export type {default as MergeDuplicatesParams} from './MergeDuplicatesParams'; export type {default as MergeTransactionParams} from './MergeTransactionParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a4afd086aee3..e18bd74d7a7f 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -400,8 +400,9 @@ const WRITE_COMMANDS = { DISMISS_VIOLATION: 'DismissViolation', SEND_INVOICE: 'SendInvoice', PAY_INVOICE: 'PayInvoice', - PAY_BILL: 'PayBill', - CREATE_BILL: 'CreateBill', + REIMBURSE_REPORT: 'ReimburseReport', + MARK_REIMBURSED: 'MarkReimbursed', + CREATE_BILL: 'Bill_Create', MARK_AS_CASH: 'MarkAsCash', MERGE_DUPLICATES: 'MergeDuplicates', RESOLVE_DUPLICATES: 'ResolveDuplicates', @@ -1134,7 +1135,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DISMISS_VIOLATION]: Parameters.DismissViolationParams; [WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams; [WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams; - [WRITE_COMMANDS.PAY_BILL]: Parameters.PayBillParams; + [WRITE_COMMANDS.REIMBURSE_REPORT]: Parameters.ReimburseReportParams; + [WRITE_COMMANDS.MARK_REIMBURSED]: Parameters.MarkReimbursedParams; [WRITE_COMMANDS.CREATE_BILL]: Parameters.CreateBillParams; [WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams; [WRITE_COMMANDS.DUPLICATE_POLICY]: Parameters.DuplicateWorkspaceParams; diff --git a/src/libs/BillPayUtils.ts b/src/libs/BillPayUtils.ts index 0648a70652f8..e253eeb0a276 100644 --- a/src/libs/BillPayUtils.ts +++ b/src/libs/BillPayUtils.ts @@ -36,7 +36,7 @@ function canPayBill(report: OnyxEntry, policy: OnyxEntry | null, if (report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || (report.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED && report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED)) { return false; } - const reimburser = policy?.reimburser ?? policy?.achAccount?.reimburser ?? policy?.owner; + const reimburser = policy?.reimburser ?? policy?.achAccount?.reimburser; return reimburser ? reimburser === email : policy?.role === CONST.POLICY.ROLE.ADMIN; } diff --git a/src/libs/actions/BillPay.ts b/src/libs/actions/BillPay.ts index 6ada1fb426ff..5fb564fea5d1 100644 --- a/src/libs/actions/BillPay.ts +++ b/src/libs/actions/BillPay.ts @@ -22,7 +22,7 @@ import mergeAdditionalPayOnyxData from './IOU/mergeAdditionalPayOnyxData'; import {getSearchPayOnyxData} from './Search'; function payBill(report: OnyxEntry, paymentMethodType: PaymentMethodType, bankAccountID?: number, searchHash?: number, searchKey?: SearchKey) { - if (!report) { + if (!report || (paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !bankAccountID)) { return; } const key = `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}` as const; @@ -54,7 +54,11 @@ function payBill(report: OnyxEntry, paymentMethodType: PaymentMethodType ], }; const searchData = searchHash === undefined ? undefined : getSearchPayOnyxData(searchHash, report.reportID, searchKey); - write(WRITE_COMMANDS.PAY_BILL, {reportID: report.reportID, paymentMethodType, bankAccountID}, mergeAdditionalPayOnyxData(onyxData, searchData)); + if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + write(WRITE_COMMANDS.MARK_REIMBURSED, {reportID: report.reportID, comment: ''}, mergeAdditionalPayOnyxData(onyxData, searchData)); + } else { + write(WRITE_COMMANDS.REIMBURSE_REPORT, {reportID: report.reportID, bankAccountID: bankAccountID ?? CONST.DEFAULT_NUMBER_ID}, mergeAdditionalPayOnyxData(onyxData, searchData)); + } } function createBill(params: Omit, accountID: number) { diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index af97062cdd12..fe6651ddc0f6 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -54,6 +54,7 @@ describe('Bill Pay', () => { const approved = {...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; expect(canPayBill(approved, policy, 456, 'admin@example.com')).toBe(false); expect(canPayBill(approved, {...policy, reimburser: undefined}, accountID, email)).toBe(true); + expect(canPayBill(approved, {...policy, reimburser: undefined, owner: email}, 456, 'admin@example.com')).toBe(true); }); it('includes received standalone invoices without requiring a room', () => { From b86b6d738b12c98a8abdea92aaac3dc02a7af7d0 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Wed, 30 Sep 2026 14:42:08 -0400 Subject: [PATCH 03/14] Remove Bill Pay contributing guide --- contributingGuides/BILL_PAY.md | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 contributingGuides/BILL_PAY.md diff --git a/contributingGuides/BILL_PAY.md b/contributingGuides/BILL_PAY.md deleted file mode 100644 index 5155d68f6c7d..000000000000 --- a/contributingGuides/BILL_PAY.md +++ /dev/null @@ -1,24 +0,0 @@ -# Bill Pay - -[Release 0.1](https://github.com/Expensify/Expensify/issues/684654) uses the existing Classic approval, reimbursement, and export flows. - -`BillPayUtils` defines which reports belong in Bills and which users can approve or pay them. Spend, Home, and Inbox share these rules. Auth also scopes `type:bill` searches to the receiver. - -Bills and received standalone invoices use report rows with Date, Status, Report title, From, To, Total, and Action. The Bills section appears only when the user has matching content. - -`CreateBillPage` accepts manual details and an optional PDF. `BillPaymentButton` offers ACH and Mark as paid without needing a parent room. Both actions use optimistic Onyx updates with failure handling. - -Auth's `isHiddenForBillReceiver` hides linked invoices from navigation and previews. It does not block opening an invoice URL or responding to a comment notification. - -## Manual checks - -1. Open a workspace with existing Classic bills and confirm that Spend shows Bills. -2. Search for a vendor and confirm that the receiver sees the bill without its linked invoice. -3. Approve a bill and confirm that the authorized payer sees it in Ready to pay, Home, and the Inbox. -4. Sign in as another workspace admin and confirm that the bill does not appear in their Ready to pay list. -5. Create a bill with a PDF and confirm that the typed details remain and neither report gains a parent room. -6. Pay an approved bill and confirm that its invoice updates to paid. -7. Open the linked invoice URL and confirm that comments remain available. -8. Open domain settings and confirm that the primary contact hint includes the domain's Bill Pay address. - -Deploy Auth and Web-Expensify before App. No beta controls this feature. From a1b9b815edab5d0295cce27ca3884042aff3b6e1 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Fri, 2 Oct 2026 10:51:06 -0400 Subject: [PATCH 04/14] Derive the bill receiver from its report owner --- src/libs/DebugUtils.ts | 2 -- src/libs/SearchUIUtils.ts | 2 +- src/selectors/Report.ts | 1 - src/types/onyx/Report.ts | 1 - src/types/utils/whitelistedReportKeys.ts | 1 - tests/unit/BillPayTest.ts | 2 +- 6 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 710611ad26ae..dc6b2767c106 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -507,7 +507,6 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa case 'lastReadSequenceNumber': case 'managerID': case 'billSenderAccountID': - case 'billReceiverAccountID': case 'lastActorAccountID': case 'ownerAccountID': case 'total': @@ -629,7 +628,6 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa billID: CONST.RED_BRICK_ROAD_PENDING_ACTION, invoiceID: CONST.RED_BRICK_ROAD_PENDING_ACTION, billSenderAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, - billReceiverAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION, isBillPayReport: CONST.RED_BRICK_ROAD_PENDING_ACTION, isHiddenForBillReceiver: CONST.RED_BRICK_ROAD_PENDING_ACTION, description: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 49a6facc7766..65a5c314d968 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2815,7 +2815,7 @@ function getReportSections({ ); const fromAccountID = reportItem.billSenderAccountID ?? reportItem.ownerAccountID; - const toAccountID = reportItem.billReceiverAccountID ?? reportItem.managerID; + const toAccountID = reportItem.type === CONST.REPORT.TYPE.BILL ? reportItem.ownerAccountID : reportItem.managerID; const fromDetails = mergedPersonalDetails?.[fromAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? getPersonalDetailsForAccountID(fromAccountID ?? CONST.DEFAULT_NUMBER_ID) ?? emptyPersonalDetails; const toDetails = !shouldShowBlankTo && toAccountID ? mergedPersonalDetails?.[toAccountID] : emptyPersonalDetails; diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index e53930abf16c..0c42b400fd3c 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -321,7 +321,6 @@ function getStableReportSelector(report: OnyxEntry) { billID: report.billID, invoiceID: report.invoiceID, billSenderAccountID: report.billSenderAccountID, - billReceiverAccountID: report.billReceiverAccountID, isBillPayReport: report.isBillPayReport, isHiddenForBillReceiver: report.isHiddenForBillReceiver, transactionCount: report.transactionCount, diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 1874761fb1b2..d6848d69cf48 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -173,7 +173,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< billID?: string; invoiceID?: string; billSenderAccountID?: number; - billReceiverAccountID?: number; isBillPayReport?: boolean; isHiddenForBillReceiver?: boolean; diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts index dd9ca9db3a88..69816e2f0e6b 100644 --- a/src/types/utils/whitelistedReportKeys.ts +++ b/src/types/utils/whitelistedReportKeys.ts @@ -47,7 +47,6 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback< billID: unknown; invoiceID: unknown; billSenderAccountID: unknown; - billReceiverAccountID: unknown; isBillPayReport: unknown; isHiddenForBillReceiver: unknown; parentReportID: unknown; diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index fe6651ddc0f6..3e99e38c6afe 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -90,7 +90,7 @@ describe('Bill Pay', () => { stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, billSenderAccountID: 456, - billReceiverAccountID: accountID, + managerID: 789, }; const data = createMock({ personalDetailsList: { From c543ccad441b9a81729187238f2599b44a4080a9 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Fri, 2 Oct 2026 18:55:09 -0600 Subject: [PATCH 05/14] Hide bill payment actions when workspace payments are disabled --- src/libs/BillPayUtils.ts | 5 ++++ src/libs/SearchUIUtils.ts | 6 +++- tests/unit/BillPayTest.ts | 63 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/libs/BillPayUtils.ts b/src/libs/BillPayUtils.ts index e253eeb0a276..773fc1be7ee5 100644 --- a/src/libs/BillPayUtils.ts +++ b/src/libs/BillPayUtils.ts @@ -4,6 +4,8 @@ import type {Policy, Report} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {arePaymentsEnabled} from './PolicyUtils'; + function isBillReport(report: OnyxEntry): boolean { return report?.type === CONST.REPORT.TYPE.BILL; } @@ -33,6 +35,9 @@ function canPayBill(report: OnyxEntry, policy: OnyxEntry | null, if (report.type === CONST.REPORT.TYPE.INVOICE) { return report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED; } + if (!arePaymentsEnabled(policy)) { + return false; + } if (report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || (report.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED && report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED)) { return false; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 65a5c314d968..6be39b306d5c 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4790,9 +4790,13 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS } if (hasBills) { + const billMenuItems = [suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS], suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]]; + if (suggestedSearchesVisibility[CONST.SEARCH.SEARCH_KEYS.PAY]) { + billMenuItems.push(suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]); + } typeMenuSections.push({ translationPath: 'billPay.bills', - menuItems: [suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS], suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE], suggestedSearches[CONST.SEARCH.SEARCH_KEYS.BILLS_PAY]], + menuItems: billMenuItems, }); } diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index 3e99e38c6afe..b49c50b200fa 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -1,8 +1,9 @@ import {canApproveBill, canPayBill, isBillPayReport} from '@libs/BillPayUtils'; import {translate} from '@libs/Localize'; +import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSuggestedSearches} from '@libs/SearchSuggestionUtils'; -import {getSections, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; +import {createTypeMenuSections, getSections, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -26,6 +27,7 @@ describe('Bill Pay', () => { role: CONST.POLICY.ROLE.ADMIN, owner: email, reimburser: email, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, outputCurrency: CONST.CURRENCY.USD, }; const bill: Report = { @@ -57,6 +59,65 @@ describe('Bill Pay', () => { expect(canPayBill(approved, {...policy, reimburser: undefined, owner: email}, 456, 'admin@example.com')).toBe(true); }); + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO])( + 'hides the report Pay action when workspace payments are disabled with %s', + (reimbursementChoice) => { + // Given an approved bill whose workspace payer can pay when payments are enabled. + const approved: Report = {...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; + const params = { + report: approved, + currentUserAccountID: accountID, + currentUserLogin: email, + policy, + ownerLogin: email, + reportTransactions: [], + violations: {}, + bankAccountList: {}, + chatReport: undefined, + isChatReportArchived: false, + rules: undefined, + }; + expect(getReportPrimaryAction(params)).toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + + // When the workspace disables payments using either its current or Classic setting. + const disabledPolicy = {...policy, reimbursementChoice}; + + // Then the same approved bill offers no Pay action. + expect(canPayBill(approved, disabledPolicy, accountID, email)).toBe(false); + expect(getReportPrimaryAction({...params, policy: disabledPolicy})).not.toBe(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }, + ); + + it.each([CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, CONST.POLICY.DEPRECATED_REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO])( + 'omits Ready to pay from Bills navigation when workspace payments are disabled with %s', + (reimbursementChoice) => { + // Given Bills content in a workspace with payments disabled. + const params = { + currentUserEmail: email, + currentUserAccountID: accountID, + cardFeedsByPolicy: {}, + defaultCardFeed: undefined, + policies: {[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: {...policy, reimbursementChoice}}, + savedSearches: undefined, + isOffline: false, + defaultExpensifyCard: undefined, + draftTransactionIDs: undefined, + isTrackIntentUser: false, + hasBills: true, + }; + + // When the menu builds the suggested searches from the workspace settings. + const disabledSection = createTypeMenuSections(params).find((section) => section.translationPath === 'billPay.bills'); + + // Then Bills and Needs approval remain available, while Ready to pay requires payments enabled. + expect(disabledSection?.menuItems.map((item) => item.key)).toEqual([CONST.SEARCH.SEARCH_KEYS.BILLS, CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE]); + const enabledSection = createTypeMenuSections({...params, policies: {[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy}}).find( + (section) => section.translationPath === 'billPay.bills', + ); + expect(enabledSection?.menuItems.map((item) => item.key)).toContain(CONST.SEARCH.SEARCH_KEYS.BILLS_PAY); + }, + ); + it('includes received standalone invoices without requiring a room', () => { const invoice: Report = {...bill, type: CONST.REPORT.TYPE.INVOICE, ownerAccountID: 456}; expect(isBillPayReport(invoice, accountID)).toBe(true); From fbac2873200f699353d586004cd032d923c9b6e7 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Mon, 5 Oct 2026 18:29:39 -0700 Subject: [PATCH 06/14] Fix manual bill creation payload and cover bill actions --- src/libs/API/parameters/CreateBillParams.ts | 2 +- src/libs/actions/BillPay.ts | 7 ++- tests/unit/BillPayActionsTest.ts | 34 ++++++++++ tests/unit/BillPayTest.ts | 69 ++++++++++++++++++++- 4 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 tests/unit/BillPayActionsTest.ts diff --git a/src/libs/API/parameters/CreateBillParams.ts b/src/libs/API/parameters/CreateBillParams.ts index 3e72b564791a..90e3147a0f82 100644 --- a/src/libs/API/parameters/CreateBillParams.ts +++ b/src/libs/API/parameters/CreateBillParams.ts @@ -4,7 +4,7 @@ type CreateBillParams = { reportID: string; invoiceReportID: string; domain: string; - vendorEmail: string; + submitterEmail: string; merchant: string; amount: number; currency: string; diff --git a/src/libs/actions/BillPay.ts b/src/libs/actions/BillPay.ts index 5fb564fea5d1..b7bb4458e630 100644 --- a/src/libs/actions/BillPay.ts +++ b/src/libs/actions/BillPay.ts @@ -21,6 +21,8 @@ import Onyx from 'react-native-onyx'; import mergeAdditionalPayOnyxData from './IOU/mergeAdditionalPayOnyxData'; import {getSearchPayOnyxData} from './Search'; +type CreateBillInput = Omit & {vendorEmail: string}; + function payBill(report: OnyxEntry, paymentMethodType: PaymentMethodType, bankAccountID?: number, searchHash?: number, searchKey?: SearchKey) { if (!report || (paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !bankAccountID)) { return; @@ -61,13 +63,14 @@ function payBill(report: OnyxEntry, paymentMethodType: PaymentMethodType } } -function createBill(params: Omit, accountID: number) { +function createBill(params: CreateBillInput, accountID: number) { + const {vendorEmail, ...requestParams} = params; const reportID = generateReportID(); const invoiceReportID = generateReportID(); const key = `${ONYXKEYS.COLLECTION.REPORT}${reportID}` as const; write( WRITE_COMMANDS.CREATE_BILL, - {...params, reportID, invoiceReportID}, + {...requestParams, submitterEmail: vendorEmail, reportID, invoiceReportID}, { optimisticData: [ { diff --git a/tests/unit/BillPayActionsTest.ts b/tests/unit/BillPayActionsTest.ts new file mode 100644 index 000000000000..116f8cddff23 --- /dev/null +++ b/tests/unit/BillPayActionsTest.ts @@ -0,0 +1,34 @@ +import {createBill} from '@libs/actions/BillPay'; +import {write} from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +jest.mock('@libs/API'); +jest.mock('@libs/Navigation/Navigation'); + +describe('Bill Pay actions', () => { + beforeAll(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sends the vendor email using the field accepted by Bill_Create', () => { + // Given a manually entered bill whose vendor email must pass the same request filter as Classic. + const vendorEmail = 'sender@example.com'; + + // When the plus menu creates the bill with client report IDs. + createBill({domain: 'receiver.com', vendorEmail, merchant: 'Gardening supplies', amount: 1250, currency: CONST.CURRENCY.USD, date: '2026-10-05'}, 170); + + // Then Bill_Create receives submitterEmail instead of the vendorEmail field that Web removes. + expect(write).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_BILL, expect.objectContaining({submitterEmail: vendorEmail}), expect.anything()); + const request = jest.mocked(write).mock.calls.at(-1)?.[1]; + expect(request).toHaveProperty('reportID', expect.any(String)); + expect(request).toHaveProperty('invoiceReportID', expect.any(String)); + expect(request).not.toHaveProperty('vendorEmail'); + }); +}); diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index b49c50b200fa..77b1742df835 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, SearchResults} from '@src/types/onyx'; +import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; import createMock from '../utils/createMock'; @@ -28,6 +28,7 @@ describe('Bill Pay', () => { owner: email, reimburser: email, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, outputCurrency: CONST.CURRENCY.USD, }; const bill: Report = { @@ -52,6 +53,72 @@ describe('Bill Pay', () => { expect(canPayBill({...approved, isWaitingOnBankAccount: true}, policy, accountID, email)).toBe(false); }); + it.each([ + [CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED, CONST.REPORT.PRIMARY_ACTIONS.APPROVE], + [CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED, CONST.REPORT.PRIMARY_ACTIONS.PAY], + ] as const)('shows the bill header action for state %s and status %s', (stateNum, statusNum, expectedAction) => { + // Given an approver who is also the payer, only the bill's workflow stage determines the header action. + const report: Report = {...bill, stateNum, statusNum}; + const transaction = createMock({transactionID: '300', reportID: bill.reportID, amount: 125, currency: CONST.CURRENCY.USD}); + + // When the report header resolves its primary action with the bill transaction loaded. + const action = getReportPrimaryAction({ + report, + currentUserAccountID: accountID, + currentUserLogin: email, + policy, + ownerLogin: email, + reportTransactions: [transaction], + violations: {}, + bankAccountList: {}, + chatReport: undefined, + isChatReportArchived: false, + rules: undefined, + }); + + // Then Submitted offers Approve and final approval replaces it with Pay. + expect(action).toBe(expectedAction); + }); + + it.each([ + [CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED, CONST.SEARCH.ACTION_TYPES.APPROVE], + [CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED, CONST.SEARCH.ACTION_TYPES.PAY], + ] as const)('shows the Bills table action for state %s and status %s', (stateNum, statusNum, expectedAction) => { + // Given an accessible bill with an approver who can pay after final approval. + const vendorAccountID = 456; + const report: Report = {...bill, stateNum, statusNum, billSenderAccountID: vendorAccountID, transactionCount: 1}; + const transaction = createMock({transactionID: '300', reportID: bill.reportID, amount: 125, currency: CONST.CURRENCY.USD}); + const data = createMock({ + personalDetailsList: { + [accountID]: {accountID, login: email, displayName: 'Receiver'}, + [vendorAccountID]: {accountID: vendorAccountID, login: 'billing@vendor.com', displayName: 'Vendor'}, + }, + }); + data[`${ONYXKEYS.COLLECTION.REPORT}${bill.reportID}`] = report; + data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + data[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; + + // When Bills builds its report row using the current workflow stage. + const [sections] = getSections({ + type: CONST.SEARCH.DATA_TYPES.BILL, + data, + currentAccountID: accountID, + currentUserEmail: email, + queryJSON: buildSearchQueryJSON('type:bill'), + translate: (key: TPath, ...parameters: TranslationParameters) => translate(CONST.LOCALES.EN, key, ...parameters), + formatPhoneNumber: (phone) => phone, + convertToDisplayString: (amount) => String(amount), + bankAccountList: {}, + rules: undefined, + conciergeReportID: undefined, + dateFnsLocale: undefined, + reportAttributesDerivedValue: {}, + }); + + // Then the table offers Approve before approval and Pay only after approval. + expect(sections.filter(isTransactionReportGroupListItemType)).toEqual([expect.objectContaining({reportID: bill.reportID, action: expectedAction})]); + }); + it('only offers payment to the authorized payer, even when another user is an admin', () => { const approved = {...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; expect(canPayBill(approved, policy, 456, 'admin@example.com')).toBe(false); From c9517884ddc3a599fc0f562343074db2015fb0c4 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Tue, 6 Oct 2026 08:05:57 -0700 Subject: [PATCH 07/14] Read bill and invoice links from report name value pairs --- src/libs/BillPayUtils.ts | 2 +- src/libs/actions/BillPay.ts | 6 +++++- src/selectors/Report.ts | 2 -- src/types/onyx/Report.ts | 2 -- src/types/onyx/ReportNameValuePairs.ts | 6 ++++++ src/types/utils/whitelistedReportKeys.ts | 2 -- tests/unit/BillPayActionsTest.ts | 17 +++++++++++++++++ tests/unit/BillPayTest.ts | 2 +- 8 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/libs/BillPayUtils.ts b/src/libs/BillPayUtils.ts index 773fc1be7ee5..20cfceb4958a 100644 --- a/src/libs/BillPayUtils.ts +++ b/src/libs/BillPayUtils.ts @@ -14,7 +14,7 @@ function isBillPayReport(report: OnyxEntry, accountID: number): boolean if (!report || report.isHiddenForBillReceiver) { return false; } - return isBillReport(report) || (report.type === CONST.REPORT.TYPE.INVOICE && (report.isBillPayReport ?? (!report.billID && report.managerID === accountID))); + return isBillReport(report) || (report.type === CONST.REPORT.TYPE.INVOICE && (report.isBillPayReport ?? report.managerID === accountID)); } function canApproveBill(report: OnyxEntry, accountID: number): boolean { diff --git a/src/libs/actions/BillPay.ts b/src/libs/actions/BillPay.ts index b7bb4458e630..b614ec0f39e6 100644 --- a/src/libs/actions/BillPay.ts +++ b/src/libs/actions/BillPay.ts @@ -78,7 +78,6 @@ function createBill(params: CreateBillInput, accountID: number) { key, value: { reportID, - invoiceID: invoiceReportID, type: CONST.REPORT.TYPE.BILL, reportName: params.merchant, ownerAccountID: accountID, @@ -90,6 +89,11 @@ function createBill(params: CreateBillInput, accountID: number) { pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, + value: {invoiceID: invoiceReportID}, + }, ], successData: [{onyxMethod: Onyx.METHOD.MERGE, key, value: {pendingFields: {createChat: null}}}], failureData: [{onyxMethod: Onyx.METHOD.MERGE, key, value: {pendingFields: {createChat: null}, errors: getMicroSecondOnyxErrorWithTranslationKey('billPay.createFailed')}}], diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 0c42b400fd3c..cf4b2b5b25a8 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -318,8 +318,6 @@ function getStableReportSelector(report: OnyxEntry) { type: report.type, visibility: report.visibility, invoiceReceiver: report.invoiceReceiver, - billID: report.billID, - invoiceID: report.invoiceID, billSenderAccountID: report.billSenderAccountID, isBillPayReport: report.isBillPayReport, isHiddenForBillReceiver: report.isHiddenForBillReceiver, diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index d6848d69cf48..0ca7482e7c59 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -170,8 +170,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** Invoice room receiver data */ invoiceReceiver?: InvoiceReceiver; - billID?: string; - invoiceID?: string; billSenderAccountID?: number; isBillPayReport?: boolean; isHiddenForBillReceiver?: boolean; diff --git a/src/types/onyx/ReportNameValuePairs.ts b/src/types/onyx/ReportNameValuePairs.ts index 48cc245852c3..f59cdc4e5173 100644 --- a/src/types/onyx/ReportNameValuePairs.ts +++ b/src/types/onyx/ReportNameValuePairs.ts @@ -62,6 +62,12 @@ type ReportNameValuePairs = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Original reportID that spawned the current report */ originalID?: string; + /** On an invoice, the reportID of the bill linked to it. The backend sends numeric IDs as numbers. */ + billID?: string | number; + + /** On a bill, the reportID of the invoice linked to it. The backend sends numeric IDs as numbers. */ + invoiceID?: string | number; + /** Set on a thread the backend opened to collect feedback on a Concierge answer, naming the rated action */ conciergeFeedbackForReportActionID?: string; diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts index 69816e2f0e6b..90d1f7c037ce 100644 --- a/src/types/utils/whitelistedReportKeys.ts +++ b/src/types/utils/whitelistedReportKeys.ts @@ -44,8 +44,6 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback< creditedCurrency: unknown; visibility: unknown; invoiceReceiver: unknown; - billID: unknown; - invoiceID: unknown; billSenderAccountID: unknown; isBillPayReport: unknown; isHiddenForBillReceiver: unknown; diff --git a/tests/unit/BillPayActionsTest.ts b/tests/unit/BillPayActionsTest.ts index 116f8cddff23..58e18b7f6440 100644 --- a/tests/unit/BillPayActionsTest.ts +++ b/tests/unit/BillPayActionsTest.ts @@ -4,6 +4,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; jest.mock('@libs/API'); jest.mock('@libs/Navigation/Navigation'); @@ -31,4 +32,20 @@ describe('Bill Pay actions', () => { expect(request).toHaveProperty('invoiceReportID', expect.any(String)); expect(request).not.toHaveProperty('vendorEmail'); }); + + it('stores the optimistic invoice link as a report name value pair', () => { + // Given a bill created from the plus menu, whose link to its invoice is a standard rNVP on the backend. + createBill({domain: 'receiver.com', vendorEmail: 'sender@example.com', merchant: 'Gardening supplies', amount: 1250, currency: CONST.CURRENCY.USD, date: '2026-10-05'}, 170); + + // When the request queues its optimistic data. + const [, request, onyxData] = jest.mocked(write).mock.calls.at(-1) ?? []; + const reportID = request && 'reportID' in request ? String(request.reportID) : ''; + const invoiceReportID = request && 'invoiceReportID' in request ? request.invoiceReportID : undefined; + + // Then the link goes to reportNameValuePairs, where the backend sends it, and not onto the report. + expect(onyxData?.optimisticData).toEqual( + expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, value: {invoiceID: invoiceReportID}})]), + ); + expect(onyxData?.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${reportID}`)?.value).not.toHaveProperty('invoiceID'); + }); }); diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index 77b1742df835..bcbcb442a68b 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -190,7 +190,7 @@ describe('Bill Pay', () => { expect(isBillPayReport(invoice, accountID)).toBe(true); expect(canPayBill(invoice, undefined, accountID, email)).toBe(true); expect(isBillPayReport(invoice, 456)).toBe(false); - expect(isBillPayReport({...invoice, billID: bill.reportID, isHiddenForBillReceiver: true}, accountID)).toBe(false); + expect(isBillPayReport({...invoice, isHiddenForBillReceiver: true}, accountID)).toBe(false); }); it('keeps business invoices visible to receivers identified by Auth', () => { From 8d33325b3996249d56ee7fc26629211762e5985d Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Tue, 6 Oct 2026 16:39:22 -0700 Subject: [PATCH 08/14] Hide Reject on bills because Auth can't reject them --- src/libs/ReportUtils.ts | 5 +++++ tests/unit/BillPayTest.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 42dc78cdb2ac..408f8836ab9b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13723,6 +13723,11 @@ function canRejectReportAction(report: Report, currentUserAccountID: number | un return false; // Disable invoice } + // RejectExpenseReport returns "400 Bills are not supported", so the approver sends a bill back with Unapprove instead. + if (report?.type === CONST.REPORT.TYPE.BILL) { + return false; + } + if (isReportBeingProcessed) { return true; // non-IOU reports can be rejected while processing } diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index bcbcb442a68b..c182afdffd29 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -1,6 +1,7 @@ import {canApproveBill, canPayBill, isBillPayReport} from '@libs/BillPayUtils'; import {translate} from '@libs/Localize'; import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; +import {canRejectReportAction} from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSuggestedSearches} from '@libs/SearchSuggestionUtils'; import {createTypeMenuSections, getSections, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; @@ -80,6 +81,15 @@ describe('Bill Pay', () => { expect(action).toBe(expectedAction); }); + it('does not offer Reject to the approver of a submitted bill', () => { + // Given a submitted bill and an expense report in the same state, both waiting for the current user to approve. + const expenseReport: Report = {...bill, type: CONST.REPORT.TYPE.EXPENSE}; + + // When the approver opens their actions, then only the expense report offers Reject, because Auth can't reject bills. + expect(canRejectReportAction(bill, accountID, policy)).toBe(false); + expect(canRejectReportAction(expenseReport, accountID, policy)).toBe(true); + }); + it.each([ [CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED, CONST.SEARCH.ACTION_TYPES.APPROVE], [CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED, CONST.SEARCH.ACTION_TYPES.PAY], From 4d59fb3c375eb2e82fc407c62542d9933b2ed392 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Tue, 6 Oct 2026 17:15:20 -0700 Subject: [PATCH 09/14] Name the vendor when a bill payment waits for a bank account --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/ReportUtils.ts | 45 ++++++++++++++----- .../ReimbursementQueuedContent.tsx | 9 ++-- tests/unit/BillPayTest.ts | 30 ++++++++++++- 14 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index a325629e3f4a..4f8d374e13f3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1554,6 +1554,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `genehmigt${memo ? `, mit dem Vermerk ${memo}` : ''}`, rejectedThisReport: 'abgelehnt', waitingOnBankAccount: (submitterDisplayName: string) => `hat die Zahlung gestartet, wartet aber darauf, dass ${submitterDisplayName} ein persönliches Bankkonto hinzufügt.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `hat die Zahlung gestartet, wartet aber darauf, dass ${vendorDisplayName} ein Bankkonto hinzufügt.`, adminCanceledRequest: 'hat die Zahlung storniert', canceledRequest: (amount: string, submitterDisplayName: string) => `${amount} Zahlung storniert, weil ${submitterDisplayName} ihr Expensify Wallet nicht innerhalb von 30 Tagen aktiviert hat`, diff --git a/src/languages/el.ts b/src/languages/el.ts index b2648db6c00f..ddad0c7f8fc1 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1612,6 +1612,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `εγκρίθηκε${memo ? `, λέγοντας ${memo}` : ''}`, rejectedThisReport: 'απορρίφθηκε', waitingOnBankAccount: (submitterDisplayName: string) => `ξεκίνησε την πληρωμή, αλλά περιμένει τον/την ${submitterDisplayName} να προσθέσει έναν προσωπικό τραπεζικό λογαριασμό.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `ξεκίνησε την πληρωμή, αλλά περιμένει τον/την ${vendorDisplayName} να προσθέσει έναν τραπεζικό λογαριασμό.`, adminCanceledRequest: 'ακύρωσε την πληρωμή', canceledRequest: (amount: string, submitterDisplayName: string) => `ακύρωσε την πληρωμή των ${amount}, επειδή ο/η ${submitterDisplayName} δεν ενεργοποίησε το Expensify Wallet του/της μέσα σε 30 ημέρες`, diff --git a/src/languages/en.ts b/src/languages/en.ts index c56c01c2e4ec..ab22aea07e6d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1657,6 +1657,7 @@ const translations = { forwarded: (memo?: string) => `approved${memo ? `, saying ${memo}` : ''}`, rejectedThisReport: 'rejected', waitingOnBankAccount: (submitterDisplayName: string) => `started payment, but is waiting for ${submitterDisplayName} to add a personal bank account.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `started payment, but is waiting for ${vendorDisplayName} to add a bank account.`, adminCanceledRequest: 'canceled the payment', canceledRequest: (amount: string, submitterDisplayName: string) => `canceled the ${amount} payment, because ${submitterDisplayName} did not enable their Expensify Wallet within 30 days`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 675cb0996759..020f304d0d03 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1555,6 +1555,7 @@ const translations: TranslationDeepObject = { forwarded: (memo) => `aprobó${memo ? `, dijo ${memo}` : ''}`, rejectedThisReport: 'rechazó', waitingOnBankAccount: (submitterDisplayName: string) => `inició el pago, pero está esperando a que ${submitterDisplayName} añada una cuenta bancaria personal.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `inició el pago, pero está esperando a que ${vendorDisplayName} añada una cuenta bancaria.`, adminCanceledRequest: 'canceló el pago', canceledRequest: (amount, submitterDisplayName) => `canceló el pago ${amount}, porque ${submitterDisplayName} no habilitó tu Billetera Expensify en un plazo de 30 días.`, settledAfterAddedBankAccount: (submitterDisplayName, amount) => `${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 54a8fedfeba6..5ff5403cbc92 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1561,6 +1561,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `approuvé${memo ? `, indiquant « ${memo} »` : ''}`, rejectedThisReport: 'rejeté', waitingOnBankAccount: (submitterDisplayName: string) => `a commencé le paiement, mais attend que ${submitterDisplayName} ajoute un compte bancaire personnel.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `a commencé le paiement, mais attend que ${vendorDisplayName} ajoute un compte bancaire.`, adminCanceledRequest: 'a annulé le paiement', canceledRequest: (amount: string, submitterDisplayName: string) => `a annulé le paiement de ${amount}, car ${submitterDisplayName} n’a pas activé son Portefeuille Expensify dans un délai de 30 jours`, diff --git a/src/languages/it.ts b/src/languages/it.ts index d2d8fdf235c5..405322d7dbf5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1552,6 +1552,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `approvato${memo ? `, con nota: ${memo}` : ''}`, rejectedThisReport: 'rifiutato', waitingOnBankAccount: (submitterDisplayName: string) => `ha avviato il pagamento, ma sta aspettando che ${submitterDisplayName} aggiunga un conto bancario personale.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `ha avviato il pagamento, ma sta aspettando che ${vendorDisplayName} aggiunga un conto bancario.`, adminCanceledRequest: 'ha annullato il pagamento', canceledRequest: (amount: string, submitterDisplayName: string) => `ha annullato il pagamento di ${amount}, perché ${submitterDisplayName} non ha abilitato il proprio Expensify Wallet entro 30 giorni`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b7badcee6416..cc104f3d0072 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1536,6 +1536,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `承認済み${memo ? `、メモ: ${memo}` : ''}`, rejectedThisReport: '却下しました', waitingOnBankAccount: (submitterDisplayName: string) => `支払いを開始しましたが、${submitterDisplayName} が個人の銀行口座を追加するのを待っています。`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `支払いを開始しましたが、${vendorDisplayName} が銀行口座を追加するのを待っています。`, adminCanceledRequest: '支払いをキャンセルしました', canceledRequest: (amount: string, submitterDisplayName: string) => `${submitterDisplayName} が30日以内に Expensify Wallet を有効化しなかったため、${amount} の支払いをキャンセルしました`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 352fa688a100..2b9191054297 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1552,6 +1552,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `goedgekeurd${memo ? `, met de omschrijving ${memo}` : ''}`, rejectedThisReport: 'afgekeurd', waitingOnBankAccount: (submitterDisplayName: string) => `is met de betaling begonnen, maar wacht tot ${submitterDisplayName} een persoonlijke bankrekening toevoegt.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `is met de betaling begonnen, maar wacht tot ${vendorDisplayName} een bankrekening toevoegt.`, adminCanceledRequest: 'heeft de betaling geannuleerd', canceledRequest: (amount: string, submitterDisplayName: string) => `heeft de betaling van ${amount} geannuleerd, omdat ${submitterDisplayName} hun Expensify Wallet niet binnen 30 dagen heeft geactiveerd`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 17bef2c9dbb8..5c407ef3dbf4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1585,6 +1585,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `zatwierdzono${memo ? `, wpisując ${memo}` : ''}`, rejectedThisReport: 'odrzucono', waitingOnBankAccount: (submitterDisplayName: string) => `rozpoczął(-ęła) płatność, ale czeka, aż ${submitterDisplayName} doda osobiste konto bankowe.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `rozpoczął(-ęła) płatność, ale czeka, aż ${vendorDisplayName} doda konto bankowe.`, adminCanceledRequest: 'anulowano płatność', canceledRequest: (amount: string, submitterDisplayName: string) => `anulowano płatność ${amount}, ponieważ ${submitterDisplayName} nie aktywował(-a) swojego portfela Expensify w ciągu 30 dni`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cab20ec1bfe9..32c118bdfe77 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1552,6 +1552,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `aprovado${memo ? `, dizendo ${memo}` : ''}`, rejectedThisReport: 'rejeitou', waitingOnBankAccount: (submitterDisplayName: string) => `iniciou o pagamento, mas está aguardando ${submitterDisplayName} adicionar uma conta bancária pessoal.`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `iniciou o pagamento, mas está aguardando ${vendorDisplayName} adicionar uma conta bancária.`, adminCanceledRequest: 'cancelou o pagamento', canceledRequest: (amount: string, submitterDisplayName: string) => `cancelou o pagamento de ${amount} porque ${submitterDisplayName} não ativou a Carteira Expensify em 30 dias`, settledAfterAddedBankAccount: (submitterDisplayName: string, amount: string) => `${submitterDisplayName} adicionou uma conta bancária. O pagamento de ${amount} foi efetuado.`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b846757481dc..f9c289e5548f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1496,6 +1496,7 @@ const translations: TranslationDeepObject = { forwarded: (memo?: string) => `已批准${memo ? `,备注为 ${memo}` : ''}`, rejectedThisReport: '已拒绝', waitingOnBankAccount: (submitterDisplayName: string) => `已开始付款,但正在等待 ${submitterDisplayName} 添加个人银行账户。`, + waitingOnVendorBankAccount: (vendorDisplayName: string) => `已开始付款,但正在等待 ${vendorDisplayName} 添加银行账户。`, adminCanceledRequest: '已取消付款', canceledRequest: (amount: string, submitterDisplayName: string) => `已取消金额为 ${amount} 的付款,因为 ${submitterDisplayName} 未在 30 天内启用其 Expensify 钱包`, settledAfterAddedBankAccount: (submitterDisplayName: string, amount: string) => `${submitterDisplayName} 已添加了一个银行账户。已完成 ${amount} 付款。`, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 408f8836ab9b..53ea866a298e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4281,6 +4281,27 @@ function getDeletedParentActionMessageForChatReport(reportAction: OnyxEntry): number | undefined { + if (report?.type === CONST.REPORT.TYPE.BILL) { + return report.billSenderAccountID ?? report.ownerAccountID; + } + return report?.ownerAccountID; +} + +/** + * Returns the message for a queued payment. A bill's vendor needs a business deposit account, so its message doesn't ask for a personal bank account. + */ +function getReimbursementQueuedMessageKey(report: OnyxEntry, paymentType: string | undefined): TranslationPaths { + if (paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + return 'iou.waitingOnEnabledWallet'; + } + return report?.type === CONST.REPORT.TYPE.BILL ? 'iou.waitingOnVendorBankAccount' : 'iou.waitingOnBankAccount'; +} + function getReimbursementQueuedActionMessage({ reportAction, report, @@ -4296,22 +4317,15 @@ function getReimbursementQueuedActionMessage({ shouldUseShortDisplayName?: boolean; personalDetails?: Partial; }): string { - const submitterDisplayName = + const payeeDisplayName = getDisplayNameForParticipant({ - accountID: report?.ownerAccountID, + accountID: getQueuedPaymentPayeeAccountID(report), shouldUseShortForm: shouldUseShortDisplayName, personalDetailsData: personalDetails, formatPhoneNumber, hiddenTranslation: translate('common.hidden'), }) ?? ''; - const originalMessage = getOriginalMessage(reportAction); - let messageKey: TranslationPaths; - if (originalMessage?.paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - messageKey = 'iou.waitingOnEnabledWallet'; - } else { - messageKey = 'iou.waitingOnBankAccount'; - } - return translate(messageKey, submitterDisplayName); + return translate(getReimbursementQueuedMessageKey(report, getOriginalMessage(reportAction)?.paymentType), payeeDisplayName); } /** @@ -12066,7 +12080,14 @@ function getIndicatedMissingPaymentMethod( reportAction: ReportAction, bankAccountList: OnyxEntry, ): MissingPaymentMethod | undefined { - const isSubmitterOfUnsettledReport = !!reportId && isCurrentUserSubmitter(getReport(reportId, deprecatedAllReports)) && !isSettled(reportId); + const report = reportId ? getReport(reportId, deprecatedAllReports) : undefined; + + // The bill's owner pays the vendor, so the owner has no bank account to add. + if (report?.type === CONST.REPORT.TYPE.BILL) { + return undefined; + } + + const isSubmitterOfUnsettledReport = !!reportId && isCurrentUserSubmitter(report) && !isSettled(reportId); if (!isSubmitterOfUnsettledReport) { return undefined; } @@ -14526,6 +14547,8 @@ export { getPolicyName, getReimbursementDeQueuedOrCanceledActionMessage, getReimbursementQueuedActionMessage, + getQueuedPaymentPayeeAccountID, + getReimbursementQueuedMessageKey, getReportDescription, getReportFieldKey, getReportFieldMaps, diff --git a/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx index abb0ec00bd44..804a4951452d 100644 --- a/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx +++ b/src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx @@ -9,7 +9,7 @@ import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage} from '@libs/ReportActionsUtils'; -import {getIndicatedMissingPaymentMethod, isChatThread} from '@libs/ReportUtils'; +import {getIndicatedMissingPaymentMethod, getQueuedPaymentPayeeAccountID, getReimbursementQueuedMessageKey, isChatThread} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; @@ -47,15 +47,14 @@ function ReimbursementQueuedContent({action, report, iouReport}: ReimbursementQu const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); const targetReport = isChatThread(report) ? parentReport : report; - const [ownerDisplayName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { - selector: personalDetailsDisplayNameSelector(targetReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, translate, formatPhoneNumber), + const [payeeDisplayName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + selector: personalDetailsDisplayNameSelector(getQueuedPaymentPayeeAccountID(targetReport) ?? CONST.DEFAULT_NUMBER_ID, translate, formatPhoneNumber), }); - const submitterDisplayName = ownerDisplayName ?? ''; const paymentType = getOriginalMessage(action)?.paymentType ?? ''; const missingPaymentMethod = getIndicatedMissingPaymentMethod(userWalletTierName, targetReport?.reportID, action, bankAccountList); return ( - + <> {missingPaymentMethod === CONST.MISSING_PAYMENT_METHODS.BANK_ACCOUNT && ( diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index c182afdffd29..e32f8d0fe428 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -1,7 +1,7 @@ import {canApproveBill, canPayBill, isBillPayReport} from '@libs/BillPayUtils'; import {translate} from '@libs/Localize'; import {getReportPrimaryAction} from '@libs/ReportPrimaryActionUtils'; -import {canRejectReportAction} from '@libs/ReportUtils'; +import {canRejectReportAction, getReimbursementQueuedActionMessage} from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSuggestedSearches} from '@libs/SearchSuggestionUtils'; import {createTypeMenuSections, getSections, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; import createMock from '../utils/createMock'; @@ -81,6 +81,32 @@ describe('Bill Pay', () => { expect(action).toBe(expectedAction); }); + it('names the vendor when a bill payment waits for a bank account', () => { + // Given an approved bill whose payment waits because its vendor has no deposit account. + const vendorAccountID = 456; + const waitingBill: Report = {...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, billSenderAccountID: vendorAccountID}; + const queuedAction = createMock>({ + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_QUEUED, + originalMessage: {}, + }); + const personalDetails = { + [accountID]: {accountID, login: email, displayName: 'Bill Owner'}, + [vendorAccountID]: {accountID: vendorAccountID, login: 'billing@vendor.com', displayName: 'Vendor'}, + }; + + // When the bill shows the queued payment. + const message = getReimbursementQueuedActionMessage({ + reportAction: queuedAction, + report: waitingBill, + translate: (key: TPath, ...parameters: TranslationParameters) => translate(CONST.LOCALES.EN, key, ...parameters), + formatPhoneNumber: (phone) => phone, + personalDetails, + }); + + // Then it waits for the vendor, who receives the payment, to add a bank account, not for the bill's owner to add a personal one. + expect(message).toBe('started payment, but is waiting for Vendor to add a bank account.'); + }); + it('does not offer Reject to the approver of a submitted bill', () => { // Given a submitted bill and an expense report in the same state, both waiting for the current user to approve. const expenseReport: Report = {...bill, type: CONST.REPORT.TYPE.EXPENSE}; From 1e0115f03167a17712d0e457c4e8d3d50388df10 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Tue, 6 Oct 2026 17:39:14 -0700 Subject: [PATCH 10/14] Show the payment action on bills and invoices paid from NewDot --- src/libs/ReportActionsUtils.ts | 43 +++++++++++++++---- .../configs/visibleReportActions.ts | 6 +-- tests/unit/ReportActionsUtilsTest.ts | 23 ++++++++++ 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 5da09c5385e5..e09fe41306ef 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1260,7 +1260,13 @@ function isResolvedConciergeDescriptionOptions(reportAction: OnyxEntry, key: string | number, canUserPerformWriteAction?: boolean, currentUserAccountID?: number): boolean { +function shouldReportActionBeVisible( + reportAction: OnyxEntry, + key: string | number, + canUserPerformWriteAction?: boolean, + currentUserAccountID?: number, + reportID?: string, +): boolean { if (!reportAction) { return false; } @@ -1327,10 +1333,11 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } - // Hide REIMBURSED and MARKED_REIMBURSED actions created from NewDot since an IOU PAY action is displayed instead - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { + // Hide REIMBURSED and MARKED_REIMBURSED actions created from NewDot since an IOU PAY action is displayed instead. + // ReimburseReport and MarkReimbursed add no IOU PAY action when they pay a bill or its invoice, so the payment action stays visible on bills and invoices. + if (isPaymentRecordedByReimbursedAction(reportAction)) { const originalMessage = getOriginalMessage(reportAction); - if (originalMessage?.isNewDot || reportAction.shouldShow === false) { + if ((originalMessage?.isNewDot && !isBillOrInvoiceReportID(reportID)) || reportAction.shouldShow === false) { return false; } } @@ -1373,18 +1380,18 @@ function isReportActionVisible( // from what's cached in visibleReportActions (which reflects persisted Onyx data). // We must recalculate visibility at runtime to ensure accuracy for these transient states. if (reportAction.pendingAction) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); } if (visibleReportActions && reportID) { const reportCache = visibleReportActions[reportID]; if (!reportCache) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); } const staticVisibility = reportCache[reportAction.reportActionID]; // If action is not in derived value cache, fall back to runtime calculation if (staticVisibility === undefined) { - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); } if (!staticVisibility) { return false; @@ -1394,7 +1401,7 @@ function isReportActionVisible( } return true; } - return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID); + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction, currentUserAccountID, reportID); } /** @@ -2734,6 +2741,25 @@ function isActionableCardFraudAlert(reportAction: OnyxInputOrEntry /** * Checks if a report action is an actionable whisper that requires write permission to be visible. */ +function isPaymentRecordedByReimbursedAction( + reportAction: OnyxEntry, +): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED) || isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED); +} + +function isBillOrInvoiceReportID(reportID: string | undefined): boolean { + const reportType = reportID ? getReportOrDraftReport(reportID)?.type : undefined; + return reportType === CONST.REPORT.TYPE.BILL || reportType === CONST.REPORT.TYPE.INVOICE; +} + +/** + * Whether the action's visibility depends on its report's type, which the cached visibility can't track because + * it updates only when report actions change. A NewDot payment action is hidden on most reports but shown on bills and invoices. + */ +function isNewDotPaymentAction(reportAction: OnyxEntry): boolean { + return isPaymentRecordedByReimbursedAction(reportAction) && !!getOriginalMessage(reportAction)?.isNewDot; +} + function isActionableWhisperRequiringWritePermission(reportAction: OnyxEntry): boolean { if (!reportAction) { return false; @@ -5233,6 +5259,7 @@ export { isActionableAddPaymentCard, isActionableCardFraudAlert, isActionableWhisperRequiringWritePermission, + isNewDotPaymentAction, getExportIntegrationActionFragments, getExportIntegrationLastMessageText, getExportIntegrationMessageHTML, diff --git a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts index 3ad87b62ada4..daffb3279bcb 100644 --- a/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/visibleReportActions.ts @@ -1,4 +1,4 @@ -import {isActionableWhisperRequiringWritePermission, isConciergeCategoryOptions, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; +import {isActionableWhisperRequiringWritePermission, isConciergeCategoryOptions, isNewDotPaymentAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; @@ -8,10 +8,10 @@ import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValu /** * Returns true if the action's visibility depends on runtime context that can't be cached, - * such as write permissions or policy settings. + * such as write permissions, policy settings, or the type of its report. */ function shouldSkipCachingAction(action: ReportAction): boolean { - return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action); + return isActionableWhisperRequiringWritePermission(action) || isConciergeCategoryOptions(action) || isNewDotPaymentAction(action); } /** diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 5da3c1f8edf2..889b26549531 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -2662,6 +2662,29 @@ describe('ReportActionsUtils', () => { expect(actual).toBe(false); }); + it.each([ + [CONST.REPORT.TYPE.BILL, true], + [CONST.REPORT.TYPE.INVOICE, true], + [CONST.REPORT.TYPE.EXPENSE, false], + ] as const)('shows a NewDot REIMBURSED action on a %s report: %s', async (reportType, isVisible) => { + // Given a NewDot payment action on a report. Bills and invoices are paid without an IOU PAY action. + const reportID = '9001'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID, type: reportType}); + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + reportActionID: '1', + created: '2025-01-01', + message: [{type: 'TEXT', style: 'normal', text: 'paid this bill'}], + originalMessage: {isNewDot: true}, + }; + + // When the action's visibility is checked for its report + const actual = ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, true, undefined, reportID); + + // Then only bills and invoices show it, because other reports show their IOU PAY action instead + expect(actual).toBe(isVisible); + }); + it('should return false for TAKE_CONTROL when automaticAction is true and mentionedAccountIDs is empty', () => { const reportAction = buildTakeControlActionFixture({ lastModified: '2025-09-29', From 0444926da905e1da134c0b34b9ad027e44dffe6b Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Tue, 13 Oct 2026 11:01:47 -0400 Subject: [PATCH 11/14] Show a bill To its current approver and filter Bills Approve by it --- src/libs/SearchSuggestionUtils.ts | 2 +- src/libs/SearchUIUtils.ts | 3 +-- tests/unit/BillPayTest.ts | 13 ++++++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/libs/SearchSuggestionUtils.ts b/src/libs/SearchSuggestionUtils.ts index 52cbcf3e86b0..e8b77ced766d 100644 --- a/src/libs/SearchSuggestionUtils.ts +++ b/src/libs/SearchSuggestionUtils.ts @@ -212,7 +212,7 @@ function getSuggestedSearches( translationPath: 'search.tabs.approve', type: CONST.SEARCH.DATA_TYPES.BILL, icon: 'ThumbsUp', - searchQuery: buildQueryStringFromFilterFormValues({type: CONST.SEARCH.DATA_TYPES.BILL, action: CONST.SEARCH.ACTION_FILTERS.APPROVE}), + searchQuery: buildQueryStringFromFilterFormValues({type: CONST.SEARCH.DATA_TYPES.BILL, action: CONST.SEARCH.ACTION_FILTERS.APPROVE, to: [`${accountID}`]}), get searchQueryJSON() { return buildSearchQueryJSON(this.searchQuery); }, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 6be39b306d5c..6a63d4a047f2 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2815,10 +2815,9 @@ function getReportSections({ ); const fromAccountID = reportItem.billSenderAccountID ?? reportItem.ownerAccountID; - const toAccountID = reportItem.type === CONST.REPORT.TYPE.BILL ? reportItem.ownerAccountID : reportItem.managerID; const fromDetails = mergedPersonalDetails?.[fromAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? getPersonalDetailsForAccountID(fromAccountID ?? CONST.DEFAULT_NUMBER_ID) ?? emptyPersonalDetails; - const toDetails = !shouldShowBlankTo && toAccountID ? mergedPersonalDetails?.[toAccountID] : emptyPersonalDetails; + const toDetails = !shouldShowBlankTo && reportItem.managerID ? mergedPersonalDetails?.[reportItem.managerID] : emptyPersonalDetails; // First approver/approved come from the earliest APPROVED/FORWARDED report action; blank when the report has no approval. const firstApprovedAction = getFirstApprovedAction(firstApprovedActionByReportID.get(reportItem.reportID), actions); diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index e32f8d0fe428..9fa9e9b3f2f7 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -235,7 +235,7 @@ describe('Bill Pay', () => { expect(canPayBill(invoice, undefined, accountID, email)).toBe(true); }); - it('builds distinct Bills searches with the payer filter on Ready to pay', () => { + it('builds distinct Bills searches with the approver filter on Approve and the payer filter on Ready to pay', () => { const searches = getSuggestedSearches(accountID); const bills = buildSearchQueryJSON(searches[CONST.SEARCH.SEARCH_KEYS.BILLS].searchQuery); const approval = buildSearchQueryJSON(searches[CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE].searchQuery); @@ -243,23 +243,26 @@ describe('Bill Pay', () => { expect(bills?.type).toBe(CONST.SEARCH.DATA_TYPES.BILL); expect(approval?.type).toBe(CONST.SEARCH.DATA_TYPES.BILL); expect(payment?.type).toBe(CONST.SEARCH.DATA_TYPES.BILL); + expect(searches[CONST.SEARCH.SEARCH_KEYS.BILLS_APPROVE].searchQuery).toContain(`to:${accountID}`); expect(searches[CONST.SEARCH.SEARCH_KEYS.BILLS_PAY].searchQuery).toContain(`payer:${accountID}`); expect(new Set([bills?.hash, approval?.hash, payment?.hash]).size).toBe(3); }); - it('renders the bill with sender and receiver emails and suppresses its invoice', () => { + it('renders the bill with the vendor and current approver emails and suppresses its invoice', () => { const vendorAccountID = 456; + const approverAccountID = 789; const approved: Report = { ...bill, stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - billSenderAccountID: 456, - managerID: 789, + billSenderAccountID: vendorAccountID, + managerID: approverAccountID, }; const data = createMock({ personalDetailsList: { [accountID]: {accountID, login: email, displayName: 'Receiver'}, [vendorAccountID]: {accountID: vendorAccountID, login: 'billing@vendor.com', displayName: 'Vendor'}, + [approverAccountID]: {accountID: approverAccountID, login: 'approver@receiver.com', displayName: 'Approver'}, }, }); data[`${ONYXKEYS.COLLECTION.REPORT}100`] = approved; @@ -282,6 +285,6 @@ describe('Bill Pay', () => { }); const reports = sections.filter(isTransactionReportGroupListItemType); expect(reports).toHaveLength(1); - expect(reports.at(0)).toMatchObject({reportID: '100', formattedFrom: 'billing@vendor.com', formattedTo: email, action: CONST.SEARCH.ACTION_TYPES.PAY}); + expect(reports.at(0)).toMatchObject({reportID: '100', formattedFrom: 'billing@vendor.com', formattedTo: 'approver@receiver.com', action: CONST.SEARCH.ACTION_TYPES.PAY}); }); }); From 6b13a9f5e2c94fdfe12ae05e3bea2ec2a5603717 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Fri, 16 Oct 2026 15:25:28 -0400 Subject: [PATCH 12/14] Let the payer pay a canceled bill on a workspace with approvals turned off --- src/libs/BillPayUtils.ts | 8 +++++++- tests/unit/BillPayTest.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/libs/BillPayUtils.ts b/src/libs/BillPayUtils.ts index 20cfceb4958a..cfa102e7d0e4 100644 --- a/src/libs/BillPayUtils.ts +++ b/src/libs/BillPayUtils.ts @@ -38,7 +38,13 @@ function canPayBill(report: OnyxEntry, policy: OnyxEntry | null, if (!arePaymentsEnabled(policy)) { return false; } - if (report.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || (report.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED && report.statusNum !== CONST.REPORT.STATUS_NUM.CLOSED)) { + const isFinallyApproved = + report.stateNum === CONST.REPORT.STATE_NUM.APPROVED && (report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED || report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED); + + // Canceling a payment on a workspace with approvals turned off leaves the bill Submitted and Closed, and it can be paid again. + const isCanceledWithoutApproval = + report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; + if (!isFinallyApproved && !isCanceledWithoutApproval) { return false; } const reimburser = policy?.reimburser ?? policy?.achAccount?.reimburser; diff --git a/tests/unit/BillPayTest.ts b/tests/unit/BillPayTest.ts index 9fa9e9b3f2f7..6433d30308b8 100644 --- a/tests/unit/BillPayTest.ts +++ b/tests/unit/BillPayTest.ts @@ -54,6 +54,15 @@ describe('Bill Pay', () => { expect(canPayBill({...approved, isWaitingOnBankAccount: true}, policy, accountID, email)).toBe(false); }); + it('lets the payer pay a bill again after its payment is canceled on a workspace with approvals turned off', () => { + // Given a bill that a canceled payment left Submitted and Closed. + const canceled = {...bill, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED}; + + // Then the payer can pay it when approvals are turned off, but not when the workspace requires approval. + expect(canPayBill(canceled, {...policy, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}, accountID, email)).toBe(true); + expect(canPayBill(canceled, policy, accountID, email)).toBe(false); + }); + it.each([ [CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED, CONST.REPORT.PRIMARY_ACTIONS.APPROVE], [CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED, CONST.REPORT.PRIMARY_ACTIONS.PAY], From e0b3eff325e27073da16e211e7bc89910cb86f11 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Mon, 19 Oct 2026 15:56:38 -0400 Subject: [PATCH 13/14] Link a bill's subtitle to its owner's workspace chat --- src/components/ParentNavigationSubtitle.tsx | 41 ++++++---- tests/ui/ParentNavigationSubtitleTest.tsx | 86 +++++++++++++++++++++ 2 files changed, 110 insertions(+), 17 deletions(-) diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 530d9d639627..bcb88a74e616 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -12,7 +12,7 @@ import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getReportAction, isReportActionVisible} from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction as canUserPerformWriteActionReportUtils, isMoneyRequestReport} from '@libs/ReportUtils'; +import {canUserPerformWriteAction as canUserPerformWriteActionReportUtils, getPolicyExpenseChat, isExpenseReport, isMoneyRequestReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; @@ -99,11 +99,14 @@ function ParentNavigationSubtitle({ const {workspaceName, reportName} = parentNavigationSubtitleData; const {translate} = useLocalize(); const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const policyExpenseChat = !parentReportID && currentReport && isExpenseReport(currentReport) ? getPolicyExpenseChat(currentReport.ownerAccountID, currentReport.policyID) : undefined; + const parentNavigationReportID = parentReportID || policyExpenseChat?.reportID; + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const report = parentReport ?? policyExpenseChat; const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const isReportArchived = useReportIsArchived(report?.reportID); const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived); - const hasAccessToParentReport = currentReport?.hasParentAccess !== false; + const hasAccessToParentReport = !!parentNavigationReportID && currentReport?.hasParentAccess !== false; const {currentFullScreenRoute, currentFocusedNavigator} = useRootNavigationState((state) => { // Find the tab navigator, which wraps all full-screen navigators const tabNavigatorRoute = state?.routes?.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); @@ -139,8 +142,12 @@ function ParentNavigationSubtitle({ } const onPress = () => { - const parentAction = getReportAction(parentReportID, parentReportActionID); - const isVisibleAction = isReportActionVisible(parentAction, parentReportID, canUserPerformWriteAction, visibleReportActionsData); + if (!parentNavigationReportID) { + return; + } + + const parentAction = getReportAction(parentNavigationReportID, parentReportActionID); + const isVisibleAction = isReportActionVisible(parentAction, parentNavigationReportID, canUserPerformWriteAction, visibleReportActionsData); const focusedNavigatorState = currentFocusedNavigator?.state; const currentReportIndex = focusedNavigatorState?.index; @@ -154,24 +161,24 @@ function ParentNavigationSubtitle({ if (previousRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT) { const moneyRequestReportID = (previousRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT])?.reportID; - if (moneyRequestReportID === parentReportID) { + if (moneyRequestReportID === parentNavigationReportID) { Navigation.goBack(); return; } } } - Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: parentReportID})); + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: parentNavigationReportID})); return; } - if (Navigation.getTopmostSuperWideRHPReportID() === parentReportID && currentFullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { + if (Navigation.getTopmostSuperWideRHPReportID() === parentNavigationReportID && currentFullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { Navigation.dismissToSuperWideRHP(); return; } // If the parent report is already displayed underneath RHP, simply dismiss the modal - if (Navigation.getTopmostReportId() === parentReportID && currentFullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { + if (Navigation.getTopmostReportId() === parentNavigationReportID && currentFullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { Navigation.dismissModal(); return; } @@ -190,7 +197,7 @@ function ParentNavigationSubtitle({ (previousRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || previousRoute?.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT) && previousRoute.params && 'reportID' in previousRoute.params && - previousRoute.params.reportID === parentReportID + previousRoute.params.reportID === parentNavigationReportID ) { Navigation.goBack(); return; @@ -205,7 +212,7 @@ function ParentNavigationSubtitle({ (lastRoute?.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT && currentFullScreenRoute?.name === SCREENS.HOME) ) { const backTo = currentFullScreenRoute?.name === SCREENS.HOME ? Navigation.getActiveRoute() : undefined; - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID, reportActionID: parentReportActionID, backTo})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: parentNavigationReportID, reportActionID: parentReportActionID, backTo})); return; } @@ -217,7 +224,7 @@ function ParentNavigationSubtitle({ const topRoute = fullScreenState?.routes?.[fullScreenState.index ?? 0]; const topRouteReportID = topRoute?.params && 'reportID' in topRoute.params ? String(topRoute.params.reportID) : undefined; - if (topRouteReportID === parentReportID && topRoute?.key && fullScreenState?.key) { + if (topRouteReportID === parentNavigationReportID && topRoute?.key && fullScreenState?.key) { if (isVisibleAction && parentReportActionID) { Navigation.setParams({reportActionID: parentReportActionID}, topRoute.key, fullScreenState.key); } @@ -231,11 +238,11 @@ function ParentNavigationSubtitle({ // would yank the user to Inbox. if (isReportInRHP) { if (!isSmallScreenWidth && isMoneyRequestReport(report)) { - Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: parentReportID})); + Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: parentNavigationReportID})); return; } - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: parentReportID, reportActionID: isVisibleAction ? parentReportActionID : undefined})); + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: parentNavigationReportID, reportActionID: isVisibleAction ? parentReportActionID : undefined})); return; } } @@ -248,7 +255,7 @@ function ParentNavigationSubtitle({ const prevRoute = focusedNavigatorState.routes[(currentReportIndex ?? 0) - 1]; const prevRouteReportID = prevRoute?.params && 'reportID' in prevRoute.params ? String(prevRoute.params.reportID) : undefined; - if (prevRouteReportID === parentReportID && prevRoute?.key) { + if (prevRouteReportID === parentNavigationReportID && prevRoute?.key) { if (isVisibleAction && parentReportActionID) { // Set params on the background screen first so it is already correct when revealed. Navigation.setParams({reportActionID: parentReportActionID}, prevRoute.key, focusedNavigatorState.key); @@ -259,9 +266,9 @@ function ParentNavigationSubtitle({ } if (isVisibleAction) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID, parentReportActionID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentNavigationReportID, parentReportActionID)); } else { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentNavigationReportID)); } }; diff --git a/tests/ui/ParentNavigationSubtitleTest.tsx b/tests/ui/ParentNavigationSubtitleTest.tsx index 9c7fbff7370e..5dc051964a61 100644 --- a/tests/ui/ParentNavigationSubtitleTest.tsx +++ b/tests/ui/ParentNavigationSubtitleTest.tsx @@ -1,14 +1,22 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; +import useOnyx from '@hooks/useOnyx'; + import Navigation from '@libs/Navigation/Navigation'; import {isReportActionVisible} from '@libs/ReportActionsUtils'; +import {getPolicyExpenseChat} from '@libs/ReportUtils'; import type {RouteProp} from '@react-navigation/native'; import React from 'react'; +import type Report from '../../src/types/onyx/Report'; + import ParentNavigationSubtitle from '../../src/components/ParentNavigationSubtitle'; +import CONST from '../../src/CONST'; import NAVIGATORS from '../../src/NAVIGATORS'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import ROUTES from '../../src/ROUTES'; import SCREENS from '../../src/SCREENS'; import createMock from '../utils/createMock'; @@ -33,6 +41,16 @@ jest.mock('@libs/ReportActionsUtils', () => ({ isReportActionVisible: jest.fn(() => false), })); +jest.mock('@libs/ReportUtils', () => { + const actualReportUtils = jest.requireActual>('@libs/ReportUtils'); + return { + ...actualReportUtils, + getPolicyExpenseChat: jest.fn(), + }; +}); + +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); + const mockUseRootNavigationState = jest.fn(); jest.mock('@hooks/useRootNavigationState', () => ({ __esModule: true, @@ -41,6 +59,7 @@ jest.mock('@hooks/useRootNavigationState', () => ({ type AnyRoute = RouteProp | undefined>, string>; const mockUseRoute = jest.fn(); +const mockUseOnyx = jest.mocked(useOnyx); jest.mock('@react-navigation/native', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actualNav = jest.requireActual('@react-navigation/native'); @@ -301,4 +320,71 @@ describe('ParentNavigationSubtitle', () => { expect(Navigation.goBack).not.toHaveBeenCalled(); expect(Navigation.navigate).toHaveBeenCalled(); }); + + it('opens the policy expense chat from a parentless bill subtitle', () => { + const billReportID = 'bill-report'; + const policyExpenseChatID = 'policy-expense-chat'; + const ownerAccountID = 123; + const policyID = 'policy-id'; + const billReport = createMock({ + reportID: billReportID, + type: CONST.REPORT.TYPE.BILL, + ownerAccountID, + policyID, + hasParentAccess: undefined, + }); + const policyExpenseChat = createMock({reportID: policyExpenseChatID}); + + mockUseOnyx.mockImplementation((key: string) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${billReportID}`) { + return [billReport, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + jest.mocked(getPolicyExpenseChat).mockReturnValueOnce(policyExpenseChat); + mockUseRoute.mockReturnValue(createMock({name: SCREENS.REPORT})); + + render( + , + ); + + fireEvent(screen.getByTestId('parent-navigation-subtitle-link'), 'press', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + nativeEvent: {}, + }); + + expect(getPolicyExpenseChat).toHaveBeenCalledWith(ownerAccountID, policyID); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(policyExpenseChatID)); + }); + + it('renders the parentless bill subtitle as text when its policy expense chat is unavailable', () => { + const billReportID = 'bill-report'; + mockUseOnyx.mockImplementation((key: string) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${billReportID}`) { + const billReport = createMock({ + reportID: billReportID, + type: CONST.REPORT.TYPE.BILL, + hasParentAccess: undefined, + }); + return [billReport, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + jest.mocked(getPolicyExpenseChat).mockReturnValueOnce(undefined); + mockUseRoute.mockReturnValue(createMock({name: SCREENS.REPORT})); + + render( + , + ); + + expect(screen.queryByTestId('parent-navigation-subtitle-link')).toBeNull(); + expect(screen.getByText("bob's expenses")).toBeTruthy(); + }); }); From 3ef284dcf85c9d719020247308fc561d0054b698 Mon Sep 17 00:00:00 2001 From: Carlos Alvarez Date: Mon, 19 Oct 2026 22:27:33 -0400 Subject: [PATCH 14/14] Show settlement columns in Bills search --- .../Search/FilterDropdowns/DisplayPopup.tsx | 4 +- tests/ui/DisplayPopupTest.tsx | 65 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/ui/DisplayPopupTest.tsx diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index 597aefe1541b..c079dac498bc 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -64,7 +64,9 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP const groupBy = groupBySections.flatMap((section) => section.options).find((option) => option.value === queryJSON.groupBy) ?? null; const viewOptions = getViewOptions(translate); const view = viewOptions.find((option) => option.value === queryJSON.view) ?? viewOptions.at(0); - const shouldShowColumnsButton = isLargeScreenWidth && (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); + const shouldShowColumnsButton = + isLargeScreenWidth && + (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || queryJSON.type === CONST.SEARCH.DATA_TYPES.BILL); const limitValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]; diff --git a/tests/ui/DisplayPopupTest.tsx b/tests/ui/DisplayPopupTest.tsx new file mode 100644 index 000000000000..24ba12d0914d --- /dev/null +++ b/tests/ui/DisplayPopupTest.tsx @@ -0,0 +1,65 @@ +import {render, screen} from '@testing-library/react-native'; + +import DisplayPopup from '@components/Search/FilterDropdowns/DisplayPopup'; + +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => () => ({updateFilterQueryParams: jest.fn()})); + +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({Columns: {}})})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); +jest.mock('@hooks/useResponsiveLayout', () => () => ({isLargeScreenWidth: true, isSmallScreenWidth: false})); +jest.mock('@hooks/useThemeStyles', () => () => ({pv4: {}})); + +jest.mock('@userActions/Session', () => ({callFunctionIfActionIsAllowed: (callback: () => void) => callback})); + +describe('DisplayPopup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows the column settings option in Bills search', () => { + // Given the user is viewing Bills on a wide screen + const queryJSON = buildSearchQueryJSON('type:bill'); + if (!queryJSON) { + throw new Error('Expected the Bills search query to parse'); + } + + // When the Display menu renders for Bills + render( + , + ); + + // Then it exposes Edit columns because Bills uses the expense report column settings. + expect(screen.getByLabelText('search.editColumns')).toBeOnTheScreen(); + }); + + it('does not show column settings for invoice search', () => { + // Given the user is viewing invoices on a wide screen + const queryJSON = buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.INVOICE}`); + if (!queryJSON) { + throw new Error('Expected the invoice search query to parse'); + } + + // When the Display menu renders for invoices + render( + , + ); + + // Then invoice search still hides the picker because it has no custom report columns. + expect(screen.queryByLabelText('search.editColumns')).not.toBeOnTheScreen(); + }); +});