diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index a1f76d23c8a1..c03409091b28 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -10,6 +10,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import {ReportSubmitToPopoverAnchor} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -169,6 +170,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); @@ -241,6 +243,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData: getYourSpendPatchData(), onPaid: () => { startAnimation(); }, @@ -453,6 +456,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo ownerBillingGracePeriodEnd, delegateEmail, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), ownerLogin: submitterLogin, }); }; diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 31b682a52102..03719702199c 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -4,6 +4,7 @@ import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext' import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton'; import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -122,6 +123,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const {openHoldMenu} = useMoneyReportHeaderModals(); @@ -182,6 +184,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData: getYourSpendPatchData(), onPaid: startAnimation, chatReportActions: getChatReportActions(false), isTrackIntentUser, diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index dfa555acbf0a..2475275742f7 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -2,6 +2,7 @@ import AnimatedSubmitButton from '@components/AnimatedSubmitButton'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import {ReportSubmitToPopoverAnchor, useOpenReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useConfirmModal from '@hooks/useConfirmModal'; import useConfirmPendingRTERAndProceed from '@hooks/useConfirmPendingRTERAndProceed'; @@ -111,6 +112,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const handleSubmit = () => { if (!moneyRequestReport || shouldBlockSubmit) { @@ -138,6 +140,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { expenseReportCurrentNextStepDeprecated: nextStep, userBillingGracePeriodEnds, amountOwed, + yourSpendPatchData: getYourSpendPatchData(), onSubmitted: startSubmittingAnimation, ownerBillingGracePeriodEnd, delegateEmail, diff --git a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts index 8c722ed55ebc..5c3b31da1515 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts +++ b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts @@ -1,5 +1,6 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; @@ -38,6 +39,7 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); @@ -69,6 +71,7 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation ownerBillingGracePeriodEnd, ownerLogin, full: true, + yourSpendPatchData: getYourSpendPatchData(), onApproved: startApprovedAnimation, delegateEmail, isTrackIntentUser, diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index dc640cf73251..46e9b2b9cb92 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -91,6 +91,7 @@ import {ModalActions} from './Modal/Global/ModalContext'; import {usePersonalDetails} from './OnyxListItemProvider'; import {useSearchQueryContext, useSearchSelectionActions} from './Search/SearchContext'; import {useWideRHPState} from './WideRHPContextProvider'; +import {useYourSpendPatchDataGetter} from './YourSpendPatchDataProvider'; type MoneyRequestHeaderSecondaryActionsProps = { /** The report ID for the current transaction thread */ @@ -114,6 +115,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const {login: currentUserLogin, accountID, localCurrencyCode} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const personalDetails = usePersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'ArrowCollapse', @@ -489,6 +491,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money currentUserAccountID: accountID, currentUserEmail: currentUserLogin ?? '', policy: iouPolicy, + yourSpendPatchData: getYourSpendPatchData(), }); } else { if (shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID])) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 506cc0339ca5..e8fbca822e40 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -1,6 +1,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import AnimatedSettlementButton from '@components/SettlementButton/AnimatedSettlementButton'; import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -65,6 +66,7 @@ function PayActionButton() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [transactionViolations] = useReportTransactionViolations(transactions); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -132,6 +134,7 @@ function PayActionButton() { amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData: getYourSpendPatchData(), onPaid: startAnimation, chatReportActions: getChatReportActions(false), isTrackIntentUser, diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx index 5ca28e9aff05..8dfbc31239d8 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx @@ -1,5 +1,6 @@ import AnimatedSubmitButton from '@components/AnimatedSubmitButton'; import {ReportSubmitToPopoverAnchor, useOpenReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useConfirmModal from '@hooks/useConfirmModal'; import useConfirmPendingRTERAndProceed from '@hooks/useConfirmPendingRTERAndProceed'; @@ -69,6 +70,7 @@ function SubmitActionButtonContent() { } = useReportPreviewActionButtonData(iouReportID); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [transactionViolations] = useReportTransactionViolations(transactions); @@ -113,6 +115,7 @@ function SubmitActionButtonContent() { expenseReportCurrentNextStepDeprecated: iouReportNextStep, userBillingGracePeriodEnds, amountOwed, + yourSpendPatchData: getYourSpendPatchData(), onSubmitted: startSubmittingAnimation, ownerBillingGracePeriodEnd, delegateEmail, diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts b/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts index 176be8bb3054..74f2df95241b 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts @@ -3,6 +3,7 @@ * handling delegate-access restrictions, held expenses, and the approveMoneyRequest call. */ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; @@ -31,6 +32,7 @@ function useConfirmApproveReportAction(actionButtonData: ReturnType({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); const {transactions: reportTransactions, violations: reportViolations} = useTransactionsAndViolationsForReport(reportItem.reportID); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const liveReportTransactions = useMemo(() => Object.values(reportTransactions), [reportTransactions]); // Recompute the violations badge from live data at the row, replacing the screen-level @@ -298,6 +300,7 @@ function ExpenseReportListItemInner({ chatReportActions, delegateEmail, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); }, [ currentSearchHash, @@ -337,6 +340,7 @@ function ExpenseReportListItemInner({ chatReportActions, delegateEmail, isTrackIntentUser, + getYourSpendPatchData, ]); const handleSelectionButtonPress = useCallback(() => { diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index 1ce2ddf96fbf..4b6361c94ed1 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -12,6 +12,7 @@ import { import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import type {ListItem} from '@components/SelectionList/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -295,6 +296,7 @@ function ReportListItemHeaderInner({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const handleOnButtonPress = (event?: ModifiedMouseEvent) => { handleActionButtonPress({ @@ -330,6 +332,7 @@ function ReportListItemHeaderInner({ chatReportActions, delegateEmail, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); }; return !isLargeScreenWidth ? ( diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index fe58fc0a08d1..1675413ba764 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -9,6 +9,7 @@ import {useSearchQueryContext, useSearchResultsContext} from '@components/Search import type {TransactionListItemProps, TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import useLiveRowCapabilities from '@components/Search/SearchList/ListItem/useLiveRowCapabilities'; import type {ListItem} from '@components/SelectionList/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -197,6 +198,7 @@ function TransactionListItemInner({ const {showConfirmModal} = useConfirmModal(); const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const handleActionButtonPress = (event?: Parameters[2]) => { handleActionButtonPressUtil({ @@ -233,6 +235,7 @@ function TransactionListItemInner({ chatReportActions, delegateEmail, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); }; diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 2c5d205c68d6..e68e906c3aad 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -7,6 +7,7 @@ import type {ContinueActionParams, PaymentMethod} from '@components/KYCWall/type import {useLockedAccountActions, useLockedAccountState} from '@components/LockedAccountModalProvider'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useActiveAdminPolicies from '@hooks/useActiveAdminPolicies'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -120,6 +121,7 @@ function SettlementButton({ const expenseReportPolicy = usePolicy(iouReport?.policyID); const {accountID, email = ''} = useCurrentUserPersonalDetails(); const lastWorkspaceNumber = useLastWorkspaceNumber(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); // The app would crash due to subscribing to the entire report collection if chatReportID is an empty string. So we should have a fallback ID here. // eslint-disable-next-line rulesdir/no-default-id-values @@ -530,6 +532,7 @@ function SettlementButton({ ownerBillingGracePeriodEnd, ownerLogin, full: false, + yourSpendPatchData: getYourSpendPatchData(), delegateEmail, isTrackIntentUser, }); diff --git a/src/components/YourSpendPatchDataProvider.tsx b/src/components/YourSpendPatchDataProvider.tsx new file mode 100644 index 000000000000..2bd192bbbd27 --- /dev/null +++ b/src/components/YourSpendPatchDataProvider.tsx @@ -0,0 +1,33 @@ +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; + +import {EMPTY_YOUR_SPEND_PATCH_DATA} from '@libs/YourSpendPatchData'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; + +import React, {createContext, useCallback, useContext, useEffect, useRef} from 'react'; + +// A getter (backed by a ref in the provider) rather than a plain value, so the many consumers that only read the +// patch data inside action handlers don't subscribe to the underlying Onyx data or re-render when it changes. +const YourSpendPatchDataContext = createContext<() => YourSpendPatchData>(() => EMPTY_YOUR_SPEND_PATCH_DATA); + +type YourSpendPatchDataProviderProps = { + children: React.ReactNode; +}; + +/** Subscribes to the Your spend patch data once for the whole app and exposes it as a ref-backed getter. */ +function YourSpendPatchDataProvider({children}: YourSpendPatchDataProviderProps) { + const yourSpendPatchData = useYourSpendPatchData(); + const yourSpendPatchDataRef = useRef(yourSpendPatchData); + useEffect(() => { + yourSpendPatchDataRef.current = yourSpendPatchData; + }); + const getYourSpendPatchData = useCallback(() => yourSpendPatchDataRef.current, []); + + return {children}; +} + +/** Returns a stable getter for the Your spend patch data; call it inside action handlers. */ +function useYourSpendPatchDataGetter() { + return useContext(YourSpendPatchDataContext); +} + +export {YourSpendPatchDataProvider, useYourSpendPatchDataGetter}; diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 7cf9975cd254..124bc1aeb13b 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -1,10 +1,13 @@ import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {deleteMoneyRequest} from '@libs/actions/IOU/DeleteMoneyRequest'; import {getIOUActionForTransactions} from '@libs/actions/IOU/Duplicate'; import {getIOURequestPolicyID} from '@libs/actions/IOU/MoneyRequest'; import {initSplitExpenseItemData} from '@libs/actions/IOU/SplitExpenseItems'; import {updateSplitTransactions} from '@libs/actions/IOU/SplitTransactionUpdate'; +import {getYourSpendSnapshotTransactionsRemovalUpdates} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import type {YourSpendSnapshotOnyxData} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import initSplitExpense from '@libs/actions/SplitExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {calculateAmount as calculateIOUAmount} from '@libs/IOUUtils'; @@ -99,6 +102,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const restrictedActionPolicyID = useRestrictedActionPolicyID(policy); const {isOffline} = useNetwork(); const {isProduction} = useEnvironment(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const getSplitExpenseEditTransactionOnDelete = useCallback( (transactionIDs: string[]): Transaction | undefined => { @@ -326,17 +330,28 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac isOffline, delegateAccountID, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); } - for (const {transactionID, action} of nonSplitTransactions) { - if (!action) { - continue; - } - const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; - const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. - const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) ? candidateIOUReport : undefined; + const deletionEntries = nonSplitTransactions + .filter((item): item is {transactionID: string; action: ReportAction; transaction?: Transaction} => !!item.action) + .map(({transactionID, action, transaction}) => { + const iouReportID = isMoneyRequestAction(action) ? action?.reportID : undefined; + const candidateIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + // For self-DM tracks and split bills, action.reportID resolves to a chat report, not an IOU/expense report. + const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) ? candidateIOUReport : undefined; + return {transactionID, action, transaction, iouReport}; + }); + + // One aggregated Your spend update per bulk delete; per-transaction updates would each write an absolute total from the same base, so only the last one would stick. + let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotTransactionsRemovalUpdates({ + transactionItems: deletionEntries.map(({transaction, iouReport}) => ({transaction, iouReport})), + currentUserAccountID: currentUserPersonalDetails.accountID, + context: getYourSpendPatchData(), + }); + + for (const {transactionID, action, iouReport} of deletionEntries) { const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`]; const chatIOUReportID = chatReport?.reportID; @@ -358,7 +373,10 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', policy: iouPolicy, + yourSpendSnapshotUpdates: pendingYourSpendSnapshotUpdates, }); + // The whole batch rides on the first request; attaching it to every request would re-apply the same absolute totals. + pendingYourSpendSnapshotUpdates = undefined; deletedTransactionIDs.push(transactionID); if (action.childReportID) { deletedTransactionThreadReportIDs.add(action.childReportID); @@ -402,6 +420,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac personalPolicy?.outputCurrency, delegateAccountID, isTrackIntentUser, + getYourSpendPatchData, ], ); diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index 2c6dfb03aac9..9637e8d504db 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -1,4 +1,5 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {getReportOrDraftReport, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; @@ -56,6 +57,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const currentUserDetails = useCurrentUserPersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); @@ -96,6 +98,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment expenseReportPolicy: policy, delegateEmail, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); } else if (currentChatReport && paymentType) { payMoneyRequest({ @@ -119,6 +122,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment onPaid: animationCallback, chatReportActions: getChatReportActions(false), isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); } onClose(); diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 14c15b99cca0..554a5040b626 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -5,6 +5,7 @@ import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/ty import {useOpenReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getValidConnectedIntegration, isSubmitPolicy} from '@libs/PolicyUtils'; @@ -106,6 +107,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {accountID, email} = currentUserPersonalDetails; + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); const {isBetaEnabled} = usePermissions(); @@ -194,6 +196,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, ownerBillingGracePeriodEnd, ownerLogin: submitterLogin, full: true, + yourSpendPatchData: getYourSpendPatchData(), onApproved: () => { if (skipAnimation) { return; @@ -242,6 +245,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, expenseReportCurrentNextStepDeprecated: nextStep, userBillingGracePeriodEnds, amountOwed, + yourSpendPatchData: getYourSpendPatchData(), onSubmitted: () => { if (skipAnimation) { return; @@ -360,7 +364,18 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail, isTrackIntentUser); + unapproveExpenseReport( + moneyRequestReport, + policy, + accountID, + email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + nextStep, + delegateEmail, + isTrackIntentUser, + getYourSpendPatchData(), + ); }, }, [CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT]: { @@ -381,7 +396,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, return; } - cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, isTrackIntentUser); + cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, isTrackIntentUser, getYourSpendPatchData()); }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { @@ -415,7 +430,19 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail, isTrackIntentUser); + retractReport( + moneyRequestReport, + chatReport, + policy, + accountID, + email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + nextStep, + delegateEmail, + isTrackIntentUser, + getYourSpendPatchData(), + ); }, }, [CONST.REPORT.SECONDARY_ACTIONS.REOPEN]: { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index ce43f5f9e5e8..055f08568d57 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -6,11 +6,14 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {exportReportsToPDF} from '@libs/actions/Export'; import {unholdRequest} from '@libs/actions/IOU/Hold'; -import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; -import {approveMoneyRequest} from '@libs/actions/IOU/ReportWorkflow'; +import {getPayYourSpendReportMoveItem, payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; +import {approveMoneyRequest, getApproveYourSpendReportMoveItem, getSubmitYourSpendReportMoveItem} from '@libs/actions/IOU/ReportWorkflow'; +import {getYourSpendSnapshotReportsMoveUpdates} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import type {YourSpendReportMoveItem, YourSpendSnapshotOnyxData} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; import { @@ -366,6 +369,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {introSelected, betas, isSelfTourViewed, activePolicyID, activePolicy, defaultWorkspaceName, userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, delegateEmail} = usePaymentContext(); const allTransactions = useAllTransactions(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); @@ -818,19 +822,32 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); let approvedReportCount = 0; - for (const reportID of uniqueReportIDs) { + const approvalEntries = uniqueReportIDs.flatMap((reportID) => { const expenseReport = getReportFromSearchSnapshot(reportID, searchData, allReports); if (!expenseReport) { - continue; + return []; } - const reportPolicy = getPolicyFromSearchSnapshot(expenseReport.policyID, searchData, policies); - const nextStep = allNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${reportID}`]; - const hasViolations = hasViolationsReportUtils(reportID, allTransactionViolations, accountID, email ?? ''); - const policyToUpgrade = reportPolicy; - const wouldNavigateToUpgrade = isSubmitPolicy(policyToUpgrade) && !!policyToUpgrade?.id; + const wouldNavigateToUpgrade = isSubmitPolicy(reportPolicy) && !!reportPolicy?.id; const wouldNavigateToRestricted = !!expenseReport.policyID && shouldRestrictUserBillableActions(reportPolicy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID); + return [{reportID, expenseReport, reportPolicy, wouldNavigateToUpgrade, wouldNavigateToRestricted}]; + }); + + // One aggregated Your spend update per bulk approve; per-report updates would each write an absolute total from the same base, so only the last one would stick. + let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: approvalEntries + .filter(({wouldNavigateToUpgrade, wouldNavigateToRestricted}) => !wouldNavigateToUpgrade && !wouldNavigateToRestricted) + .map(({expenseReport, reportPolicy}) => getApproveYourSpendReportMoveItem(expenseReport, reportPolicy)) + .filter((item): item is YourSpendReportMoveItem => !!item), + currentUserAccountID: accountID, + context: getYourSpendPatchData(), + }); + + for (const {reportID, expenseReport, reportPolicy, wouldNavigateToUpgrade, wouldNavigateToRestricted} of approvalEntries) { + const nextStep = allNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${reportID}`]; + const hasViolations = hasViolationsReportUtils(reportID, allTransactionViolations, accountID, email ?? ''); + const willApprove = !wouldNavigateToUpgrade && !wouldNavigateToRestricted; approveMoneyRequest({ expenseReport, @@ -850,9 +867,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { additionalOnyxData: getSearchApproveOnyxData(hash, reportID, currentSearchKey), shouldPlaySuccessSound: false, isTrackIntentUser, + yourSpendSnapshotUpdates: pendingYourSpendSnapshotUpdates, }); - if (!wouldNavigateToUpgrade && !wouldNavigateToRestricted) { + if (willApprove) { + // The whole batch rides on the first approving request; attaching it to every request would re-apply the same absolute totals. + pendingYourSpendSnapshotUpdates = undefined; approvedReportCount += 1; } } @@ -888,6 +908,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { delegateEmail, currentSearchKey, isTrackIntentUser, + getYourSpendPatchData, personalDetails, ]); @@ -1149,6 +1170,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); let paidReportCount = 0; + // Pay calls are deferred so one aggregated Your spend update can be attached to the first payment request; per-report updates would each write an absolute total from the same base, so only the last one would stick. + const payCalls: Array[0]> = []; for (const item of itemsToPay) { if (!item.reportID) { continue; @@ -1233,7 +1256,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { continue; } - payMoneyRequest({ + payCalls.push({ paymentType: paymentItem.paymentType as PaymentMethodType, chatReport, iouReport, @@ -1260,6 +1283,17 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paidReportCount += 1; } + let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: payCalls.map(({iouReport}) => getPayYourSpendReportMoveItem(iouReport)).filter((item): item is YourSpendReportMoveItem => !!item), + currentUserAccountID: accountID, + context: getYourSpendPatchData(), + }); + for (const payCallParams of payCalls) { + payMoneyRequest({...payCallParams, yourSpendSnapshotUpdates: pendingYourSpendSnapshotUpdates}); + // The whole batch rides on the first request; attaching it to every request would re-apply the same absolute totals. + pendingYourSpendSnapshotUpdates = undefined; + } + if (paidReportCount > 0) { playSound(SOUNDS.SUCCESS); clearSelectedTransactions(); @@ -1299,6 +1333,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentSearchKey, searchResults?.data, isTrackIntentUser, + getYourSpendPatchData, ], ); @@ -1860,6 +1895,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); if (snapshotReport) { + const submitYourSpendSnapshotUpdates = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: [getSubmitYourSpendReportMoveItem(snapshotReport, policyForSubmit)].filter((moveItem): moveItem is YourSpendReportMoveItem => !!moveItem), + currentUserAccountID: accountID, + context: getYourSpendPatchData(), + }); openSearchReportSubmitToPopover(reportIDForSubmit, { onSubmitWithManagerEmail: (managerEmail, managerAccountID) => { submitMoneyRequestOnSearch( @@ -1870,6 +1910,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentSearchKey, managerEmail, managerAccountID, + submitYourSpendSnapshotUpdates, ); clearSelectedTransactions(); }, @@ -1878,11 +1919,52 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - for (const item of itemList) { + // Resolve each item's report and policy up front so one aggregated Your spend update can cover the + // whole batch; per-report updates would each write an absolute total from the same base, so only the last one would stick. + const seenSubmitReportIDs = new Set(); + const submitEntries = itemList.flatMap((item) => { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; - if (policy) { - submitMoneyRequestOnSearch(hash, [item as Report], [policy], getLoginByAccountID(item.ownerAccountID, personalDetails)); + if (!policy) { + return []; } + const resolvedReport = item.reportID + ? getReportOrDraftReport( + item.reportID, + undefined, + searchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`] ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`], + ) + : undefined; + return [{item, policy, resolvedReport}]; + }); + let pendingSubmitYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: submitEntries + .filter(({resolvedReport}) => { + // Multiple selected transactions can belong to the same report; count it once. + if (!resolvedReport?.reportID || seenSubmitReportIDs.has(resolvedReport.reportID)) { + return false; + } + seenSubmitReportIDs.add(resolvedReport.reportID); + return true; + }) + .map(({resolvedReport, policy}) => getSubmitYourSpendReportMoveItem(resolvedReport, policy)) + .filter((moveItem): moveItem is YourSpendReportMoveItem => !!moveItem), + currentUserAccountID: accountID, + context: getYourSpendPatchData(), + }); + + for (const {item, policy} of submitEntries) { + submitMoneyRequestOnSearch( + hash, + [item as Report], + [policy], + getLoginByAccountID(item.ownerAccountID, personalDetails), + undefined, + undefined, + undefined, + pendingSubmitYourSpendSnapshotUpdates, + ); + // The whole batch rides on the first request; attaching it to every request would re-apply the same absolute totals. + pendingSubmitYourSpendSnapshotUpdates = undefined; } clearSelectedTransactions(); }, @@ -2238,6 +2320,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { allReportsShouldMarkAsDone, noReportsShouldMarkAsDone, queryJSON?.groupBy, + getYourSpendPatchData, ]); const handleOfflineModalClose = useCallback(() => { diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 8c627f815c1b..b1ca051448fd 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -5,6 +5,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {ActionHandledType} from '@components/ProcessMoneyReportHoldMenu'; import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; @@ -89,6 +90,7 @@ function useSelectionModePayment({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); @@ -212,6 +214,7 @@ function useSelectionModePayment({ amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData: getYourSpendPatchData(), onPaid, chatReportActions: getChatReportActions(false), isTrackIntentUser, @@ -309,6 +312,7 @@ function useSelectionModePayment({ delegateEmail, expenseReportPolicy: policy, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), ownerLogin, }); }; diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts index 6c263a4a38a5..9cfa082ca367 100644 --- a/src/hooks/useTransactionInlineEdit.ts +++ b/src/hooks/useTransactionInlineEdit.ts @@ -1,4 +1,5 @@ import {useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import type {TransactionInlineEditParams} from '@libs/actions/TransactionInlineEdit'; import { @@ -152,6 +153,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); const {isOffline} = useNetwork(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const personalPolicy = usePersonalPolicy(); const permissions = getTransactionEditPermissions({ @@ -193,6 +195,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use distanceOriginalPolicy, delegateAccountID, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }; }; diff --git a/src/hooks/useYourSpendPatchData.ts b/src/hooks/useYourSpendPatchData.ts new file mode 100644 index 000000000000..8b0f7e209582 --- /dev/null +++ b/src/hooks/useYourSpendPatchData.ts @@ -0,0 +1,40 @@ +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {EMPTY_YOUR_SPEND_PATCH_DATA} from '@libs/YourSpendPatchData'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; +import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, getPaidGroupPolicyIDs, selectPaidGroupPolicies} from '@libs/YourSpendQueryUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useMemo} from 'react'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnyx from './useOnyx'; + +/** Supplies the paid-group policies and "Your spend" snapshot aggregates the offline snapshot-patch builders need. */ +function useYourSpendPatchData(): YourSpendPatchData { + const {accountID} = useCurrentUserPersonalDetails(); + const [paidPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: selectPaidGroupPolicies}); + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); + + const approvalHash = buildSearchQueryJSON(buildAwaitingApprovalQuery(accountID, paidGroupPolicyIDs))?.hash; + const paymentHash = buildSearchQueryJSON(buildRepaidLast30DaysQuery(accountID))?.hash; + + const approvalKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalHash}` as const; + const paymentKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${paymentHash}` as const; + + const [approvalSearch] = useOnyx(approvalKey, {selector: (snapshot) => snapshot?.search}); + const [paymentSearch] = useOnyx(paymentKey, {selector: (snapshot) => snapshot?.search}); + + return useMemo( + (): YourSpendPatchData => ({ + paidPolicies: paidPolicies ?? EMPTY_YOUR_SPEND_PATCH_DATA.paidPolicies, + snapshotSearches: { + [approvalKey]: approvalSearch, + [paymentKey]: paymentSearch, + }, + }), + [paidPolicies, approvalKey, approvalSearch, paymentKey, paymentSearch], + ); +} + +export default useYourSpendPatchData; diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index b8e8cc0e442f..29fce258ecf3 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -15,6 +15,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import type {WriteOverrides} from './Navigation/helpers/submitWithDismissFirst'; +import type {YourSpendPatchData} from './YourSpendPatchData'; import { getMoneyRequestParticipantsFromReport, @@ -102,6 +103,7 @@ type SubmitAmountArgs = { amountOwed: OnyxEntry; ownerBillingGracePeriodEnd: OnyxEntry; conciergeReportID: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }; /** @@ -538,6 +540,7 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi allReports, navigateBack, isTrackIntentUser, + yourSpendPatchData, reportPolicyTags, } = args; const {currentTransaction, allowNegative, disableOppositeConversion, isSplitBill, currentUserAccountID, currentUserEmail, isASAPSubmitBetaEnabled, newAmount} = ctx; @@ -590,6 +593,7 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi delegateAccountID, reportPolicyTags, isTrackIntentUser, + yourSpendPatchData, }); navigateBack(); } diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 8b51c78599c7..1c5868cca12c 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -19,6 +19,7 @@ import {PlaybackContextProvider} from '@components/VideoPlayerContexts/PlaybackC import {VideoPopoverMenuContextProvider} from '@components/VideoPlayerContexts/VideoPopoverMenuContext'; import {VolumeContextProvider} from '@components/VideoPlayerContexts/VolumeContext'; import WideRHPContextProvider from '@components/WideRHPContextProvider'; +import {YourSpendPatchDataProvider} from '@components/YourSpendPatchDataProvider'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -182,6 +183,7 @@ function AuthScreens() { CurrencyListContextProvider, SidebarOrderedReportsContextProvider, SearchContextProvider, + YourSpendPatchDataProvider, LockedAccountModalProvider, DelegateNoAccessModalProvider, MultifactorAuthenticationContextProviders, diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index 660e40c727d5..46cb33c1b5bf 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -23,6 +23,8 @@ import type {Merge, ValueOf} from 'type-fest'; import isEmpty from 'lodash/isEmpty'; +import type {YourSpendPatchData} from './YourSpendPatchData'; + import {approveMoneyRequest} from './actions/IOU/ReportWorkflow'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; import BankAccountModel from './models/BankAccount'; @@ -54,6 +56,7 @@ type SelectPaymentTypeParams = { ownerBillingGracePeriodEnd: OnyxEntry; delegateEmail: string | undefined; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; ownerLogin: string | undefined; }; @@ -229,6 +232,7 @@ const selectPaymentType = (params: SelectPaymentTypeParams) => { ownerBillingGracePeriodEnd, delegateEmail, isTrackIntentUser, + yourSpendPatchData, ownerLogin, } = params; if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentAccountID)) { @@ -266,6 +270,7 @@ const selectPaymentType = (params: SelectPaymentTypeParams) => { full: true, delegateEmail, isTrackIntentUser, + yourSpendPatchData, }); } return; diff --git a/src/libs/YourSpendPatchData.ts b/src/libs/YourSpendPatchData.ts new file mode 100644 index 000000000000..b8861767011a --- /dev/null +++ b/src/libs/YourSpendPatchData.ts @@ -0,0 +1,21 @@ +import type {Policy, SearchResults} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +// Snapshot patching only needs `search` aggregates, not the large `data` blob. +type SnapshotSearch = SearchResults['search']; + +/** The only policy fields the snapshot builders read; narrowed so `useOnyx` result comparisons stay cheap and subscribers don't re-render on unrelated policy changes. */ +type YourSpendPolicy = Pick; + +/** Onyx data the Your spend snapshot builders need, supplied by the triggering component. */ +type YourSpendPatchData = { + // Paid-group workspaces only + paidPolicies: OnyxCollection; + snapshotSearches: Record; +}; + +const EMPTY_YOUR_SPEND_PATCH_DATA: YourSpendPatchData = {paidPolicies: {}, snapshotSearches: {}}; + +export {EMPTY_YOUR_SPEND_PATCH_DATA}; +export type {YourSpendPatchData, YourSpendPolicy}; diff --git a/src/pages/home/YourSpendSection/queries.ts b/src/libs/YourSpendQueryUtils.ts similarity index 50% rename from src/pages/home/YourSpendSection/queries.ts rename to src/libs/YourSpendQueryUtils.ts index ff915a2213a5..20ffabb9edb0 100644 --- a/src/pages/home/YourSpendSection/queries.ts +++ b/src/libs/YourSpendQueryUtils.ts @@ -1,7 +1,37 @@ -import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; - import CONST from '@src/CONST'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import type {YourSpendPolicy} from './YourSpendPatchData'; + +// eslint-disable-next-line no-restricted-imports -- Your spend is a billing/paid-only feature (Collect/Control), so paid-group scoping is intentional here. +import {isPaidGroupPolicy} from './PolicyUtils'; +import {buildQueryStringFromFilterFormValues} from './SearchQueryUtils'; + +/** Extracts policy IDs from an already-narrowed paid-group collection (see `selectPaidGroupPolicies`). */ +function getPaidGroupPolicyIDs(paidPolicies: OnyxCollection): string[] { + return Object.values(paidPolicies ?? {}) + .map((policy) => policy?.id) + .filter((id): id is string => !!id); +} + +/** + * `useOnyx` selector that narrows the full policy collection to only paid-group workspaces (the only ones "Your spend" + * cares about) and to the few fields the snapshot builders read, so subscribers re-render only when those fields of a + * paid policy change rather than on any policy change. + */ +function selectPaidGroupPolicies(policies: OnyxCollection): OnyxCollection { + const paidPolicies: OnyxCollection = {}; + for (const [key, policy] of Object.entries(policies ?? {})) { + if (!policy?.id || !isPaidGroupPolicy(policy)) { + continue; + } + paidPolicies[key] = {id: policy.id, type: policy.type, outputCurrency: policy.outputCurrency}; + } + return paidPolicies; +} function get30DaysAgoDateString(): string { const date = new Date(); @@ -42,4 +72,4 @@ function buildRecentCardTransactionsQuery(accountID: number, cardID: number): st }); } -export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery}; +export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery, get30DaysAgoDateString, getPaidGroupPolicyIDs, selectPaidGroupPolicies}; diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index de0d6dd6c801..48083d6022d3 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -23,6 +23,7 @@ import { updateOptimisticParentReportAction, } from '@libs/ReportUtils'; import {getAmount, getCurrency, isOnHold, removeTransactionFromDuplicateTransactionViolation} from '@libs/TransactionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import {clearByKey as clearPdfByOnyxKey} from '@userActions/CachedPDFPaths'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; @@ -40,8 +41,11 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} import cloneDeep from 'lodash/cloneDeep'; import Onyx from 'react-native-onyx'; +import type {YourSpendSnapshotOnyxData} from './YourSpendSnapshotUpdate'; + import {getAllReportActionsFromIOU, getAllReportNameValuePairs, getAllReports, getAllTransactions, getAllTransactionViolations} from '.'; import {getReportPreviewAction, maybeUpdateReportNameForFormulaTitle} from './MoneyRequestBuilder'; +import {getYourSpendSnapshotTransactionRemovalUpdates} from './YourSpendSnapshotUpdate'; type PrepareToCleanUpMoneyRequestResult = { shouldDeleteTransactionThread: boolean; @@ -74,6 +78,9 @@ type DeleteMoneyRequestFunctionParams = { currentUserEmail: string; transactionThreadReport: OnyxEntry; policy?: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; + /** Precomputed batch snapshot updates; when deleting several expenses in one action the caller aggregates them into a single update (attached to one request) instead of stacking per-transaction absolute totals. */ + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData; }; /** Builds the Onyx surface a delete needs to touch: updated report + preview action, thread/report deletion flags, sticky-total marker. */ @@ -737,6 +744,8 @@ function deleteMoneyRequest({ currentUserAccountID, currentUserEmail, policy, + yourSpendPatchData, + yourSpendSnapshotUpdates, }: DeleteMoneyRequestFunctionParams) { if (!transactionID) { return; @@ -1035,8 +1044,21 @@ function deleteMoneyRequest({ reportActionID: reportAction.reportActionID, }; + const yourSpendUpdates = + yourSpendSnapshotUpdates ?? + getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport, + currentUserAccountID, + context: yourSpendPatchData, + }); + // STEP 3: Make the API request - API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, { + optimisticData: [...optimisticData, ...yourSpendUpdates.optimisticData], + successData: [...successData, ...yourSpendUpdates.successData], + failureData: [...failureData, ...yourSpendUpdates.failureData], + }); clearPdfByOnyxKey(transactionID); return urlToNavigateBack; diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index e8b61e826b1a..f2263eb91e53 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -26,6 +26,7 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import {buildPolicyData, generatePolicyID} from '@userActions/Policy/Policy'; import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; @@ -47,9 +48,12 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; +import type {YourSpendReportMoveItem, YourSpendSnapshotOnyxData} from './YourSpendSnapshotUpdate'; + import {getAllPersonalDetails, getAllTransactionViolations} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; import {getReportPreviewAction} from './MoneyRequestBuilder'; +import {getYourSpendSnapshotReportMoveUpdates} from './YourSpendSnapshotUpdate'; type PayInvoiceArgs = { paymentMethodType: PaymentMethodType; @@ -86,6 +90,7 @@ type PayMoneyRequestData = { | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT | BuildPolicyDataKeys >; }; @@ -124,6 +129,9 @@ type PayMoneyRequestFunctionParams = { delegateAccountID?: number | undefined; chatReportActions: OnyxEntry; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; + /** Precomputed batch snapshot updates; when paying several reports in one action the caller aggregates them into a single update (attached to one request) instead of stacking per-report absolute totals. */ + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData; }; function mergeAdditionalPayOnyxData< @@ -170,6 +178,8 @@ function getPayMoneyRequestParams({ delegateAccountID, chatReportActions, isTrackIntentUser, + yourSpendPatchData, + yourSpendSnapshotUpdates, }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; @@ -197,6 +207,8 @@ function getPayMoneyRequestParams({ delegateAccountID?: number | undefined; chatReportActions: OnyxEntry; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData; }): PayMoneyRequestData { // TODO: https://github.com/Expensify/App/issues/66512 // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -214,6 +226,7 @@ function getPayMoneyRequestParams({ | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT | BuildPolicyDataKeys > = { optimisticData: [], @@ -519,6 +532,21 @@ function getPayMoneyRequestParams({ optimisticHoldReportExpenseActionIDs = JSON.stringify(holdReportOnyxData.optimisticHoldReportExpenseActionIDs); } + // Paying a report moves it from "Awaiting approval" to "Repaid" in the Your spend widget; patch both snapshots offline. + const yourSpendUpdates = + yourSpendSnapshotUpdates ?? + getYourSpendSnapshotReportMoveUpdates({ + iouReport, + reportTransactions, + fromStatus: {stateNum: iouReport?.stateNum, statusNum: iouReport?.statusNum}, + toStatus: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + onyxData.optimisticData?.push(...yourSpendUpdates.optimisticData); + onyxData.successData?.push(...yourSpendUpdates.successData); + onyxData.failureData?.push(...yourSpendUpdates.failureData); + return { params: { iouReportID: iouReport?.reportID, @@ -546,6 +574,7 @@ function cancelPayment( currentUserEmailParam: string, hasViolations: boolean, isTrackIntentUser: boolean | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { if (isEmptyObject(expenseReport)) { return; @@ -742,6 +771,15 @@ function cancelPayment( }), }); + const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum, statusNum}, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + API.write( WRITE_COMMANDS.CANCEL_PAYMENT, { @@ -750,7 +788,11 @@ function cancelPayment( managerAccountID: expenseReport.managerID ?? CONST.DEFAULT_NUMBER_ID, reportActionID: optimisticReportAction.reportActionID, }, - {optimisticData, successData, failureData}, + { + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }, ); notifyNewAction(expenseReport.reportID, undefined, true); @@ -804,6 +846,19 @@ function completePaymentOnboarding( }); } +/** Builds the Your spend report-move item for a payment (the report moves to reimbursed), so bulk callers can aggregate one snapshot update across reports. */ +function getPayYourSpendReportMoveItem(iouReport: OnyxEntry): YourSpendReportMoveItem | undefined { + if (!iouReport) { + return undefined; + } + return { + iouReport, + reportTransactions: getReportTransactions(iouReport.reportID), + fromStatus: {stateNum: iouReport.stateNum, statusNum: iouReport.statusNum}, + toStatus: {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}, + }; +} + function payMoneyRequest(params: PayMoneyRequestFunctionParams) { const { paymentType, @@ -830,6 +885,8 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { delegateAccountID, chatReportActions, isTrackIntentUser, + yourSpendPatchData, + yourSpendSnapshotUpdates, } = params; const policyForBillingRestriction = chatReportPolicy ?? (policy?.id === chatReport.policyID ? policy : undefined); if ( @@ -866,6 +923,8 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { delegateAccountID, chatReportActions, isTrackIntentUser, + yourSpendPatchData, + yourSpendSnapshotUpdates, }); // For now, we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with @@ -1184,5 +1243,14 @@ function savePreferredPaymentMethod( }); } -export {cancelPayment, completePaymentOnboarding, markReportPaymentReceived, mergeAdditionalPayOnyxData, payInvoice, payMoneyRequest, savePreferredPaymentMethod}; +export { + cancelPayment, + completePaymentOnboarding, + getPayYourSpendReportMoveItem, + markReportPaymentReceived, + mergeAdditionalPayOnyxData, + payInvoice, + payMoneyRequest, + savePreferredPaymentMethod, +}; export type {AdditionalPayOnyxData}; diff --git a/src/libs/actions/IOU/RejectMoneyRequest.ts b/src/libs/actions/IOU/RejectMoneyRequest.ts index 9c57a52bce9c..122da8fd03d9 100644 --- a/src/libs/actions/IOU/RejectMoneyRequest.ts +++ b/src/libs/actions/IOU/RejectMoneyRequest.ts @@ -29,6 +29,7 @@ import { } from '@libs/ReportUtils'; import {getAmount, getCurrency} from '@libs/TransactionUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import {notifyNewAction} from '@userActions/Report'; @@ -45,7 +46,10 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {YourSpendSnapshotOnyxData} from './YourSpendSnapshotUpdate'; + import {getAllReports, getAllTransactions, getAllTransactionViolations} from '.'; +import {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotTransactionRemovalUpdates} from './YourSpendSnapshotUpdate'; type RejectMoneyRequestData = { optimisticData: Array< @@ -57,6 +61,7 @@ type RejectMoneyRequestData = { | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT > >; successData: Array< @@ -71,6 +76,7 @@ type RejectMoneyRequestData = { | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT > >; parameters: RejectMoneyRequestParams; @@ -81,6 +87,9 @@ type RejectMoneyRequestOptions = { sharedRejectedToReportID?: string; existingRejectedReport?: OnyxEntry; setExistingRejectedReport?: (report: OnyxEntry) => void; + yourSpendPatchData?: YourSpendPatchData; + /** Precomputed batch snapshot updates; when rejecting several expenses in one action the caller aggregates them into a single update (attached to one request) instead of stacking per-transaction absolute totals. */ + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData; }; function dismissRejectUseExplanation() { @@ -184,6 +193,7 @@ function prepareRejectMoneyRequestData( | typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT > > = []; @@ -207,6 +217,7 @@ function prepareRejectMoneyRequestData( | typeof ONYXKEYS.COLLECTION.REPORT_METADATA | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT > > = []; @@ -909,6 +920,17 @@ function prepareRejectMoneyRequestData( expenseCreatedReportActionID, }; + const yourSpendSnapshotUpdates = + options?.yourSpendSnapshotUpdates ?? + getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport: report, + currentUserAccountID: currentUserAccountIDParam, + context: options?.yourSpendPatchData, + }); + optimisticData.push(...yourSpendSnapshotUpdates.optimisticData); + failureData.push(...yourSpendSnapshotUpdates.failureData); + return {optimisticData, successData, failureData, parameters, urlToNavigateBack: urlToNavigateBack as Route}; } @@ -922,7 +944,7 @@ function rejectMoneyRequest( betas: OnyxEntry, options?: RejectMoneyRequestOptions, ): Route | undefined { - const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, options); + const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, options, undefined, undefined); if (!data) { return; } @@ -1009,6 +1031,7 @@ function rejectExpenseReport( currentUserDisplayName: string | undefined, currentUserAvatarSource: AvatarSource | undefined, isTrackIntentUser: boolean | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { const {reportID} = report; const isRejectToSubmitter = targetAccountID === report.ownerAccountID; @@ -1190,7 +1213,20 @@ function rejectExpenseReport( rejectedCommentReportActionID: optimisticCommentAction.reportActionID, }; - API.write(WRITE_COMMANDS.REJECT_EXPENSE_REPORT, parameters, {optimisticData, successData, failureData}); + const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ + iouReport: report, + reportTransactions: getReportTransactions(reportID), + fromStatus: {stateNum: report.stateNum, statusNum: report.statusNum}, + toStatus: {stateNum: optimisticStateNum, statusNum: optimisticStatusNum}, + currentUserAccountID: currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, + context: yourSpendPatchData, + }); + + API.write(WRITE_COMMANDS.REJECT_EXPENSE_REPORT, parameters, { + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }); } export {dismissRejectUseExplanation, prepareRejectMoneyRequestData, rejectMoneyRequest, markRejectViolationAsResolved, rejectExpenseReport}; diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index d8d5d215805d..5fd3c8341b62 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -73,6 +73,7 @@ import { isTransactionSubmittable, } from '@libs/TransactionUtils'; import {isValidAccountRoute} from '@libs/ValidationUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -88,10 +89,12 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; import type {AdditionalPayOnyxData} from './PayMoneyRequest'; +import type {YourSpendReportMoveItem, YourSpendSnapshotOnyxData} from './YourSpendSnapshotUpdate'; import {getAllReportNameValuePairs, getAllTransactionViolations} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; import {mergeAdditionalPayOnyxData} from './PayMoneyRequest'; +import {getYourSpendSnapshotReportMoveUpdates} from './YourSpendSnapshotUpdate'; type ApproveMoneyRequestFunctionParams = { expenseReport: OnyxEntry; @@ -112,6 +115,9 @@ type ApproveMoneyRequestFunctionParams = { ownerLogin: string | undefined; additionalOnyxData?: AdditionalPayOnyxData; shouldPlaySuccessSound?: boolean; + yourSpendPatchData?: YourSpendPatchData; + /** Precomputed batch snapshot updates; when approving several reports in one action the caller aggregates them into a single update (attached to one request) instead of stacking per-report absolute totals. */ + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData; }; type SubmitReportFunctionParams = { @@ -132,6 +138,7 @@ type SubmitReportFunctionParams = { managerEmail?: string; /** When provided (e.g. from the submit-to popover selection), used for optimistic managerID before falling back to email resolution. */ managerAccountID?: number; + yourSpendPatchData?: YourSpendPatchData; }; function canApproveIOU( @@ -428,6 +435,41 @@ function getReportOriginalCreationTimestamp(expenseReport?: OnyxEntry, policy: OnyxEntry): YourSpendReportMoveItem | undefined { + if (!expenseReport || hasDynamicExternalWorkflow(policy)) { + return undefined; + } + return { + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: isSubmitAndClose(policy) + ? {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED} + : {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + }; +} + +/** + * Builds the Your spend report-move item for an approval, mirroring `approveMoneyRequest`'s optimistic state prediction, + * so bulk callers can aggregate one snapshot update across reports. Returns undefined for DEW policies (the backend + * decides the workflow, so nothing is patched optimistically). + */ +function getApproveYourSpendReportMoveItem(expenseReport: OnyxEntry, expenseReportPolicy: OnyxEntry): YourSpendReportMoveItem | undefined { + if (!expenseReport || hasDynamicExternalWorkflow(expenseReportPolicy)) { + return undefined; + } + const nextApproverAccountID = getNextApproverAccountID(expenseReport); + return { + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: !nextApproverAccountID + ? {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED} + : {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + }; +} + function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { const { expenseReport, @@ -448,6 +490,8 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { additionalOnyxData, shouldPlaySuccessSound = true, isTrackIntentUser, + yourSpendPatchData, + yourSpendSnapshotUpdates, } = params; if (!expenseReport) { return; @@ -808,11 +852,37 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { optimisticCreatedReportForUnapprovedTransactionsActionID, }; + // DEW policies don't optimistically change the report state (the backend decides the workflow), so there's + // nothing to patch into Your spend until the next online refresh. + const yourSpendUpdates = + yourSpendSnapshotUpdates ?? + (isDEWPolicy + ? {optimisticData: [], successData: [], failureData: []} + : getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions, + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + })); + onApproved?.(); if (shouldPlaySuccessSound) { playSound(SOUNDS.SUCCESS); } - API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, mergeAdditionalPayOnyxData({optimisticData, successData, failureData}, additionalOnyxData)); + API.write( + WRITE_COMMANDS.APPROVE_MONEY_REQUEST, + parameters, + mergeAdditionalPayOnyxData( + { + optimisticData: [...optimisticData, ...yourSpendUpdates.optimisticData], + successData: [...successData, ...yourSpendUpdates.successData], + failureData: [...failureData, ...yourSpendUpdates.failureData], + }, + additionalOnyxData, + ), + ); return optimisticHoldReportID; } @@ -1015,6 +1085,7 @@ function reopenReport( }); } +// eslint-disable-next-line @typescript-eslint/max-params -- isTrackIntentUser and yourSpendPatchData are independent optional context args added by separate features function retractReport( expenseReport: OnyxEntry, chatReport: OnyxEntry, @@ -1026,6 +1097,7 @@ function retractReport( expenseReportCurrentNextStepDeprecated: OnyxEntry, delegateEmail: string | undefined, isTrackIntentUser: boolean | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { if (!expenseReport) { return; @@ -1189,10 +1261,19 @@ function retractReport( reportActionID: optimisticRetractReportAction.reportActionID, }; + const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + API.write(WRITE_COMMANDS.RETRACT_REPORT, parameters, { - optimisticData, - successData, - failureData, + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], }); } @@ -1206,6 +1287,7 @@ function unapproveExpenseReport( expenseReportCurrentNextStepDeprecated: OnyxEntry, delegateEmail: string | undefined, isTrackIntentUser: boolean | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { if (isEmptyObject(expenseReport)) { return; @@ -1359,10 +1441,19 @@ function unapproveExpenseReport( reportActionID: optimisticUnapprovedReportAction.reportActionID, }; + const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + API.write(WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT, parameters, { - optimisticData, - successData, - failureData, + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], }); } @@ -1383,6 +1474,7 @@ function submitReport({ managerEmail, managerAccountID: managerAccountIDFromPopover, isTrackIntentUser, + yourSpendPatchData, }: SubmitReportFunctionParams) { if (!expenseReport) { return; @@ -1661,11 +1753,26 @@ function submitReport({ : {}), }; + // DEW policies don't optimistically change the report state (the backend decides the workflow), so there's + // nothing to patch into Your spend until the next online refresh. + const yourSpendSnapshotUpdates = isDEWPolicy + ? {optimisticData: [], successData: [], failureData: []} + : getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: isSubmitAndClosePolicy + ? {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.CLOSED} + : {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + onSubmitted?.(); API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, { - optimisticData, - successData, - failureData, + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], }); } @@ -1919,9 +2026,11 @@ export { canIOUBePaid, canSubmitReport, clearPendingExpenseAction, + getApproveYourSpendReportMoveItem, getBadgeFromIOUReport, getIOUReportActionWithBadge, getReportOriginalCreationTimestamp, + getSubmitYourSpendReportMoveItem, reopenReport, retractReport, submitReport, diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 049d7906a9cd..4778e697c777 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -44,6 +44,7 @@ import { } from '@libs/ReportUtils'; import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import {getChildTransactions, isDistanceRequest as isDistanceRequestTransactionUtils, isOnHold, isPerDiemRequest as isPerDiemRequestTransactionUtils} from '@libs/TransactionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import {removeDraftSplitTransaction} from '@userActions/TransactionEdit'; @@ -75,6 +76,7 @@ import {getMoneyRequestInformation, getReportPreviewAction} from './MoneyRequest import {addPendingNewTransactionIDs} from './PendingNewTransactions'; import {getDeleteTrackExpenseInformation} from './TrackExpense'; import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest'; +import {getYourSpendSnapshotSplitUpdates} from './YourSpendSnapshotUpdate'; type UpdateSplitTransactionsParams = { allTransactionsList: OnyxCollection; @@ -109,8 +111,33 @@ type UpdateSplitTransactionsParams = { isOffline: boolean; delegateAccountID: number | undefined; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; }; +type GetReimbursableSplitDiffParams = { + splits: SplitTransactionSplitsParam; + originalTransaction: OnyxEntry; + originalChildTransactions: Array>; + splitExpensesTotal: number; + isCreationOfSplits: boolean; +}; + +/** + * Returns the change to the reimbursable total from a split, signed with the snapshot `total` convention (spend is negative), + * so it can be applied to the Your spend aggregates directly. Your spend only counts reimbursable expenses, so + * non-reimbursable splits are excluded — otherwise splitting a reimbursable expense into reimbursable + non-reimbursable + * parts nets to 0 against the whole-report change and leaves Your spend stale. + */ +function getReimbursableSplitDiff({splits, originalTransaction, originalChildTransactions, splitExpensesTotal, isCreationOfSplits}: GetReimbursableSplitDiffParams): number { + const newReimbursableTotal = splits.reduce((total, split) => total + (split.reimbursable !== false ? split.amount : 0), 0); + const creationPreviousTotal = originalTransaction?.reimbursable !== false ? splitExpensesTotal : 0; + const previousReimbursableTotal = isCreationOfSplits + ? creationPreviousTotal + : originalChildTransactions.reduce((total, childTransaction) => total + (childTransaction?.reimbursable !== false ? Math.abs(childTransaction?.amount ?? 0) : 0), 0); + // The totals above are magnitudes; more reimbursable spend must push the (negative) snapshot total further from zero, hence the flip. + return previousReimbursableTotal - newReimbursableTotal; +} + function updateSplitTransactions({ allTransactionsList, allReportsList, @@ -139,6 +166,7 @@ function updateSplitTransactions({ isOffline, delegateAccountID, isTrackIntentUser, + yourSpendPatchData, }: UpdateSplitTransactionsParams) { const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); // For selfDM-origin splits the caller can't resolve a real `expenseReport` (the draft/source @@ -1818,6 +1846,32 @@ function updateSplitTransactions({ } } + const reimbursableSplitsCount = splits.filter((split) => split.reimbursable !== false).length; + const previousReimbursableCount = isCreationOfSplits + ? Number(originalTransaction?.reimbursable !== false) + : originalChildTransactions.filter((childTransaction) => childTransaction?.reimbursable !== false).length; + const reimbursableCountDiff = reimbursableSplitsCount - previousReimbursableCount; + + const reimbursableDiff = getReimbursableSplitDiff({ + splits, + originalTransaction, + originalChildTransactions, + splitExpensesTotal, + isCreationOfSplits, + }); + + const yourSpendSplitUpdates = getYourSpendSnapshotSplitUpdates({ + iouReport: expenseReport, + originalTransaction, + reimbursableDiff, + reimbursableCountDiff, + currentUserAccountID: currentUserPersonalDetails.accountID, + context: yourSpendPatchData, + }); + onyxData.optimisticData?.push(...yourSpendSplitUpdates.optimisticData); + onyxData.successData?.push(...yourSpendSplitUpdates.successData); + onyxData.failureData?.push(...yourSpendSplitUpdates.failureData); + if (isReverseSplitOperation) { const parameters = { ...splits.at(0), @@ -1990,4 +2044,4 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac }); } -export {updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow}; +export {getReimbursableSplitDiff, updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow}; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index b266a05fb167..a5744cc5abcf 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -76,6 +76,7 @@ import { isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, isScanRequest as isScanRequestTransactionUtils, } from '@libs/TransactionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import {clearByKey as clearPdfByOnyxKey} from '@userActions/CachedPDFPaths'; import {buildAddMembersToWorkspaceOnyxData, buildUpdateWorkspaceMembersRoleOnyxData} from '@userActions/Policy/Member'; @@ -216,6 +217,7 @@ type DeleteTrackExpenseParams = { currentUserAccountID: number; currentUserEmail: string; policy?: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }; type BuildOnyxDataForTrackExpenseParams = { @@ -2918,6 +2920,7 @@ function deleteTrackExpense({ currentUserAccountID, currentUserEmail, policy, + yourSpendPatchData, }: DeleteTrackExpenseParams) { if (!chatReportID || !transactionID) { return; @@ -2953,6 +2956,7 @@ function deleteTrackExpense({ currentUserAccountID, currentUserEmail, policy, + yourSpendPatchData, }); return urlToNavigateBack; } diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 5f37461435d7..3ae50ac49bdc 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -36,6 +36,7 @@ import { removeTransactionFromDuplicateTransactionViolation, } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -58,6 +59,7 @@ import Onyx from 'react-native-onyx'; import {getAllReports, getAllTransactions, getAllTransactionViolations, getPolicyTagsData, getRecentAttendees} from '.'; import {getUpdatedMoneyRequestReportData, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; +import {getYourSpendSnapshotReimbursableUpdates, getYourSpendSnapshotTotalUpdates} from './YourSpendSnapshotUpdate'; type UpdateMoneyRequestData = { params: UpdateMoneyRequestParams; @@ -403,6 +405,7 @@ function updateMoneyRequestReimbursable({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + yourSpendPatchData, }: { transactionID: string | undefined; transactionThreadReport: OnyxEntry; @@ -419,6 +422,7 @@ function updateMoneyRequestReimbursable({ delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; }) { if (!transactionID || !transactionThreadReport?.reportID) { return; @@ -442,6 +446,7 @@ function updateMoneyRequestReimbursable({ isOffline, delegateAccountID, isTrackIntentUser, + yourSpendPatchData, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); } @@ -1356,6 +1361,7 @@ type UpdateMoneyRequestAmountAndCurrencyParams = { delegateAccountID: number | undefined; reportPolicyTags: OnyxEntry; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; }; /** Updates the amount and currency fields of an expense */ @@ -1383,6 +1389,7 @@ function updateMoneyRequestAmountAndCurrency({ delegateAccountID, reportPolicyTags, isTrackIntentUser, + yourSpendPatchData, }: UpdateMoneyRequestAmountAndCurrencyParams) { const transactionChanges = { amount, @@ -1415,6 +1422,7 @@ function updateMoneyRequestAmountAndCurrency({ hash, delegateAccountID, isTrackIntentUser, + yourSpendPatchData, }); removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } @@ -1450,6 +1458,7 @@ type GetUpdateMoneyRequestParamsType = { delegateAccountID: number | undefined; distanceOriginalPolicy?: OnyxEntry; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; personalPolicyOutputCurrency?: string; }; @@ -1494,6 +1503,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U delegateAccountID, distanceOriginalPolicy, isTrackIntentUser, + yourSpendPatchData, personalPolicyOutputCurrency, } = params; const optimisticData: Array< @@ -2128,6 +2138,34 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U }); } + // Only amount edits can be patched offline; currency changes need server-side FX rates. + if (hasModifiedAmount && transaction && updatedTransaction && iouReport) { + const yourSpendSnapshotTotalUpdates = getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + optimisticData.push(...yourSpendSnapshotTotalUpdates.optimisticData); + successData.push(...yourSpendSnapshotTotalUpdates.successData); + failureData.push(...yourSpendSnapshotTotalUpdates.failureData); + } + + // Toggling reimbursable adds/removes the expense from the reimbursable-only Your spend totals. + if (hasModifiedReimbursable && transaction && updatedTransaction && iouReport) { + const yourSpendSnapshotReimbursableUpdates = getYourSpendSnapshotReimbursableUpdates({ + transaction, + updatedTransaction, + iouReport, + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + optimisticData.push(...yourSpendSnapshotReimbursableUpdates.optimisticData); + successData.push(...yourSpendSnapshotReimbursableUpdates.successData); + failureData.push(...yourSpendSnapshotReimbursableUpdates.failureData); + } + return { params: apiParams, onyxData: {optimisticData, successData, failureData}, diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts new file mode 100644 index 000000000000..aae5960f793d --- /dev/null +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -0,0 +1,662 @@ +import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {getAmount, getConvertedAmount, getCurrency} from '@libs/TransactionUtils'; +import {EMPTY_YOUR_SPEND_PATCH_DATA} from '@libs/YourSpendPatchData'; +import type {YourSpendPatchData, YourSpendPolicy} from '@libs/YourSpendPatchData'; +import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDateString, getPaidGroupPolicyIDs} from '@libs/YourSpendQueryUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Transaction} from '@src/types/onyx'; +import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; + +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** Onyx updates that optimistically patch the "Your spend" search snapshots, with rollback on failure. */ +type YourSpendSnapshotOnyxData = { + /** Updates applied optimistically while offline */ + optimisticData: Array>; + + /** Updates applied once the request succeeds */ + successData: Array>; + + /** Updates that roll the snapshots back if the request fails */ + failureData: Array>; +}; + +/** Params for computing the "Your spend" snapshot updates triggered by a money-request change. */ +type GetYourSpendSnapshotTotalUpdatesParams = { + /** The transaction before the change */ + transaction: OnyxEntry; + + /** The transaction after the change */ + updatedTransaction: OnyxEntry; + + /** The IOU report the transaction belongs to */ + iouReport: OnyxEntry; + + /** Account ID of the current user */ + currentUserAccountID: number; + + /** Paid-group policies and snapshot aggregates supplied from the view layer */ + context?: YourSpendPatchData; +}; + +function transactionMatchesAwaitingApprovalQuery(iouReport: OnyxEntry, transaction: OnyxEntry, accountID: number, paidGroupPolicyIDs: string[]): boolean { + if (!iouReport || !transaction) { + return false; + } + if (iouReport.ownerAccountID !== accountID) { + return false; + } + if (transaction.reimbursable === false) { + return false; + } + if (!iouReport.policyID || !paidGroupPolicyIDs.includes(iouReport.policyID)) { + return false; + } + return iouReport.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && iouReport.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED; +} + +function transactionMatchesRepaidLast30DaysQuery(iouReport: OnyxEntry, transaction: OnyxEntry, accountID: number): boolean { + if (!iouReport || !transaction) { + return false; + } + if (iouReport.ownerAccountID !== accountID) { + return false; + } + if (transaction.reimbursable === false) { + return false; + } + const isPaid = (iouReport.stateNum ?? 0) >= CONST.REPORT.STATE_NUM.APPROVED && iouReport.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; + if (!isPaid) { + return false; + } + const created = transaction.created?.slice(0, 10); + if (!created) { + return false; + } + return created > get30DaysAgoDateString(); +} + +function buildSnapshotTotalUpdatesForHash( + snapshotSearches: YourSpendPatchData['snapshotSearches'], + snapshotHash: number | undefined, + diff: number, + currency: string, + countDiff = 0, +): YourSpendSnapshotOnyxData { + if (!snapshotHash || (diff === 0 && countDiff === 0)) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; + const search = snapshotSearches[snapshotKey]; + + // Skip when the snapshot isn't loaded; a loaded-but-empty snapshot is a valid zero base. + if (!search) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const currentCurrency = search.currency; + const currentTotal = search.total ?? 0; + const currentCount = search.count ?? 0; + + if (currentCurrency && currentCurrency !== currency) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const updatedTotal = currentTotal + diff; + // `count` drives row visibility, so it must move in lockstep with `total`. + const updatedCount = Math.max(0, currentCount + countDiff); + return { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: snapshotKey, + value: { + search: { + total: updatedTotal, + count: updatedCount, + currency, + }, + }, + }, + ], + successData: [], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: snapshotKey, + value: { + search: { + total: search.total ?? null, + count: search.count ?? null, + currency: currentCurrency ?? null, + }, + }, + }, + ], + }; +} + +function mergeYourSpendSnapshotOnyxData(target: YourSpendSnapshotOnyxData, source: YourSpendSnapshotOnyxData) { + target.optimisticData.push(...source.optimisticData); + target.successData.push(...source.successData); + target.failureData.push(...source.failureData); +} + +function calculateYourSpendTotalDiff(iouReport: OnyxEntry, updatedTransaction: OnyxEntry, transaction: OnyxEntry): number | null { + if (!iouReport || !updatedTransaction || !transaction) { + return null; + } + + const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + const currentCurrency = getCurrency(transaction); + const updatedCurrency = getCurrency(updatedTransaction); + + // A currency change can't be patched offline (FX rates are server-side only). + if (currentCurrency !== updatedCurrency) { + return null; + } + + // Signed to match the snapshot `total` convention (spend negative, credits positive). + const currentTotal = getAmount(transaction, isExpenseReportLocal); + const updatedTotal = getAmount(updatedTransaction, isExpenseReportLocal); + + if (currentTotal === updatedTotal) { + return 0; + } + + return updatedTotal - currentTotal; +} + +type ReportStatus = Pick; + +type GetYourSpendSnapshotReportMoveUpdatesParams = { + iouReport: OnyxEntry; + reportTransactions: Transaction[]; + fromStatus: ReportStatus; + toStatus: ReportStatus; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + +function isAwaitingApprovalStatus(status: ReportStatus): boolean { + return status.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && status.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED; +} + +function isRepaidStatus(status: ReportStatus): boolean { + return (status.stateNum ?? 0) >= CONST.REPORT.STATE_NUM.APPROVED && status.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; +} + +function reportInAwaitingApprovalScope(iouReport: OnyxEntry, accountID: number, paidGroupPolicyIDs: string[]): boolean { + if (!iouReport || iouReport.ownerAccountID !== accountID) { + return false; + } + return !!iouReport.policyID && paidGroupPolicyIDs.includes(iouReport.policyID); +} + +function reportInRepaidScope(iouReport: OnyxEntry, accountID: number): boolean { + return !!iouReport && iouReport.ownerAccountID === accountID; +} + +function getSnapshotSearchResults(snapshotSearches: YourSpendPatchData['snapshotSearches'], snapshotHash: number | undefined) { + if (!snapshotHash) { + return undefined; + } + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; + return snapshotSearches[snapshotKey]; +} + +/** Returns a transaction's signed reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. */ +function getReimbursableTransactionAmountInCurrency( + policies: OnyxCollection, + transaction: Transaction, + iouReport: OnyxEntry, + targetCurrency: string, +): number | null { + const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + const transactionCurrency = getCurrency(transaction); + + if (transactionCurrency === targetCurrency) { + return getAmount(transaction, isExpenseReportLocal); + } + // `convertedAmount` is in the policy output currency; only trust it when that matches the target currency. + const policyOutputCurrency = iouReport?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]?.outputCurrency : undefined; + if (transaction.convertedAmount != null && policyOutputCurrency === targetCurrency) { + return getConvertedAmount(transaction, isExpenseReportLocal); + } + + return null; +} + +type ReportReimbursableAggregate = { + total: number; + count: number; + // Contributing transactions, injected into `snapshot.data` so the Search page isn't empty offline. + transactions: Transaction[]; +}; + +/** Sums reimbursable transactions (and counts them) in the snapshot currency, optionally restricted to the last 30 days. */ +function getReportReimbursableTotal( + policies: OnyxCollection, + iouReport: OnyxEntry, + reportTransactions: Transaction[], + onlyWithinLast30Days: boolean, + targetCurrency: string, +): ReportReimbursableAggregate | null { + let total = 0; + let count = 0; + const transactions: Transaction[] = []; + + for (const reportTransaction of reportTransactions) { + if (!reportTransaction || reportTransaction.reimbursable === false) { + continue; + } + if (onlyWithinLast30Days) { + const created = reportTransaction.created?.slice(0, 10); + if (!created || created <= get30DaysAgoDateString()) { + continue; + } + } + + const amount = getReimbursableTransactionAmountInCurrency(policies, reportTransaction, iouReport, targetCurrency); + if (amount === null) { + return null; + } + total += amount; + count += 1; + transactions.push(reportTransaction); + } + + return {total, count, transactions}; +} + +/** Adds (or removes) a report's reimbursable transactions in `snapshot.data` when it enters (or leaves) a section, so the Search page isn't empty offline. */ +function buildSnapshotDataUpdatesForHash( + snapshotSearches: YourSpendPatchData['snapshotSearches'], + snapshotHash: number | undefined, + transactions: Transaction[], + enters: boolean, + leaves: boolean, +): YourSpendSnapshotOnyxData { + // Only a section crossing (enter XOR leave) changes membership. + if (!snapshotHash || transactions.length === 0 || enters === leaves) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; + if (!snapshotSearches[snapshotKey]) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const presentData: SearchResultDataType = {}; + const absentData: NullishDeep = {}; + for (const transaction of transactions) { + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}` as const; + presentData[transactionKey] = transaction; + absentData[transactionKey] = null; + } + + const optimisticDataValue = enters ? presentData : absentData; + const failureDataValue = enters ? absentData : presentData; + + return { + optimisticData: [{onyxMethod: Onyx.METHOD.MERGE, key: snapshotKey, value: {data: optimisticDataValue}}], + successData: [], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: snapshotKey, value: {data: failureDataValue}}], + }; +} + +/** A report (with its transactions) moving between states, used when patching several reports in one action. */ +type YourSpendReportMoveItem = { + iouReport: OnyxEntry; + reportTransactions: Transaction[]; + fromStatus: ReportStatus; + toStatus: ReportStatus; +}; + +type GetYourSpendSnapshotReportsMoveUpdatesParams = { + reportItems: YourSpendReportMoveItem[]; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + +/** + * Optimistically patches Your spend snapshot aggregates when reports move between states (e.g. submit, retract, reject, + * unapprove, approve, pay, cancel payment). All reports are aggregated into a single total update per snapshot, so each + * update sees the full batch diff instead of the last-write-wins result of stacking per-report absolute totals. + */ +function getYourSpendSnapshotReportsMoveUpdates({ + reportItems, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotReportsMoveUpdatesParams): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (reportItems.length === 0) { + return result; + } + + const {paidPolicies, snapshotSearches} = context; + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); + + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + const approvalSnapshotSearch = getSnapshotSearchResults(snapshotSearches, approvalQueryJSON?.hash); + // An empty section's snapshot has no currency yet, so fall back to the first in-scope report's currency. + const approvalTargetCurrency = + approvalSnapshotSearch?.currency ?? reportItems.find(({iouReport}) => reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs))?.iouReport?.currency; + if (approvalSnapshotSearch && approvalTargetCurrency) { + let diff = 0; + let countDiff = 0; + const dataUpdates: YourSpendSnapshotOnyxData[] = []; + for (const {iouReport, reportTransactions, fromStatus, toStatus} of reportItems) { + if (!iouReport || !reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs)) { + continue; + } + const aggregate = getReportReimbursableTotal(paidPolicies, iouReport, reportTransactions, false, approvalTargetCurrency); + if (aggregate === null) { + continue; + } + const enters = isAwaitingApprovalStatus(toStatus); + const leaves = isAwaitingApprovalStatus(fromStatus); + const reportDiff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); + const reportCountDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); + if (reportDiff === 0 && reportCountDiff === 0) { + continue; + } + diff += reportDiff; + countDiff += reportCountDiff; + dataUpdates.push(buildSnapshotDataUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, aggregate.transactions, enters, leaves)); + } + if (diff !== 0 || countDiff !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, diff, approvalTargetCurrency, countDiff)); + } + // Data updates touch disjoint transaction keys, so per-report merges don't clobber each other the way absolute totals do. + for (const dataUpdate of dataUpdates) { + mergeYourSpendSnapshotOnyxData(result, dataUpdate); + } + } + + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + const paymentSnapshotSearch = getSnapshotSearchResults(snapshotSearches, paymentQueryJSON?.hash); + const paymentTargetCurrency = paymentSnapshotSearch?.currency ?? reportItems.find(({iouReport}) => reportInRepaidScope(iouReport, currentUserAccountID))?.iouReport?.currency; + if (paymentSnapshotSearch && paymentTargetCurrency) { + let diff = 0; + let countDiff = 0; + const dataUpdates: YourSpendSnapshotOnyxData[] = []; + for (const {iouReport, reportTransactions, fromStatus, toStatus} of reportItems) { + if (!iouReport || !reportInRepaidScope(iouReport, currentUserAccountID)) { + continue; + } + const aggregate = getReportReimbursableTotal(paidPolicies, iouReport, reportTransactions, true, paymentTargetCurrency); + if (aggregate === null) { + continue; + } + const enters = isRepaidStatus(toStatus); + const leaves = isRepaidStatus(fromStatus); + const reportDiff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); + const reportCountDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); + if (reportDiff === 0 && reportCountDiff === 0) { + continue; + } + diff += reportDiff; + countDiff += reportCountDiff; + dataUpdates.push(buildSnapshotDataUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, aggregate.transactions, enters, leaves)); + } + if (diff !== 0 || countDiff !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, diff, paymentTargetCurrency, countDiff)); + } + for (const dataUpdate of dataUpdates) { + mergeYourSpendSnapshotOnyxData(result, dataUpdate); + } + } + + return result; +} + +/** Optimistically patches Your spend snapshot aggregates when a report moves between states (e.g. submit, retract, reject, unapprove, cancel payment). */ +function getYourSpendSnapshotReportMoveUpdates({ + iouReport, + reportTransactions, + fromStatus, + toStatus, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotReportMoveUpdatesParams): YourSpendSnapshotOnyxData { + return getYourSpendSnapshotReportsMoveUpdates({reportItems: [{iouReport, reportTransactions, fromStatus, toStatus}], currentUserAccountID, context}); +} + +/** A transaction paired with the IOU report it belongs to, used when patching several expenses in one action. */ +type YourSpendTransactionItem = { + transaction: OnyxEntry; + iouReport: OnyxEntry; +}; + +type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { + transaction: OnyxEntry; + iouReport: OnyxEntry; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + +type GetYourSpendSnapshotTransactionsRemovalUpdatesParams = { + transactionItems: YourSpendTransactionItem[]; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + +/** + * Optimistically patches Your spend snapshot aggregates when transactions enter or leave the reimbursable-only sections. + * `enters` adds the transactions' amounts/counts (and their `data` rows); otherwise it subtracts them. + * All transactions are aggregated into a single update per snapshot, so each update sees the full batch diff + * instead of the last-write-wins result of stacking per-transaction absolute totals. + */ +function getYourSpendSnapshotTransactionsMembershipUpdates( + context: YourSpendPatchData, + transactionItems: YourSpendTransactionItem[], + currentUserAccountID: number, + enters: boolean, +): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (transactionItems.length === 0) { + return result; + } + + const {paidPolicies, snapshotSearches} = context; + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); + const sign = enters ? 1 : -1; + + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + const approvalSnapshotCurrency = getSnapshotSearchResults(snapshotSearches, approvalQueryJSON?.hash)?.currency; + if (approvalSnapshotCurrency) { + let totalDiff = 0; + const matchingTransactions: Transaction[] = []; + for (const {transaction, iouReport} of transactionItems) { + if (!transaction || !transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { + continue; + } + const amount = getReimbursableTransactionAmountInCurrency(paidPolicies, transaction, iouReport, approvalSnapshotCurrency); + if (amount === null) { + continue; + } + totalDiff += amount; + matchingTransactions.push(transaction); + } + if (matchingTransactions.length > 0) { + mergeYourSpendSnapshotOnyxData( + result, + buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, sign * totalDiff, approvalSnapshotCurrency, sign * matchingTransactions.length), + ); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, matchingTransactions, enters, !enters)); + } + } + + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + const paymentSnapshotCurrency = getSnapshotSearchResults(snapshotSearches, paymentQueryJSON?.hash)?.currency; + if (paymentSnapshotCurrency) { + let totalDiff = 0; + const matchingTransactions: Transaction[] = []; + for (const {transaction, iouReport} of transactionItems) { + if (!transaction || !transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { + continue; + } + const amount = getReimbursableTransactionAmountInCurrency(paidPolicies, transaction, iouReport, paymentSnapshotCurrency); + if (amount === null) { + continue; + } + totalDiff += amount; + matchingTransactions.push(transaction); + } + if (matchingTransactions.length > 0) { + mergeYourSpendSnapshotOnyxData( + result, + buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, sign * totalDiff, paymentSnapshotCurrency, sign * matchingTransactions.length), + ); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, matchingTransactions, enters, !enters)); + } + } + + return result; +} + +/** Optimistically patches Your spend snapshot aggregates when several transactions leave their reports in one action (e.g. bulk delete or reject). */ +function getYourSpendSnapshotTransactionsRemovalUpdates({ + transactionItems, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotTransactionsRemovalUpdatesParams): YourSpendSnapshotOnyxData { + return getYourSpendSnapshotTransactionsMembershipUpdates(context, transactionItems, currentUserAccountID, false); +} + +/** Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or reject). */ +function getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { + return getYourSpendSnapshotTransactionsMembershipUpdates(context, [{transaction, iouReport}], currentUserAccountID, false); +} + +type GetYourSpendSnapshotReimbursableUpdatesParams = { + transaction: OnyxEntry; + updatedTransaction: OnyxEntry; + iouReport: OnyxEntry; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + +/** + * Optimistically patches Your spend snapshot aggregates when an expense's reimbursable flag is toggled. + * Your spend counts reimbursable expenses only, so flipping to non-reimbursable removes it from the totals, and flipping back adds it. + */ +function getYourSpendSnapshotReimbursableUpdates({ + transaction, + updatedTransaction, + iouReport, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotReimbursableUpdatesParams): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (!transaction || !updatedTransaction || !iouReport) { + return result; + } + + const wasReimbursable = transaction.reimbursable !== false; + const willBeReimbursable = updatedTransaction.reimbursable !== false; + if (wasReimbursable === willBeReimbursable) { + return result; + } + + // Match on the transaction that carries the reimbursable state relevant to the section (the reimbursable one), since the scope queries skip non-reimbursable transactions. + const membershipTransaction = willBeReimbursable ? updatedTransaction : transaction; + return getYourSpendSnapshotTransactionsMembershipUpdates(context, [{transaction: membershipTransaction, iouReport}], currentUserAccountID, willBeReimbursable); +} + +/** Optimistically patches Your spend snapshot aggregates when a transaction amount changes. */ +function getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotTotalUpdatesParams): YourSpendSnapshotOnyxData { + const emptyResult: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (!transaction || !updatedTransaction || !iouReport) { + return emptyResult; + } + + const diff = calculateYourSpendTotalDiff(iouReport, updatedTransaction, transaction); + if (diff === null || diff === 0) { + return emptyResult; + } + + const {paidPolicies, snapshotSearches} = context; + const currency = getCurrency(updatedTransaction); + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + + if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, diff, currency)); + } + + if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, diff, currency)); + } + + return result; +} + +type GetYourSpendSnapshotSplitUpdatesParams = { + iouReport: OnyxEntry; + originalTransaction: OnyxEntry; + // Change to the reimbursable total in the report currency, signed with the snapshot convention (spend negative). + reimbursableDiff: number; + // Signed change to the number of reimbursable transactions in the report; drives row visibility in lockstep with the total. + reimbursableCountDiff: number; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + +/** Optimistically patches the "Awaiting approval" snapshot when a SUBMITTED expense is split (same-currency only). */ +function getYourSpendSnapshotSplitUpdates({ + iouReport, + originalTransaction, + reimbursableDiff, + reimbursableCountDiff, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotSplitUpdatesParams): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (!iouReport || !originalTransaction || (reimbursableDiff === 0 && reimbursableCountDiff === 0)) { + return result; + } + + const {paidPolicies, snapshotSearches} = context; + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); + if (!transactionMatchesAwaitingApprovalQuery(iouReport, originalTransaction, currentUserAccountID, paidGroupPolicyIDs)) { + return result; + } + + const currency = getCurrency(originalTransaction); + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, reimbursableDiff, currency, reimbursableCountDiff)); + return result; +} + +export { + getYourSpendSnapshotReimbursableUpdates, + getYourSpendSnapshotReportMoveUpdates, + getYourSpendSnapshotReportsMoveUpdates, + getYourSpendSnapshotSplitUpdates, + getYourSpendSnapshotTotalUpdates, + getYourSpendSnapshotTransactionRemovalUpdates, + getYourSpendSnapshotTransactionsRemovalUpdates, + transactionMatchesAwaitingApprovalQuery, +}; +export type {YourSpendReportMoveItem, YourSpendSnapshotOnyxData}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1ba88e362a12..1b8296387d19 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -56,6 +56,7 @@ import type {SearchKey} from '@libs/SearchUIUtils'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {hasOnlyPendingCardTransactions} from '@libs/TransactionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -91,11 +92,13 @@ import Onyx from 'react-native-onyx'; import type {AdditionalPayOnyxData} from './IOU/PayMoneyRequest'; import type {RejectMoneyRequestData} from './IOU/RejectMoneyRequest'; +import type {YourSpendReportMoveItem, YourSpendSnapshotOnyxData} from './IOU/YourSpendSnapshotUpdate'; import {getAllTransactionViolations} from './IOU'; import {payMoneyRequest} from './IOU/PayMoneyRequest'; import {prepareRejectMoneyRequestData, rejectMoneyRequest} from './IOU/RejectMoneyRequest'; -import {approveMoneyRequest} from './IOU/ReportWorkflow'; +import {approveMoneyRequest, getSubmitYourSpendReportMoveItem} from './IOU/ReportWorkflow'; +import {getYourSpendSnapshotReportsMoveUpdates, getYourSpendSnapshotTransactionsRemovalUpdates} from './IOU/YourSpendSnapshotUpdate'; import {isCurrencySupportedForGlobalReimbursement} from './Policy/Policy'; import {setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; @@ -226,6 +229,7 @@ type HandleActionButtonPressParams = { chatReportActions: OnyxEntry; delegateEmail?: string; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; }; function handleActionButtonPress({ @@ -263,6 +267,7 @@ function handleActionButtonPress({ chatReportActions, delegateEmail, isTrackIntentUser, + yourSpendPatchData, }: HandleActionButtonPressParams) { // The transactionIDList is needed to handle actions taken on `status:""` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. @@ -314,6 +319,7 @@ function handleActionButtonPress({ searchData, chatReportActions, isTrackIntentUser, + yourSpendPatchData, }); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: @@ -345,6 +351,7 @@ function handleActionButtonPress({ iouReportCurrentNextStepDeprecated, delegateEmail, isTrackIntentUser, + yourSpendPatchData, ownerLogin: submitterLogin, }); return; @@ -361,15 +368,29 @@ function handleActionButtonPress({ return; } const policyForSubmit = policy ?? snapshotPolicy; + const submitYourSpendSnapshotUpdates = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: [getSubmitYourSpendReportMoveItem(snapshotReport, policyForSubmit)].filter((moveItem): moveItem is YourSpendReportMoveItem => !!moveItem), + currentUserAccountID, + context: yourSpendPatchData, + }); if (isSubmitPolicy(policyForSubmit) && openReportSubmitToPopover) { openReportSubmitToPopover({ onSubmitWithManagerEmail: (managerEmail, managerAccountID) => { - submitMoneyRequestOnSearch(hash, [snapshotReport], [policyForSubmit], submitterLogin, currentSearchKey, managerEmail, managerAccountID); + submitMoneyRequestOnSearch( + hash, + [snapshotReport], + [policyForSubmit], + submitterLogin, + currentSearchKey, + managerEmail, + managerAccountID, + submitYourSpendSnapshotUpdates, + ); }, }); return; } - submitMoneyRequestOnSearch(hash, [snapshotReport], [policyForSubmit], submitterLogin, currentSearchKey); + submitMoneyRequestOnSearch(hash, [snapshotReport], [policyForSubmit], submitterLogin, currentSearchKey, undefined, undefined, submitYourSpendSnapshotUpdates); return; } case CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING: { @@ -520,6 +541,7 @@ type GetPayActionCallbackParams = { searchData?: SearchResultDataType; chatReportActions: OnyxEntry; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; }; function getPayActionCallback({ @@ -547,6 +569,7 @@ function getPayActionCallback({ searchData, chatReportActions, isTrackIntentUser, + yourSpendPatchData, }: GetPayActionCallbackParams) { const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, personalPolicyID, lastPaymentMethod, getReportType(item.reportID)); @@ -595,6 +618,7 @@ function getPayActionCallback({ additionalOnyxData: getSearchPayOnyxData(hash, item.reportID, currentSearchKey), chatReportActions, isTrackIntentUser, + yourSpendPatchData, }); } @@ -614,6 +638,7 @@ type GetApproveActionCallbackParams = { iouReportCurrentNextStepDeprecated?: OnyxEntry; delegateEmail?: string; isTrackIntentUser: boolean | undefined; + yourSpendPatchData?: YourSpendPatchData; ownerLogin: string | undefined; }; @@ -633,6 +658,7 @@ function getApproveActionCallback({ iouReportCurrentNextStepDeprecated, delegateEmail, isTrackIntentUser, + yourSpendPatchData, ownerLogin, }: GetApproveActionCallbackParams) { if (!item.reportID) { @@ -661,6 +687,7 @@ function getApproveActionCallback({ full: true, additionalOnyxData: getSearchApproveOnyxData(hash, item.reportID, currentSearchKey), isTrackIntentUser, + yourSpendPatchData, }); } @@ -1040,15 +1067,17 @@ function submitMoneyRequestOnSearch( currentSearchKey?: SearchKey, managerEmail?: string, managerAccountID?: number, + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData, ) { const firstReport = (reportList.at(0) ?? {}) as Report; const firstPolicy = policy.at(0); - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, value: Object.fromEntries(reportList.map((report) => [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.reportID}`, {isActionLoading: true}])), }, + ...(yourSpendSnapshotUpdates?.optimisticData ?? []), ]; const successData: Array> = [ @@ -1057,6 +1086,7 @@ function submitMoneyRequestOnSearch( key: ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, value: Object.fromEntries(reportList.map((report) => [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.reportID}`, {isActionLoading: false}])), }, + ...(yourSpendSnapshotUpdates?.successData ?? []), ]; // If we are on the 'Submit' suggested search, remove the report from the view once the action is taken, don't wait for the view to be re-fetched via Search @@ -1070,7 +1100,7 @@ function submitMoneyRequestOnSearch( }); } - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, @@ -1088,6 +1118,7 @@ function submitMoneyRequestOnSearch( ]), ), }, + ...(yourSpendSnapshotUpdates?.failureData ?? []), ]; const trimmedManagerEmail = managerEmail?.trim(); @@ -1229,10 +1260,11 @@ function rejectMoneyRequestInBulk( currentUserLogin: string, betas: OnyxEntry, hash?: number, + yourSpendSnapshotUpdates?: YourSpendSnapshotOnyxData, ) { const optimisticData: Array> = []; const finallyData: Array> = []; - const successData: RejectMoneyRequestData['successData'] = []; + const successData: Array> = []; const failureData: RejectMoneyRequestData['failureData'] = []; const loadingData = hash !== undefined ? getOnyxLoadingData(hash) : {optimisticData: undefined, finallyData: undefined}; @@ -1247,7 +1279,7 @@ function rejectMoneyRequestInBulk( } > = {}; for (const transactionID of transactionIDs) { - const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, undefined, true); + const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, undefined, true, undefined); if (data) { optimisticData.push(...data.optimisticData); successData.push(...data.successData); @@ -1259,6 +1291,11 @@ function rejectMoneyRequestInBulk( } } + // The Your spend patch is aggregated over the whole batch; per-transaction updates would each write an absolute total from the same base, so only the last one would stick. + optimisticData.push(...(yourSpendSnapshotUpdates?.optimisticData ?? [])); + successData.push(...(yourSpendSnapshotUpdates?.successData ?? [])); + failureData.push(...(yourSpendSnapshotUpdates?.failureData ?? [])); + write( WRITE_COMMANDS.REJECT_MONEY_REQUEST_IN_BULK, { @@ -1284,6 +1321,7 @@ function rejectMoneyRequestsOnSearch( currentUserAccountIDParam: number, currentUserLogin: string, betas: OnyxEntry, + yourSpendPatchData?: YourSpendPatchData, ) { const transactionIDs = Object.keys(selectedTransactions); @@ -1305,6 +1343,21 @@ function rejectMoneyRequestsOnSearch( const isSingleReport = Object.keys(transactionsByReport).length === 1; let urlToNavigateBack; + + // One aggregated Your spend update for the whole selection (across reports); per-transaction or per-report updates + // would each write an absolute total from the same base, so only the last one would stick. + let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotTransactionsRemovalUpdates({ + transactionItems: Object.entries(transactionsByReport).flatMap(([reportID, selectedTransactionIDs]) => { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const selectedTransactionIDsSet = new Set(selectedTransactionIDs); + return getReportTransactions(reportID) + .filter((transaction) => selectedTransactionIDsSet.has(transaction.transactionID)) + .map((transaction) => ({transaction, iouReport: report})); + }), + currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, + }); + for (const [reportID, selectedTransactionIDs] of Object.entries(transactionsByReport)) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const totalReportTransactions = report?.transactionCount ?? 0; @@ -1315,8 +1368,11 @@ function rejectMoneyRequestsOnSearch( const areAllExpensesSelected = selectedTransactionIDs.length === effectiveTransactionCount; const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const isPolicyDelayedSubmissionEnabled = policy ? isDelayedSubmissionEnabled(policy) : false; + if (isPolicyDelayedSubmissionEnabled && areAllExpensesSelected) { - rejectMoneyRequestInBulk(reportID, comment, policy, selectedTransactionIDs, currentUserAccountIDParam, currentUserLogin, betas, hash); + rejectMoneyRequestInBulk(reportID, comment, policy, selectedTransactionIDs, currentUserAccountIDParam, currentUserLogin, betas, hash, pendingYourSpendSnapshotUpdates); + // The whole batch rides on the first request; attaching it to every request would re-apply the same absolute totals. + pendingYourSpendSnapshotUpdates = undefined; } else { // Share a single destination ID across all rejections from the same source report const sharedRejectedToReportID = generateReportID(); @@ -1329,7 +1385,10 @@ function rejectMoneyRequestsOnSearch( sharedRejectedToReportID, existingRejectedReport, setExistingRejectedReport, + yourSpendSnapshotUpdates: pendingYourSpendSnapshotUpdates, }); + // The whole batch rides on the first request; attaching it to every request would re-apply the same absolute totals. + pendingYourSpendSnapshotUpdates = undefined; } } if (isSingleReport && areAllExpensesSelected && !isPolicyDelayedSubmissionEnabled) { diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index f01dd72dd81f..73c51fe9a612 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -26,6 +26,7 @@ import { isPerDiemRequest, isScanning, } from '@libs/TransactionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -200,6 +201,7 @@ type GetIouParamsInput = { type TransactionInlineEditParams = GetIouParamsInput & { hash: number | undefined; isOffline: boolean; + yourSpendPatchData?: YourSpendPatchData; }; /** @@ -384,6 +386,7 @@ function editTransactionAmountInline(params: TransactionInlineEditParams, newAmo transactionViolations: allTransactionViolations, policyRecentlyUsedCurrencies: [], hash: params.hash, + yourSpendPatchData: params.yourSpendPatchData, }); } diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index a80118c36081..b9d3ec716578 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -15,6 +15,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; import {SUPER_WIDE_RIGHT_MODALS} from '@components/WideRHPContextProvider/WIDE_RIGHT_MODALS'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useActivePolicy from '@hooks/useActivePolicy'; import useAncestors from '@hooks/useAncestors'; @@ -349,6 +350,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report reportActions: requestParentReportAction ? [requestParentReportAction] : [], policy, }); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const isCardTransactionCanBeDeleted = canDeleteCardTransactionByLiabilityType(iouTransaction); const shouldShowDeleteButton = shouldShowTaskDeleteButton || (canDeleteRequest && isCardTransactionCanBeDeleted) || isDemoTransaction(iouTransaction); const shouldShowEditSplitOnDeleteAction = iouTransactionID ? shouldOpenSplitExpenseEditFlowOnDelete([iouTransactionID]) : false; @@ -972,6 +974,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', policy: iouPolicy, + yourSpendPatchData: getYourSpendPatchData(), }); } else if (iouTransactionID) { const deleteResult = deleteTransactions([iouTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined, isSingleTransactionView); @@ -1009,6 +1012,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report deleteTransactions, removeTransaction, iouPolicy, + getYourSpendPatchData, ]); // Where to navigate back to after deleting the transaction and its report. diff --git a/src/pages/RejectExpenseReportPage.tsx b/src/pages/RejectExpenseReportPage.tsx index 2b06683a3033..34949da6d8fd 100644 --- a/src/pages/RejectExpenseReportPage.tsx +++ b/src/pages/RejectExpenseReportPage.tsx @@ -9,6 +9,7 @@ import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -80,6 +81,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [lastForwardedActorAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`, {selector: lastForwardedActorAccountIDSelector}); @@ -167,6 +169,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { currentUserPersonalDetails?.displayName, currentUserPersonalDetails?.avatar, isTrackIntentUser, + getYourSpendPatchData(), ); Navigation.goBack(); }; diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 07a5bc8c9509..3fdab6f979db 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -5,6 +5,7 @@ import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -101,6 +102,7 @@ function ReportSubmitToContent({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); @@ -306,6 +308,7 @@ function ReportSubmitToContent({ ownerBillingGracePeriodEnd, delegateEmail, submitterLogin, + yourSpendPatchData: getYourSpendPatchData(), managerEmail: trimmed, managerAccountID: resolvedManagerAccountID, isTrackIntentUser, @@ -343,6 +346,7 @@ function ReportSubmitToContent({ ownerBillingGracePeriodEnd, delegateEmail, submitterLogin, + getYourSpendPatchData, currentSearchQueryJSON, isOffline, currentSearchKey, diff --git a/src/pages/Search/SearchRejectReasonPage.tsx b/src/pages/Search/SearchRejectReasonPage.tsx index 7fb51cb1c7e5..eb7a504d649c 100644 --- a/src/pages/Search/SearchRejectReasonPage.tsx +++ b/src/pages/Search/SearchRejectReasonPage.tsx @@ -1,6 +1,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -38,6 +39,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); // When coming from the report view, selectedTransactions is empty, build it from selectedTransactionIDs const selectedTransactionsForReject = useMemo(() => { if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS && reportID) { @@ -67,6 +69,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { currentUserAccountID, currentUserLogin ?? '', betas, + getYourSpendPatchData(), ); if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS) { clearSelectedTransactions(true); @@ -90,6 +93,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { route.name, showDelegateNoAccessModal, clearSelectedTransactions, + getYourSpendPatchData, ], ); diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 483d0d7fd456..4d75db19240f 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -5,8 +5,10 @@ import useOnyx from '@hooks/useOnyx'; import {search} from '@libs/actions/Search'; import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards, isPersonalCard, lastFourNumbersFromCardName} from '@libs/CardUtils'; +// eslint-disable-next-line no-restricted-imports -- Your Spend scopes approval/payment workflows to paid (Collect/Control) group policies, so this is a genuine billing/paid-only check. import {arePaymentsEnabled, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -21,7 +23,6 @@ import {useIsFocused} from '@react-navigation/native'; import {useEffect, useEffectEvent, useMemo, useState} from 'react'; import {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE} from './const'; -import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from './queries'; type YourSpendRowState = ValueOf; type YourSpendCardKind = ValueOf; @@ -117,6 +118,19 @@ function getOutstandingReportsSignature(reports: OnyxCollection | undefi return ids.sort().join(','); } +function getRepaidReportsSignature(reports: OnyxCollection | undefined, accountID: number): string { + if (!reports) { + return ''; + } + const ids: string[] = []; + for (const report of Object.values(reports)) { + if (report?.ownerAccountID === accountID && (report.stateNum ?? 0) >= CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED) { + ids.push(report.reportID); + } + } + return ids.sort().join(','); +} + function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourSpendRowStateParams): YourSpendRowState { if (!isApplicable) { return YOUR_SPEND_ROW_STATE.HIDDEN; @@ -162,6 +176,10 @@ function useYourSpendData(): UseYourSpendDataReturn { selector: (reports) => getOutstandingReportsSignature(reports, paidGroupPolicyIDs, accountID), }); + const [repaidReportsSignature] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: (reports) => getRepaidReportsSignature(reports, accountID), + }); + // Destructure here so downstream memos depend only on the sub-records, not on // the parent value that's rebuilt on every CARD_FEED_ERRORS tick. const {cardsWithBrokenFeedConnection, personalCardsWithBrokenConnection} = useCardFeedErrors(); @@ -359,7 +377,14 @@ function useYourSpendData(): UseYourSpendDataReturn { cachedApprovalReady !== null && cachedApprovalHash === approvalHash && outstandingReportsSignature !== ''; - const shouldUseCachedPayment = paymentRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && paymentCountIsMissing && paymentSearchResults !== undefined && cachedPaymentReady !== null; + // Only bridge a wiped/missing count with the cached total while the user still owns a REPAID report, + // so the row hides immediately after cancelling the payment of the last repaid expense. + const shouldUseCachedPayment = + paymentRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && + paymentCountIsMissing && + paymentSearchResults !== undefined && + cachedPaymentReady !== null && + repaidReportsSignature !== ''; const approvalRowState = shouldUseCachedApproval ? YOUR_SPEND_ROW_STATE.READY : approvalRowStateRaw; const paymentRowState = shouldUseCachedPayment ? YOUR_SPEND_ROW_STATE.READY : paymentRowStateRaw; @@ -431,5 +456,5 @@ function useYourSpendData(): UseYourSpendDataReturn { }; } -export {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE, getOutstandingReportsSignature, getYourSpendApplicability, getYourSpendRowState, useYourSpendData}; +export {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE, getOutstandingReportsSignature, getRepaidReportsSignature, getYourSpendApplicability, getYourSpendRowState, useYourSpendData}; export type {GetYourSpendRowStateParams, UseYourSpendDataReturn, YourSpendApplicability, YourSpendCardKind, YourSpendCardRow, YourSpendRowState, YourSpendRowTotals}; diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index de07a0bf5141..366326c073b0 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -2,6 +2,7 @@ import {Actions, useActionSheetAwareScrollViewActions} from '@components/ActionS import ConfirmModal from '@components/ConfirmModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import {useSearchQueryContext} from '@components/Search/SearchContext'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -359,6 +360,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro reportActions: reportActionRef.current ? [reportActionRef.current] : [], policy, }); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getOriginalReportID(reportIDRef.current, reportActionRef.current, reportActions)}`); const ancestorsRef = useRef([]); @@ -393,6 +395,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro currentUserAccountID, currentUserEmail: email ?? '', policy: iouPolicy, + yourSpendPatchData: getYourSpendPatchData(), }); } else if (originalMessage?.IOUTransactionID) { const deleteResult = deleteTransactions([originalMessage.IOUTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined); @@ -457,6 +460,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro iouTransaction, iouOriginalTransaction, iouPolicy, + getYourSpendPatchData, ]); const hideDeleteModal = () => { diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index d2e3b0c35076..1db6aa61998b 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -2,6 +2,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; import {useWideRHPState} from '@components/WideRHPContextProvider'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -40,6 +41,7 @@ function RejectReasonPage({route}: RejectReasonPageProps) { const policy = usePolicy(reportPolicyID); const {superWideRHPRouteKeys} = useWideRHPState(); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [betas] = useOnyx(ONYXKEYS.BETAS); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -49,7 +51,9 @@ function RejectReasonPage({route}: RejectReasonPageProps) { return; } - const urlToNavigateBack = rejectMoneyRequest(transactionID, reportID, values.comment, policy, currentUserAccountID, currentUserLogin ?? '', betas); + const urlToNavigateBack = rejectMoneyRequest(transactionID, reportID, values.comment, policy, currentUserAccountID, currentUserLogin ?? '', betas, { + yourSpendPatchData: getYourSpendPatchData(), + }); removeTransaction(transactionID); // If the super wide rhp is not opened, dismiss the entire modal. if (superWideRHPRouteKeys.length > 0) { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 9e017c2a4737..1c89cf0e5fe4 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -10,6 +10,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SplitListItemType} from '@components/SelectionList/ListItem/types'; import TabSelector from '@components/TabSelector/TabSelector'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useAllTransactions from '@hooks/useAllTransactions'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -125,6 +126,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [expenseReportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(expenseReport?.policyID)}`); const allTransactions = useAllTransactions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`]; const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`]; @@ -387,6 +389,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { isOffline, delegateAccountID, isTrackIntentUser, + yourSpendPatchData: getYourSpendPatchData(), }); }; diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 8eaf5ecae913..a1080218cf20 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -1,5 +1,6 @@ import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -74,6 +75,7 @@ function IOURequestStepAmount({ const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const textInput = useRef(null); const amountFormRef = useRef(null); @@ -236,6 +238,7 @@ function IOURequestStepAmount({ paymentMethod, isTrackIntentUser, policyTags, + yourSpendPatchData: getYourSpendPatchData(), reportPolicyTags, ...submitData, }); diff --git a/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts b/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts new file mode 100644 index 000000000000..2c92267f426c --- /dev/null +++ b/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts @@ -0,0 +1,100 @@ +import {getReimbursableSplitDiff} from '@libs/actions/IOU/SplitTransactionUpdate'; +import type {SplitTransactionSplitsParam} from '@libs/API/parameters'; + +import type {Transaction} from '@src/types/onyx'; + +import createRandomTransaction from '../../utils/collections/transaction'; + +let transactionIndex = 0; + +function buildSplit(amount: number, reimbursable?: boolean): SplitTransactionSplitsParam[number] { + return {transactionID: `split-${amount}-${String(reimbursable)}`, amount, created: '2026-01-01', reimbursable}; +} + +function buildChildTransaction(amount: number, reimbursable?: boolean): Transaction { + transactionIndex += 1; + return {...createRandomTransaction(transactionIndex), amount, reimbursable}; +} + +describe('getReimbursableSplitDiff', () => { + describe('creation of splits', () => { + it('excludes the non-reimbursable portion when a reimbursable expense is split into reimbursable + non-reimbursable parts', () => { + // Given a $100 reimbursable expense split into $60 reimbursable + $40 non-reimbursable + const diff = getReimbursableSplitDiff({ + splits: [buildSplit(6000, true), buildSplit(4000, false)], + originalTransaction: buildChildTransaction(-10000, true), + originalChildTransactions: [], + splitExpensesTotal: 10000, + isCreationOfSplits: true, + }); + + // Then the (negative) snapshot total moves toward zero by the $40 that became non-reimbursable + expect(diff).toBe(4000); + }); + + it('nets to 0 when the whole reimbursable expense stays reimbursable', () => { + const diff = getReimbursableSplitDiff({ + splits: [buildSplit(6000, true), buildSplit(4000, true)], + originalTransaction: buildChildTransaction(-10000, true), + originalChildTransactions: [], + splitExpensesTotal: 10000, + isCreationOfSplits: true, + }); + + expect(diff).toBe(0); + }); + + it('treats a split with an undefined reimbursable flag as reimbursable', () => { + const diff = getReimbursableSplitDiff({ + splits: [buildSplit(6000), buildSplit(4000, false)], + originalTransaction: buildChildTransaction(-10000, true), + originalChildTransactions: [], + splitExpensesTotal: 10000, + isCreationOfSplits: true, + }); + + expect(diff).toBe(4000); + }); + + it('adds only the reimbursable portion when the original expense was non-reimbursable', () => { + const diff = getReimbursableSplitDiff({ + splits: [buildSplit(6000, true), buildSplit(4000, false)], + originalTransaction: buildChildTransaction(-10000, false), + originalChildTransactions: [], + splitExpensesTotal: 10000, + isCreationOfSplits: true, + }); + + // $60 of new reimbursable spend pushes the (negative) snapshot total further from zero + expect(diff).toBe(-6000); + }); + }); + + describe('editing existing splits', () => { + it('compares reimbursable totals using existing child transactions (magnitude)', () => { + // Given two reimbursable $50 children, re-split so one becomes non-reimbursable + const diff = getReimbursableSplitDiff({ + splits: [buildSplit(5000, true), buildSplit(5000, false)], + originalTransaction: buildChildTransaction(-10000, true), + originalChildTransactions: [buildChildTransaction(-5000, true), buildChildTransaction(-5000, true)], + splitExpensesTotal: 10000, + isCreationOfSplits: false, + }); + + // Then the (negative) snapshot total moves toward zero by the $50 that became non-reimbursable + expect(diff).toBe(5000); + }); + + it('nets to 0 when reimbursable totals are unchanged', () => { + const diff = getReimbursableSplitDiff({ + splits: [buildSplit(6000, true), buildSplit(4000, false)], + originalTransaction: buildChildTransaction(-10000, true), + originalChildTransactions: [buildChildTransaction(-6000, true), buildChildTransaction(-4000, false)], + splitExpensesTotal: 10000, + isCreationOfSplits: false, + }); + + expect(diff).toBe(0); + }); + }); +}); diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts new file mode 100644 index 000000000000..eb03f2d48082 --- /dev/null +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -0,0 +1,460 @@ +import {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotReportsMoveUpdates} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; +import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery} from '@libs/YourSpendQueryUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const ACCOUNT_ID = 42; +const OTHER_ACCOUNT_ID = 99; +const POLICY_ID = 'paidPolicy1'; +const EXPENSE_REPORT_ID = 'expenseReport1'; + +const paidPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, 'Team Workspace'), + id: POLICY_ID, + owner: 'owner@test.com', + role: CONST.POLICY.ROLE.ADMIN, + outputCurrency: CONST.CURRENCY.USD, +}; + +const OPEN_STATUS = {stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; +const SUBMITTED_STATUS = {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}; +const APPROVED_STATUS = {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED}; +const REIMBURSED_STATUS = {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED}; + +/** Builds a fully-typed snapshot collection key from a search query hash. */ +function getSnapshotKey(hash: number): `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}` { + return `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`; +} + +/** Builds a fully-typed `SearchResults` snapshot with the given total/currency/count. */ +function buildSnapshotSearchResults(total: number, currency: string, count = 1): SearchResults { + return { + search: { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hash: 0, + hasMoreResults: false, + hasResults: true, + isLoading: false, + count, + total, + currency, + }, + data: {}, + }; +} + +/** A date string a few days in the past so repaid-last-30-days matching includes the transaction. */ +function getRecentCreatedDate(): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 5); + return date.toISOString().slice(0, 10); +} + +function buildExpenseReport(overrides: Partial = {}): Report { + return { + reportID: EXPENSE_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: POLICY_ID, + ownerAccountID: ACCOUNT_ID, + currency: CONST.CURRENCY.USD, + total: -10000, + ...overrides, + } as Report; +} + +// Expense-report transactions are stored with the opposite sign, so a positive stored `amount` (e.g. 10000) is a +// spend that `getAmount`/the snapshot total represent as negative (-10000). Mirrors real Onyx data. +function buildTransaction(overrides: Partial = {}): Transaction { + return { + transactionID: 'reportMoveTxn', + reportID: EXPENSE_REPORT_ID, + amount: 10000, + currency: CONST.CURRENCY.USD, + reimbursable: true, + created: '2026-01-15', + merchant: 'Test Merchant', + ...overrides, + } as Transaction; +} + +const TRANSACTION_KEY = `${ONYXKEYS.COLLECTION.TRANSACTION}reportMoveTxn` as const; + +beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: ACCOUNT_ID, email: 'user@test.com'}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: 'user@test.com'}}, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); +}); + +beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); +}); + +/** + * Builds the snapshot context the builder now receives as a parameter (previously read via a module-level Onyx + * subscription), mirroring what `useYourSpendPatchData` supplies from the triggering component. + */ +function buildContext( + snapshotKey: `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`, + search: SearchResults['search'], + paidPolicies: OnyxCollection = {[`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: paidPolicy}, +): YourSpendPatchData { + return {paidPolicies, snapshotSearches: {[snapshotKey]: search}}; +} + +function seedAwaitingApprovalSnapshot(total: number, currency: string = CONST.CURRENCY.USD, count = 1) { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + const context = buildContext(snapshotKey, buildSnapshotSearchResults(total, currency, count).search); + return {snapshotKey, context}; +} + +function seedRepaidSnapshot(total: number, currency: string = CONST.CURRENCY.USD) { + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(ACCOUNT_ID)); + const snapshotKey = getSnapshotKey(paymentQueryJSON?.hash ?? 0); + const context = buildContext(snapshotKey, buildSnapshotSearchResults(total, currency).search); + return {snapshotKey, context}; +} + +describe('getYourSpendSnapshotReportMoveUpdates', () => { + it('adds the report total to awaiting approval when a report is submitted (OPEN -> SUBMITTED)', async () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-10000); + + const {optimisticData, failureData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + // Submitting adds the report's (negative) spend to the section total and injects the transaction into + // `data` so the linked Search page is not empty offline. + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -20000, count: 2, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: buildTransaction()}}, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -10000, count: 1, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: null}}, + }), + ]); + }); + + it('subtracts the report total from awaiting approval when a report is retracted (SUBMITTED -> OPEN)', async () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-30000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(OPEN_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: SUBMITTED_STATUS, + toStatus: OPEN_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + // Leaving the section removes the (negative) spend from the total and removes the transaction from `data`. + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: null}}, + }), + ]); + }); + + it('subtracts the report total from awaiting approval when a report is rejected (SUBMITTED -> OPEN)', async () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-30000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(OPEN_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: SUBMITTED_STATUS, + toStatus: OPEN_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: null}}, + }), + ]); + }); + + it('adds the report total back to awaiting approval when a report is unapproved (APPROVED -> SUBMITTED)', async () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: APPROVED_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -20000, count: 2, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: buildTransaction()}}, + }), + ]); + }); + + it('subtracts the report total from repaid when a payment is cancelled (REIMBURSED -> APPROVED)', async () => { + const {snapshotKey, context} = seedRepaidSnapshot(-30000); + const recentTransaction = buildTransaction({created: getRecentCreatedDate()}); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(APPROVED_STATUS), + reportTransactions: [recentTransaction], + fromStatus: REIMBURSED_STATUS, + toStatus: APPROVED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: null}}, + }), + ]); + }); + + it('does not patch when the report is not owned by the current user', async () => { + const {context} = seedAwaitingApprovalSnapshot(10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport({...SUBMITTED_STATUS, ownerAccountID: OTHER_ACCOUNT_ID}), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('does not patch when the report transactions are non-reimbursable', async () => { + const {context} = seedAwaitingApprovalSnapshot(10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction({reimbursable: false})], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('does not patch when the workspace is not a paid group policy', () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + // No paid-group policies in context, so the report is out of the awaiting-approval scope. + const context = buildContext(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD).search, {}); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('does not patch when the transaction cannot be converted into the snapshot currency', async () => { + const {context} = seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.EUR); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('patches using convertedAmount when the snapshot currency differs from the transaction currency', () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-10000); + const convertedTransaction = buildTransaction({currency: CONST.CURRENCY.EUR, amount: 10000, convertedAmount: 5000}); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport({...SUBMITTED_STATUS, currency: CONST.CURRENCY.EUR}), + reportTransactions: [convertedTransaction], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -15000, count: 2, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: convertedTransaction}}, + }), + ]); + }); + + it('does not patch with convertedAmount when the policy output currency differs from the snapshot currency', () => { + // Snapshot is in USD, but the report's policy outputs EUR, so the EUR-denominated convertedAmount + // cannot be safely added to the USD total. The patch is skipped and reconciled on the next online refresh. + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + const context = buildContext(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD).search, { + [`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: {...paidPolicy, outputCurrency: CONST.CURRENCY.EUR}, + }); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport({...SUBMITTED_STATUS, currency: CONST.CURRENCY.GBP}), + reportTransactions: [buildTransaction({currency: CONST.CURRENCY.GBP, amount: 10000, convertedAmount: 5000})], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('adds the report total to a previously empty awaiting approval bucket (count 0 -> visible)', () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(0, CONST.CURRENCY.USD, 0); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + context, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -10000, count: 1, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: buildTransaction()}}, + }), + ]); + }); +}); + +describe('getYourSpendSnapshotReportsMoveUpdates', () => { + it('aggregates a bulk report move into a single total update covering every report', () => { + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-30000, CONST.CURRENCY.USD, 3); + + const secondReportID = 'expenseReport2'; + const secondTransaction = buildTransaction({transactionID: 'reportMoveTxn2', reportID: secondReportID, amount: 5000}); + + // Approving both reports (SUBMITTED -> APPROVED) subtracts each report's spend from awaiting approval in one update: -300 -> -150. + const {optimisticData, failureData} = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: [ + {iouReport: buildExpenseReport(APPROVED_STATUS), reportTransactions: [buildTransaction()], fromStatus: SUBMITTED_STATUS, toStatus: APPROVED_STATUS}, + { + iouReport: buildExpenseReport({...APPROVED_STATUS, reportID: secondReportID}), + reportTransactions: [secondTransaction], + fromStatus: SUBMITTED_STATUS, + toStatus: APPROVED_STATUS, + }, + ], + currentUserAccountID: ACCOUNT_ID, + context, + }); + + const secondTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}reportMoveTxn2`; + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -15000, count: 1, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: null}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[secondTransactionKey]: null}}, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -30000, count: 3, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: buildTransaction()}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[secondTransactionKey]: secondTransaction}}, + }), + ]); + }); +}); diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts new file mode 100644 index 000000000000..b2b2d3632100 --- /dev/null +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -0,0 +1,489 @@ +import {getUpdateMoneyRequestParams} from '@libs/actions/IOU/UpdateMoneyRequest'; +import { + getYourSpendSnapshotReimbursableUpdates, + getYourSpendSnapshotTotalUpdates, + getYourSpendSnapshotTransactionRemovalUpdates, + getYourSpendSnapshotTransactionsRemovalUpdates, + transactionMatchesAwaitingApprovalQuery, +} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; +import {buildAwaitingApprovalQuery} from '@libs/YourSpendQueryUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const ACCOUNT_ID = 42; +const POLICY_ID = 'paidPolicy1'; +const TRANSACTION_ID = 'txn1'; +const EXPENSE_REPORT_ID = 'expenseReport1'; +const THREAD_REPORT_ID = 'thread1'; + +const paidPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, 'Team Workspace'), + id: POLICY_ID, + owner: 'owner@test.com', + role: CONST.POLICY.ROLE.ADMIN, + outputCurrency: CONST.CURRENCY.USD, +}; + +/** Builds a fully-typed snapshot collection key from a search query hash. */ +function getSnapshotKey(hash: number): `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}` { + return `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`; +} + +/** Builds a fully-typed `SearchResults` snapshot with the given total/currency. */ +function buildSnapshotSearchResults(total: number, currency: string): SearchResults { + return { + search: { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hash: 0, + hasMoreResults: false, + hasResults: true, + isLoading: false, + count: 1, + total, + currency, + }, + data: {}, + }; +} + +/** + * Builds the snapshot context the builders now receive as a parameter (previously read via a module-level Onyx + * subscription), mirroring what `useYourSpendPatchData` supplies from the triggering component. + */ +function buildYourSpendPatchData(snapshotKey: `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`, total: number, currency: string): YourSpendPatchData { + return { + paidPolicies: {[`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`]: paidPolicy}, + snapshotSearches: {[snapshotKey]: buildSnapshotSearchResults(total, currency).search}, + }; +} + +const expenseReport: Report = { + reportID: EXPENSE_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: POLICY_ID, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + currency: CONST.CURRENCY.USD, + total: -10000, +} as Report; + +const transactionThreadReport: Report = { + reportID: THREAD_REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + parentReportID: EXPENSE_REPORT_ID, +} as Report; + +// Expense-report transactions are stored with the opposite sign, so a positive stored `amount` (10000) is a spend +// that `getAmount`/the snapshot total represent as negative (-10000). Mirrors real Onyx data. +const transaction: Transaction = { + transactionID: TRANSACTION_ID, + reportID: EXPENSE_REPORT_ID, + amount: 10000, + currency: CONST.CURRENCY.USD, + reimbursable: true, + created: '2026-01-15', + merchant: 'Test Merchant', +} as Transaction; + +beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: ACCOUNT_ID, email: 'user@test.com'}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[ACCOUNT_ID]: {accountID: ACCOUNT_ID, login: 'user@test.com'}}, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); +}); + +beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); +}); + +describe('getYourSpendSnapshotTotalUpdates', () => { + it('patches the awaiting-approval snapshot total with the amount delta', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + expect(transactionMatchesAwaitingApprovalQuery(expenseReport, transaction, ACCOUNT_ID, [POLICY_ID])).toBe(true); + + const updatedTransaction: Transaction = { + ...transaction, + modifiedAmount: 20000, + }; + + // Raising the spend from 100 to 200 makes the (negative) section total more negative: -100 -> -200. + const {optimisticData, failureData} = getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: { + search: { + total: -20000, + count: 1, + currency: CONST.CURRENCY.USD, + }, + }, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: { + search: { + total: -10000, + count: 1, + currency: CONST.CURRENCY.USD, + }, + }, + }), + ]); + }); + + it('does not patch snapshots when the currency changes to a different report currency', () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + Onyx.set(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD)); + + const updatedTransaction: Transaction = { + ...transaction, + amount: -20000, + currency: CONST.CURRENCY.EUR, + }; + + const {optimisticData} = getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, 10000, CONST.CURRENCY.USD), + }); + + expect(optimisticData).toHaveLength(0); + }); +}); + +describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { + it('subtracts the removed transaction amount from the awaiting-approval snapshot total', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-30000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + // Removing a 100 spend pulls the (negative) section total toward zero: -300 -> -200. + const {optimisticData, failureData} = getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -30000, CONST.CURRENCY.USD), + }); + + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + // The removed transaction is also dropped from the snapshot data so the Search page stays in sync offline. + expect.objectContaining({ + key: snapshotKey, + value: {data: {[transactionKey]: null}}, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -30000, count: 1, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[transactionKey]: transaction}}, + }), + ]); + }); + + it('does not patch when the removed transaction is non-reimbursable', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(30000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const {optimisticData} = getYourSpendSnapshotTransactionRemovalUpdates({ + transaction: {...transaction, reimbursable: false}, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, 30000, CONST.CURRENCY.USD), + }); + + expect(optimisticData).toHaveLength(0); + }); +}); + +describe('getYourSpendSnapshotTransactionsRemovalUpdates', () => { + it('aggregates a bulk removal into a single snapshot update covering every transaction', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-30000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const secondTransaction: Transaction = {...transaction, transactionID: 'txn2', amount: 5000}; + // A non-reimbursable transaction must not contribute to the batch total. + const nonReimbursableTransaction: Transaction = {...transaction, transactionID: 'txn3', reimbursable: false}; + + // Removing a 100 and a 50 spend pulls the (negative) section total toward zero: -300 -> -150. + const {optimisticData, failureData} = getYourSpendSnapshotTransactionsRemovalUpdates({ + transactionItems: [ + {transaction, iouReport: expenseReport}, + {transaction: secondTransaction, iouReport: expenseReport}, + {transaction: nonReimbursableTransaction, iouReport: expenseReport}, + ], + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -30000, CONST.CURRENCY.USD), + }); + + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; + const secondTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}txn2`; + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -15000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[transactionKey]: null, [secondTransactionKey]: null}}, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: -30000, count: 1, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[transactionKey]: transaction, [secondTransactionKey]: secondTransaction}}, + }), + ]); + }); +}); + +describe('getYourSpendSnapshotReimbursableUpdates', () => { + it('subtracts the amount and drops the data row when an expense becomes non-reimbursable', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-30000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + // Flipping a 100 reimbursable spend to non-reimbursable pulls the (negative) section total toward zero: -300 -> -200. + const {optimisticData, failureData} = getYourSpendSnapshotReimbursableUpdates({ + transaction, + updatedTransaction: {...transaction, reimbursable: false}, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -30000, CONST.CURRENCY.USD), + }); + + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; + expect(optimisticData).toEqual([ + expect.objectContaining({key: snapshotKey, value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}}), + expect.objectContaining({key: snapshotKey, value: {data: {[transactionKey]: null}}}), + ]); + expect(failureData).toEqual([ + expect.objectContaining({key: snapshotKey, value: {search: {total: -30000, count: 1, currency: CONST.CURRENCY.USD}}}), + expect.objectContaining({key: snapshotKey, value: {data: {[transactionKey]: transaction}}}), + ]); + }); + + it('adds the amount and inserts the data row when an expense becomes reimbursable', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const updatedTransaction: Transaction = {...transaction, reimbursable: true}; + + // Flipping a 100 non-reimbursable spend to reimbursable makes the (negative) section total more negative: -100 -> -200. + const {optimisticData, failureData} = getYourSpendSnapshotReimbursableUpdates({ + transaction: {...transaction, reimbursable: false}, + updatedTransaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), + }); + + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; + expect(optimisticData).toEqual([ + expect.objectContaining({key: snapshotKey, value: {search: {total: -20000, count: 2, currency: CONST.CURRENCY.USD}}}), + expect.objectContaining({key: snapshotKey, value: {data: {[transactionKey]: updatedTransaction}}}), + ]); + expect(failureData).toEqual([ + expect.objectContaining({key: snapshotKey, value: {search: {total: -10000, count: 1, currency: CONST.CURRENCY.USD}}}), + expect.objectContaining({key: snapshotKey, value: {data: {[transactionKey]: null}}}), + ]); + }); + + it('does not patch snapshots when the reimbursable flag is unchanged', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const {optimisticData} = getYourSpendSnapshotReimbursableUpdates({ + transaction, + updatedTransaction: {...transaction, merchant: 'Renamed'}, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), + }); + + expect(optimisticData).toHaveLength(0); + }); +}); + +describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { + it('includes awaiting-approval snapshot total updates when the amount changes', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const {onyxData} = getUpdateMoneyRequestParams({ + transactionID: TRANSACTION_ID, + transactionThreadReport, + transactionChanges: {amount: -20000}, + policy: paidPolicy, + policyTagList: {}, + reportPolicyTags: {}, + policyCategories: {}, + iouReport: expenseReport, + currentUserAccountIDParam: ACCOUNT_ID, + currentUserEmailParam: 'user@test.com', + isASAPSubmitBetaEnabled: false, + iouReportNextStep: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + yourSpendPatchData: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), + }); + + const snapshotOptimisticUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey); + expect(snapshotOptimisticUpdate).toEqual( + expect.objectContaining({ + value: { + search: { + total: -20000, + count: 1, + currency: CONST.CURRENCY.USD, + }, + }, + }), + ); + }); + + it('includes snapshot updates that remove the expense when it is toggled non-reimbursable', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-30000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const {onyxData} = getUpdateMoneyRequestParams({ + transactionID: TRANSACTION_ID, + transactionThreadReport, + transactionChanges: {reimbursable: false}, + policy: paidPolicy, + policyTagList: {}, + reportPolicyTags: {}, + policyCategories: {}, + iouReport: expenseReport, + currentUserAccountIDParam: ACCOUNT_ID, + currentUserEmailParam: 'user@test.com', + isASAPSubmitBetaEnabled: false, + iouReportNextStep: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + yourSpendPatchData: buildYourSpendPatchData(snapshotKey, -30000, CONST.CURRENCY.USD), + }); + + const snapshotTotalUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey && !!update.value && typeof update.value === 'object' && 'search' in update.value); + expect(snapshotTotalUpdate).toEqual( + expect.objectContaining({ + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + ); + }); + + it('does not include snapshot total updates for a currency-only change', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const {onyxData} = getUpdateMoneyRequestParams({ + transactionID: TRANSACTION_ID, + transactionThreadReport, + transactionChanges: {currency: CONST.CURRENCY.EUR}, + policy: paidPolicy, + policyTagList: {}, + reportPolicyTags: {}, + policyCategories: {}, + iouReport: expenseReport, + currentUserAccountIDParam: ACCOUNT_ID, + currentUserEmailParam: 'user@test.com', + isASAPSubmitBetaEnabled: false, + iouReportNextStep: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + yourSpendPatchData: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), + }); + + const snapshotOptimisticUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey); + expect(snapshotOptimisticUpdate).toBeUndefined(); + }); +}); diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 5640d3e6e89d..a52df5346490 100644 --- a/tests/actions/IOUTest/ReportWorkflowTest.ts +++ b/tests/actions/IOUTest/ReportWorkflowTest.ts @@ -24,6 +24,9 @@ import {submitMoneyRequestOnSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import getReportPreviewAction from '@libs/ReportPreviewActionUtils'; import {isPayer} from '@libs/ReportUtils'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; +import {buildAwaitingApprovalQuery} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -33,7 +36,7 @@ import DateUtils from '@src/libs/DateUtils'; import {generateAccountID} from '@src/libs/UserUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportNameValuePairs, ReportNextStepDeprecated} from '@src/types/onyx'; +import type {Policy, Report, ReportNameValuePairs, ReportNextStepDeprecated, SearchResults} from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -3578,6 +3581,120 @@ describe('actions/IOU/ReportWorkflow', () => { }); }); + describe('approveMoneyRequest Your spend snapshot patching', () => { + const accountID = 1; + const email = 'user@test.com'; + const policyID = 'paid-group-policy'; + const reportID = 'your-spend-report'; + const transactionID = 'your-spend-txn'; + + function buildPaidGroupPolicy(approvalMode: Policy['approvalMode']): Policy { + return { + id: policyID, + name: 'Paid Group Policy', + role: CONST.POLICY.ROLE.ADMIN, + owner: email, + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + type: CONST.POLICY.TYPE.TEAM, + approvalMode, + // Single approver who is also the report owner, so approving fully approves the report + // (SUBMITTED -> APPROVED) and it leaves the Awaiting approval bucket. + employeeList: { + [email]: {email, role: CONST.POLICY.ROLE.ADMIN, submitsTo: '', forwardsTo: ''}, + }, + }; + } + + const submittedReport: Report = { + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: accountID, + managerID: accountID, + policyID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + total: -10000, + currency: CONST.CURRENCY.USD, + }; + + function buildSnapshotSearch(): SearchResults['search'] { + return { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hash: 0, + hasMoreResults: false, + hasResults: true, + isLoading: false, + count: 1, + total: -10000, + currency: CONST.CURRENCY.USD, + }; + } + + // Approving a report whose sole approver is also its owner fully approves it (SUBMITTED -> APPROVED), + // so it leaves the Awaiting approval bucket and would patch the snapshot total — unless the guard skips DEW. + // Returns whether the API.write payload contained a Your spend snapshot update. + function seedAndApprove(policy: Policy): Promise { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(accountID, [policyID])); + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as const; + const transaction = { + ...createRandomTransaction(1), + transactionID, + reportID, + amount: 10000, + modifiedAmount: 0, + currency: CONST.CURRENCY.USD, + reimbursable: true, + }; + const yourSpendPatchData: YourSpendPatchData = { + paidPolicies: {[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]: policy}, + snapshotSearches: {[snapshotKey]: buildSnapshotSearch()}, + }; + + return Onyx.set(ONYXKEYS.SESSION, {email, accountID}) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy)) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, submittedReport)) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction)) + .then(() => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- Inspecting API.write to verify the Your spend snapshot patch is (or isn't) included. + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + approveMoneyRequest({ + expenseReport: submittedReport, + expenseReportPolicy: policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email, + hasViolations: false, + isASAPSubmitBetaEnabled: false, + expenseReportCurrentNextStepDeprecated: undefined, + betas: [CONST.BETAS.ALL], + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + delegateEmail: undefined, + isTrackIntentUser: false, + ownerLogin: undefined, + yourSpendPatchData, + }); + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + const patched = !!onyxData?.optimisticData?.some((update) => update.key.startsWith(ONYXKEYS.COLLECTION.SNAPSHOT)); + return waitForBatchedUpdates().then(() => patched); + }); + } + + it('patches the Your spend snapshot for a non-DEW paid group policy', () => { + return seedAndApprove(buildPaidGroupPolicy(CONST.POLICY.APPROVAL_MODE.ADVANCED)).then((patched) => { + expect(patched).toBe(true); + }); + }); + + it('does not patch the Your spend snapshot for a DEW policy', () => { + return seedAndApprove(buildPaidGroupPolicy(CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL)).then((patched) => { + expect(patched).toBe(false); + }); + }); + }); + describe('approveMoneyRequest partially', () => { const adminAccountID = 1; const employeeAccountID = 3; diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index c8d594f1ae9c..1999588a1495 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -16,9 +16,9 @@ import {search} from '@libs/actions/Search'; import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards} from '@libs/CardUtils'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@libs/YourSpendQueryUtils'; import {YOUR_SPEND_ROW_STATE} from '@pages/home/YourSpendSection/const'; -import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; import {useYourSpendData} from '@pages/home/YourSpendSection/useYourSpendData'; import CONST from '@src/CONST'; @@ -55,7 +55,7 @@ const THIRD_PARTY_QUERY_2 = `type:expense from:${ACCOUNT_ID} cardID:${THIRD_PART // Module mocks -jest.mock('@pages/home/YourSpendSection/queries', () => ({ +jest.mock('@libs/YourSpendQueryUtils', () => ({ buildAwaitingApprovalQuery: jest.fn(), buildRepaidLast30DaysQuery: jest.fn(), buildRecentCardTransactionsQuery: jest.fn(), diff --git a/tests/unit/Search/yourSpendApplicabilityTest.ts b/tests/unit/Search/yourSpendApplicabilityTest.ts index 1b1ed766de5e..6b53e71de659 100644 --- a/tests/unit/Search/yourSpendApplicabilityTest.ts +++ b/tests/unit/Search/yourSpendApplicabilityTest.ts @@ -3,7 +3,7 @@ */ import {arePaymentsEnabled} from '@libs/PolicyUtils'; -import {getOutstandingReportsSignature, getYourSpendApplicability} from '@pages/home/YourSpendSection/useYourSpendData'; +import {getOutstandingReportsSignature, getRepaidReportsSignature, getYourSpendApplicability} from '@pages/home/YourSpendSection/useYourSpendData'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -162,3 +162,49 @@ describe('getOutstandingReportsSignature', () => { expect(getOutstandingReportsSignature(reports, PAID_GROUP_POLICY_IDS, ACCOUNT_ID)).toBe('r1,r2,r3'); }); }); + +describe('getRepaidReportsSignature', () => { + const ACCOUNT_ID = 12345; + + function makeReimbursedReport(overrides: Partial = {}): Report { + return makeReport({ + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + ...overrides, + }); + } + + it('returns an empty string when reports is undefined', () => { + expect(getRepaidReportsSignature(undefined, ACCOUNT_ID)).toBe(''); + }); + + it('includes only reimbursed reports owned by the account', () => { + const reports = reportsCollection([makeReimbursedReport({reportID: 'r1'}), makeReimbursedReport({reportID: 'r2'})]); + + expect(getRepaidReportsSignature(reports, ACCOUNT_ID)).toBe('r1,r2'); + }); + + it('excludes reports owned by a different account', () => { + const reports = reportsCollection([makeReimbursedReport({reportID: 'r1'}), makeReimbursedReport({reportID: 'r2', ownerAccountID: 99999})]); + + expect(getRepaidReportsSignature(reports, ACCOUNT_ID)).toBe('r1'); + }); + + it('excludes reports whose statusNum is not REIMBURSED', () => { + const reports = reportsCollection([makeReimbursedReport({reportID: 'r1'}), makeReimbursedReport({reportID: 'r2', statusNum: CONST.REPORT.STATUS_NUM.APPROVED})]); + + expect(getRepaidReportsSignature(reports, ACCOUNT_ID)).toBe('r1'); + }); + + it('excludes reports whose stateNum is below APPROVED', () => { + const reports = reportsCollection([makeReimbursedReport({reportID: 'r1'}), makeReimbursedReport({reportID: 'r2', stateNum: CONST.REPORT.STATE_NUM.SUBMITTED})]); + + expect(getRepaidReportsSignature(reports, ACCOUNT_ID)).toBe('r1'); + }); + + it('returns report IDs sorted ascending regardless of input order', () => { + const reports = reportsCollection([makeReimbursedReport({reportID: 'r3'}), makeReimbursedReport({reportID: 'r1'}), makeReimbursedReport({reportID: 'r2'})]); + + expect(getRepaidReportsSignature(reports, ACCOUNT_ID)).toBe('r1,r2,r3'); + }); +}); diff --git a/tests/unit/Search/yourSpendQueryBuildersTest.ts b/tests/unit/Search/yourSpendQueryBuildersTest.ts index 056791a439f7..2fceba7804ad 100644 --- a/tests/unit/Search/yourSpendQueryBuildersTest.ts +++ b/tests/unit/Search/yourSpendQueryBuildersTest.ts @@ -6,14 +6,29 @@ * - `cardID` uses the numeric card ID (not the card name) * - Date filter serializes as `date>YYYY-MM-DD` (not `dateAfter:`) */ -import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries'; +import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery, getPaidGroupPolicyIDs, selectPaidGroupPolicies} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import {buildSearchQueryJSON, getFilterFromQuery} from '@src/libs/SearchQueryUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; const ACCOUNT_ID = 12345; const CARD_ID = 67890; +/** Builds a POLICY collection keyed by `policy_` from the given policies. */ +function buildPolicyCollection(policies: Policy[]): OnyxCollection { + const collection: OnyxCollection = {}; + for (const policy of policies) { + collection[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; + } + return collection; +} + // Helpers /** @@ -214,3 +229,73 @@ describe('buildRecentCardTransactionsQuery', () => { expect(diffDays).toBeLessThanOrEqual(31); }); }); + +// selectPaidGroupPolicies + +describe('selectPaidGroupPolicies', () => { + it('returns an empty collection for undefined/empty input', () => { + expect(selectPaidGroupPolicies(undefined)).toEqual({}); + expect(selectPaidGroupPolicies({})).toEqual({}); + }); + + it('keeps only Team and Corporate policies', () => { + const team = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: '1'}; + const corporate = {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), id: '2'}; + const personal = {...createRandomPolicy(3, CONST.POLICY.TYPE.PERSONAL), id: '3'}; + const submit = {...createRandomPolicy(4, CONST.POLICY.TYPE.SUBMIT), id: '4'}; + + const result = selectPaidGroupPolicies(buildPolicyCollection([team, corporate, personal, submit])); + + expect( + Object.values(result ?? {}) + .map((policy) => policy?.id) + .sort(), + ).toEqual(['1', '2']); + }); + + it('narrows kept policies to the fields the snapshot builders read', () => { + const team = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: '1', outputCurrency: CONST.CURRENCY.USD}; + + const result = selectPaidGroupPolicies(buildPolicyCollection([team])); + + expect(result?.[`${ONYXKEYS.COLLECTION.POLICY}1`]).toEqual({id: '1', type: CONST.POLICY.TYPE.TEAM, outputCurrency: CONST.CURRENCY.USD}); + }); + + it('drops policies without an id', () => { + const team = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: '1'}; + const collection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: team, + [`${ONYXKEYS.COLLECTION.POLICY}missing`]: {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: ''}, + }; + + const result = selectPaidGroupPolicies(collection); + + expect(Object.values(result ?? {}).map((policy) => policy?.id)).toEqual(['1']); + }); +}); + +// getPaidGroupPolicyIDs + +describe('getPaidGroupPolicyIDs', () => { + it('returns an empty array for undefined/empty input', () => { + expect(getPaidGroupPolicyIDs(undefined)).toEqual([]); + expect(getPaidGroupPolicyIDs({})).toEqual([]); + }); + + it('returns the ids of the provided policies', () => { + const team = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: '1'}; + const corporate = {...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), id: '2'}; + + expect(getPaidGroupPolicyIDs(buildPolicyCollection([team, corporate])).sort()).toEqual(['1', '2']); + }); + + it('filters out entries with a falsy id', () => { + const collection: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: '1'}, + [`${ONYXKEYS.COLLECTION.POLICY}empty`]: {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: ''}, + [`${ONYXKEYS.COLLECTION.POLICY}missing`]: undefined, + }; + + expect(getPaidGroupPolicyIDs(collection)).toEqual(['1']); + }); +});