Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2131,14 +2131,14 @@ const CONST = {
TYPE: {
CHAT: 'chat',
EXPENSE: 'expense',
BILL: 'bill',
IOU: 'iou',
TASK: 'task',
SUPPORT_TICKET: 'supportTicket',
INVOICE: 'invoice',
},
UNSUPPORTED_TYPE: {
PAYCHECK: 'paycheck',
BILL: 'bill',
},
CHAT_TYPE: chatTypes,
WORKSPACE_CHAT_ROOMS: {
Expand Down Expand Up @@ -2356,6 +2356,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',
Expand Down Expand Up @@ -7661,6 +7662,7 @@ const CONST = {
DATA_TYPES: {
EXPENSE: 'expense',
EXPENSE_REPORT: 'expense-report',
BILL: 'bill',
INVOICE: 'invoice',
TASK: 'task',
TRIP: 'trip',
Expand Down Expand Up @@ -8585,6 +8587,9 @@ const CONST = {
SEARCH_KEYS: {
EXPENSES: 'expenses',
REPORTS: 'reports',
BILLS: 'bills',
BILLS_APPROVE: 'billsApprove',
BILLS_PAY: 'billsPay',
SUBMIT: 'submit',
APPROVE: 'approve',
PAY: 'pay',
Expand Down
3 changes: 3 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,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',
Expand Down Expand Up @@ -1530,6 +1532,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;
Expand Down
1 change: 1 addition & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2190,6 +2190,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',
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,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',
Expand Down
112 changes: 112 additions & 0 deletions src/components/BillPaymentButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/** Pays bills and received invoices without depending on a parent room. */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI: Adds a shared ACH/Mark as paid menu for bills and invoices. It uses the report directly, so payment does not depend on a parent room.

Context: Approval and payment requirements, No parent room requirement.


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<Report>;
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 ?? {})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI: Offers accessible, open USD debit accounts and excludes card-settlement accounts. Slack clarifies that payment admins may use any bank account they can access, not only the workspace account.

Context: Bank access clarification.

.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 (
<OfflineWithFeedback
errors={report?.errorFields?.reimbursed}
pendingAction={report?.pendingFields?.reimbursed}
>
<ButtonWithDropdownMenu
customText={translate('iou.pay')}
variant={CONST.BUTTON_VARIANT.SUCCESS}
isSplitButton={false}
shouldAlwaysShowDropdownMenu
isDisabled={isDisabled || !!report?.pendingFields?.reimbursed}
isLoading={!!report?.pendingFields?.reimbursed}
onPress={() => {}}
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),
},
]}
/>
</OfflineWithFeedback>
);
}

export default BillPaymentButton;
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -203,6 +204,10 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction
}
};

if (moneyRequestReport?.type === CONST.REPORT.TYPE.BILL || isInvoiceReport) {
return <BillPaymentButton report={moneyRequestReport} />;
}

return (
<AnimatedSettlementButton
isPaidAnimationRunning={isPaidAnimationRunning}
Expand Down
46 changes: 27 additions & 19 deletions src/components/ParentNavigationSubtitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,12 +100,16 @@ function ParentNavigationSubtitle({

const {workspaceName, reportName} = parentNavigationSubtitleData;
const {translate} = useLocalize();
const [hasParentAccess] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: getReportHasParentAccess});
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`);
const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: reportVisibleActionsSelector(parentReportID)});
const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const hasParentAccess = getReportHasParentAccess(currentReport);
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, {selector: reportVisibleActionsSelector(parentNavigationReportID)});
const isReportArchived = useReportIsArchived(report?.reportID);
const canUserPerformWriteAction = canUserPerformWriteActionReportUtils(report, isReportArchived);
const hasAccessToParentReport = hasParentAccess !== false;
const hasAccessToParentReport = !!parentNavigationReportID && 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);
Expand Down Expand Up @@ -141,8 +145,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;
Expand All @@ -156,24 +164,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;
}
Expand All @@ -192,7 +200,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;
Expand All @@ -207,7 +215,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;
}

Expand All @@ -219,7 +227,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);
}
Expand All @@ -233,11 +241,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;
}
}
Expand All @@ -250,7 +258,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);
Expand All @@ -261,9 +269,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));
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BillPaymentButton from '@components/BillPaymentButton';
import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider';
import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton';
import type {PaymentActionParams} from '@components/SettlementButton/types';
Expand Down Expand Up @@ -139,6 +140,10 @@ function PayActionButton() {
}
};

if (iouReport?.type === CONST.REPORT.TYPE.BILL || isInvoiceReportUtils(iouReport)) {
return <BillPaymentButton report={iouReport} />;
}

return (
<AnimatedSettlementButton
onlyShowPayElsewhere={shouldShowOnlyPayElsewhere}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,10 @@ function MoneyRequestReportPreview({
);
};

if (iouReport?.isHiddenForBillReceiver) {
return null;
}

return (
<MoneyRequestReportPreviewContent
newTransactionIDs={newTransactionIDs}
Expand Down
4 changes: 3 additions & 1 deletion src/components/Search/FilterDropdowns/DisplayPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BillPaymentButton from '@components/BillPaymentButton';
import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider';
import {SearchScopeProvider} from '@components/Search/SearchScopeProvider';
import SettlementButton from '@components/SettlementButton';
Expand Down Expand Up @@ -200,6 +201,16 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab
});
};

if (iouReport?.type === CONST.REPORT.TYPE.BILL || isInvoiceReport(iouReport)) {
return (
<BillPaymentButton
report={iouReport}
isDisabled={shouldDisablePointerEvents}
searchHash={hash}
/>
);
}

return (
<SearchScopeProvider isOnSearch={false}>
<SettlementButton
Expand Down
Loading
Loading