From ef190b3a51efb8f6c0f692c83c85fa5a1a74cc19 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 8 Jun 2026 17:34:43 +0200 Subject: [PATCH 01/32] Fix Your spend totals staying stale after offline expense edits. Patch awaiting-approval and repaid snapshot search.total aggregates optimistically when money request amounts change, since Home cannot refetch those totals while offline. --- src/libs/YourSpendQueryUtils.ts | 44 ++++ src/libs/actions/IOU/UpdateMoneyRequest.ts | 13 ++ .../actions/IOU/YourSpendSnapshotUpdate.ts | 200 ++++++++++++++++++ src/pages/home/YourSpendSection/queries.ts | 45 +--- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 196 +++++++++++++++++ 5 files changed, 454 insertions(+), 44 deletions(-) create mode 100644 src/libs/YourSpendQueryUtils.ts create mode 100644 src/libs/actions/IOU/YourSpendSnapshotUpdate.ts create mode 100644 tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts diff --git a/src/libs/YourSpendQueryUtils.ts b/src/libs/YourSpendQueryUtils.ts new file mode 100644 index 000000000000..6a4efeab11ab --- /dev/null +++ b/src/libs/YourSpendQueryUtils.ts @@ -0,0 +1,44 @@ +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import {buildQueryStringFromFilterFormValues} from './SearchQueryUtils'; + +function get30DaysAgoDateString(): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 30); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function buildAwaitingApprovalQuery(accountID: number, policyIDs: string[]): string { + return buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, + from: [String(accountID)], + reimbursable: CONST.SEARCH.BOOLEAN.YES, + // Limit to the user's workspaces so IOU and personal expenses aren't counted. + ...(policyIDs.length > 0 ? {[FILTER_KEYS.POLICY_ID]: policyIDs} : {}), + }); +} + +function buildRepaidLast30DaysQuery(accountID: number): string { + return buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.PAID, + from: [String(accountID)], + reimbursable: CONST.SEARCH.BOOLEAN.YES, + [FILTER_KEYS.DATE_AFTER]: get30DaysAgoDateString(), + }); +} + +function buildRecentCardTransactionsQuery(accountID: number, cardID: number): string { + return buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + from: [String(accountID)], + cardID: [String(cardID)], + [FILTER_KEYS.DATE_AFTER]: get30DaysAgoDateString(), + }); +} + +export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery, get30DaysAgoDateString}; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index dca8086ca4be..6a1a11b8a98c 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -52,6 +52,7 @@ import type {Routes, TransactionChanges, WaypointCollection} from '@src/types/on import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllReports, getAllTransactions, getAllTransactionViolations, getPolicyTagsData, getRecentAttendees} from '.'; import {getUpdatedMoneyRequestReportData, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; +import {getYourSpendSnapshotTotalUpdates} from './YourSpendSnapshotUpdate'; type UpdateMoneyRequestData = { params: UpdateMoneyRequestParams; @@ -1792,6 +1793,18 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U }); } + if ((hasModifiedAmount || hasModifiedCurrency) && transaction && updatedTransaction && iouReport) { + const yourSpendSnapshotTotalUpdates = getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport, + currentUserAccountID: currentUserAccountIDParam, + }); + optimisticData.push(...yourSpendSnapshotTotalUpdates.optimisticData); + successData.push(...yourSpendSnapshotTotalUpdates.successData); + failureData.push(...yourSpendSnapshotTotalUpdates.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..c85231212f3e --- /dev/null +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -0,0 +1,200 @@ +import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import {isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {getAmount, getCurrency} from '@libs/TransactionUtils'; +import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDateString} from '@libs/YourSpendQueryUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; + +type YourSpendSnapshotOnyxData = { + optimisticData: Array>; + successData: Array>; + failureData: Array>; +}; + +type GetYourSpendSnapshotTotalUpdatesParams = { + transaction: OnyxEntry; + updatedTransaction: OnyxEntry; + iouReport: OnyxEntry; + currentUserAccountID: number; +}; + +// connectWithoutView: snapshot/policy reads are for optimistic Your spend total patches only. +let allSnapshots: OnyxCollection = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, + waitForCollectionCallback: true, + callback: (value) => { + allSnapshots = value ?? {}; + }, +}); + +let allPolicies: OnyxCollection = {}; +Onyx.connectWithoutView({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => { + allPolicies = value ?? {}; + }, +}); + +function getPaidGroupPolicyIDs(): string[] { + const policyIDs: string[] = []; + for (const policy of Object.values(allPolicies ?? {})) { + if (policy?.id && isPaidGroupPolicy(policy)) { + policyIDs.push(policy.id); + } + } + return policyIDs; +} + +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(snapshotHash: number | undefined, diff: number, currency: string): YourSpendSnapshotOnyxData { + if (!snapshotHash || diff === 0) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; + const currentSnapshot = allSnapshots?.[snapshotKey]; + const currentTotal = currentSnapshot?.search?.total; + const currentCurrency = currentSnapshot?.search?.currency; + + if (currentTotal === undefined || currentTotal === null) { + return {optimisticData: [], successData: [], failureData: []}; + } + if (currentCurrency && currentCurrency !== currency) { + return {optimisticData: [], successData: [], failureData: []}; + } + + const updatedTotal = currentTotal + diff; + return { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: snapshotKey, + value: { + search: { + total: updatedTotal, + currency, + }, + }, + }, + ], + successData: [], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: snapshotKey, + value: { + search: { + total: currentTotal, + currency: currentCurrency ?? currency, + }, + }, + }, + ], + }; +} + +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); + + if (currentCurrency !== updatedCurrency) { + return null; + } + + const currentTotal = Math.abs(getAmount(transaction, isExpenseReportLocal)); + const updatedTotal = Math.abs(getAmount(updatedTransaction, isExpenseReportLocal)); + + if (currentTotal === updatedTotal) { + return 0; + } + + return updatedTotal - currentTotal; +} + +/** + * Optimistically patches Your spend snapshot aggregates when a transaction amount changes. + * Home reads totals from `snapshot.search.total`, which is only refreshed via search() while online. + */ +function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouReport, currentUserAccountID}: 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 currency = getCurrency(updatedTransaction); + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + + if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, currency)); + } + + if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, currency)); + } + + return result; +} + +export {getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery, transactionMatchesRepaidLast30DaysQuery}; +export type {GetYourSpendSnapshotTotalUpdatesParams, YourSpendSnapshotOnyxData}; diff --git a/src/pages/home/YourSpendSection/queries.ts b/src/pages/home/YourSpendSection/queries.ts index 9f913c310e0d..5835dd799d23 100644 --- a/src/pages/home/YourSpendSection/queries.ts +++ b/src/pages/home/YourSpendSection/queries.ts @@ -1,44 +1 @@ -import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; -import CONST from '@src/CONST'; -import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; - -function get30DaysAgoDateString(): string { - const date = new Date(); - date.setUTCDate(date.getUTCDate() - 30); - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, '0'); - const day = String(date.getUTCDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function buildAwaitingApprovalQuery(accountID: number, policyIDs: string[]): string { - return buildQueryStringFromFilterFormValues({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, - from: [String(accountID)], - reimbursable: CONST.SEARCH.BOOLEAN.YES, - // Limit to the user's workspaces so IOU and personal expenses aren't counted. - ...(policyIDs.length > 0 ? {[FILTER_KEYS.POLICY_ID]: policyIDs} : {}), - }); -} - -function buildRepaidLast30DaysQuery(accountID: number): string { - return buildQueryStringFromFilterFormValues({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - status: CONST.SEARCH.STATUS.EXPENSE.PAID, - from: [String(accountID)], - reimbursable: CONST.SEARCH.BOOLEAN.YES, - [FILTER_KEYS.DATE_AFTER]: get30DaysAgoDateString(), - }); -} - -function buildRecentCardTransactionsQuery(accountID: number, cardID: number): string { - return buildQueryStringFromFilterFormValues({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - from: [String(accountID)], - cardID: [String(cardID)], - [FILTER_KEYS.DATE_AFTER]: get30DaysAgoDateString(), - }); -} - -export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery}; +export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery} from '@libs/YourSpendQueryUtils'; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts new file mode 100644 index 000000000000..d56c00a08db8 --- /dev/null +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -0,0 +1,196 @@ +import Onyx from 'react-native-onyx'; +import {getUpdateMoneyRequestParams} from '@libs/actions/IOU/UpdateMoneyRequest'; +import {getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +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 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 = { + id: POLICY_ID, + type: CONST.POLICY.TYPE.TEAM, + name: 'Team Workspace', + owner: 'owner@test.com', + role: CONST.POLICY.ROLE.ADMIN, + outputCurrency: CONST.CURRENCY.USD, +} as Policy; + +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; + +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 = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, { + search: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, + count: 1, + total: 10000, + currency: CONST.CURRENCY.USD, + }, + data: {}, + } as SearchResults); + await waitForBatchedUpdates(); + + expect(transactionMatchesAwaitingApprovalQuery(expenseReport, transaction, ACCOUNT_ID, [POLICY_ID])).toBe(true); + + const updatedTransaction: Transaction = { + ...transaction, + modifiedAmount: 20000, + }; + + const {optimisticData, failureData} = getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: { + search: { + total: 20000, + currency: CONST.CURRENCY.USD, + }, + }, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: { + search: { + total: 10000, + 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 = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`; + + Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + Onyx.set(snapshotKey, { + search: {count: 1, total: 10000, currency: CONST.CURRENCY.USD}, + data: {}, + } as SearchResults); + + const updatedTransaction: Transaction = { + ...transaction, + amount: -20000, + currency: CONST.CURRENCY.EUR, + }; + + const {optimisticData} = getYourSpendSnapshotTotalUpdates({ + transaction, + updatedTransaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + }); + + 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 = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await Onyx.set(snapshotKey, { + search: {count: 1, total: 10000, currency: CONST.CURRENCY.USD}, + data: {}, + } as SearchResults); + 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, + }); + + const snapshotOptimisticUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey); + expect(snapshotOptimisticUpdate).toEqual( + expect.objectContaining({ + value: { + search: { + total: 20000, + currency: CONST.CURRENCY.USD, + }, + }, + }), + ); + }); +}); From fabd3fdb05959087a8407688103fa5e826ffed53 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 9 Jun 2026 16:45:35 +0200 Subject: [PATCH 02/32] Remove redundant YourSpendSection queries barrel Import Your spend query builders directly from @libs/YourSpendQueryUtils instead of a page-local re-export barrel, giving a single source of truth. --- src/pages/home/YourSpendSection/queries.ts | 1 - src/pages/home/YourSpendSection/useYourSpendData.ts | 2 +- tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts | 4 ++-- tests/unit/Search/yourSpendQueryBuildersTest.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 src/pages/home/YourSpendSection/queries.ts diff --git a/src/pages/home/YourSpendSection/queries.ts b/src/pages/home/YourSpendSection/queries.ts deleted file mode 100644 index 5835dd799d23..000000000000 --- a/src/pages/home/YourSpendSection/queries.ts +++ /dev/null @@ -1 +0,0 @@ -export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery} from '@libs/YourSpendQueryUtils'; diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 9a55239ed25d..227216ea36b9 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -11,13 +11,13 @@ import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards, isPersonalC import {arePaymentsEnabled, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSuggestedSearches, getSuggestedSearchesVisibility, TODO_SEARCH_KEYS} from '@libs/SearchUIUtils'; +import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Card, Policy, Report} from '@src/types/onyx'; import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import type SearchResults from '@src/types/onyx/SearchResults'; import {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE} from './const'; -import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from './queries'; type YourSpendRowState = ValueOf; type YourSpendCardKind = ValueOf; diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index 2756a910a4ec..8a4ceb82e1b3 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -14,8 +14,8 @@ 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'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,7 +51,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/yourSpendQueryBuildersTest.ts b/tests/unit/Search/yourSpendQueryBuildersTest.ts index 95aa2289af50..c761c7640594 100644 --- a/tests/unit/Search/yourSpendQueryBuildersTest.ts +++ b/tests/unit/Search/yourSpendQueryBuildersTest.ts @@ -6,7 +6,7 @@ * - `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} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import {buildSearchQueryJSON} from '@src/libs/SearchQueryUtils'; From 3545edc04e45f108b0c08c5bef1113806b7cd738 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 9 Jun 2026 17:03:09 +0200 Subject: [PATCH 03/32] Remove unused exports from YourSpendSnapshotUpdate --- src/libs/actions/IOU/YourSpendSnapshotUpdate.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index c85231212f3e..6d07597f5f54 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -196,5 +196,4 @@ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouR return result; } -export {getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery, transactionMatchesRepaidLast30DaysQuery}; -export type {GetYourSpendSnapshotTotalUpdatesParams, YourSpendSnapshotOnyxData}; +export {getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery}; From d767640a2f006cc72da4a57c2cb50bd3bbbd1f94 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 11 Jun 2026 12:09:37 +0200 Subject: [PATCH 04/32] Fix ESLint no-unsafe-type-assertion errors in Your spend snapshot test Replace unsafe type assertions with a typed createRandomPolicy factory and typed getSnapshotKey/buildSnapshotSearchResults helpers. --- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index d56c00a08db8..bdb5e7751167 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -8,6 +8,7 @@ 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 createRandomPolicy from '../../utils/collections/policies'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const ACCOUNT_ID = 42; @@ -17,13 +18,35 @@ const EXPENSE_REPORT_ID = 'expenseReport1'; const THREAD_REPORT_ID = 'thread1'; const paidPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, 'Team Workspace'), id: POLICY_ID, - type: CONST.POLICY.TYPE.TEAM, - name: 'Team Workspace', owner: 'owner@test.com', role: CONST.POLICY.ROLE.ADMIN, outputCurrency: CONST.CURRENCY.USD, -} as Policy; +}; + +/** 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, + status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, + hasMoreResults: false, + hasResults: true, + isLoading: false, + count: 1, + total, + currency, + }, + data: {}, + }; +} const expenseReport: Report = { reportID: EXPENSE_REPORT_ID, @@ -72,19 +95,10 @@ beforeEach(() => { describe('getYourSpendSnapshotTotalUpdates', () => { it('patches the awaiting-approval snapshot total with the amount delta', async () => { const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); - const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`; + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); - await Onyx.set(snapshotKey, { - search: { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, - count: 1, - total: 10000, - currency: CONST.CURRENCY.USD, - }, - data: {}, - } as SearchResults); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD)); await waitForBatchedUpdates(); expect(transactionMatchesAwaitingApprovalQuery(expenseReport, transaction, ACCOUNT_ID, [POLICY_ID])).toBe(true); @@ -127,13 +141,10 @@ describe('getYourSpendSnapshotTotalUpdates', () => { it('does not patch snapshots when the currency changes to a different report currency', () => { const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); - const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`; + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); - Onyx.set(snapshotKey, { - search: {count: 1, total: 10000, currency: CONST.CURRENCY.USD}, - data: {}, - } as SearchResults); + Onyx.set(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD)); const updatedTransaction: Transaction = { ...transaction, @@ -155,14 +166,11 @@ describe('getYourSpendSnapshotTotalUpdates', () => { 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 = `${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}` as `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${number}`; + 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, { - search: {count: 1, total: 10000, currency: CONST.CURRENCY.USD}, - data: {}, - } as SearchResults); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD)); await waitForBatchedUpdates(); const {onyxData} = getUpdateMoneyRequestParams({ From 215db73b0feae701068b5a26a3170619d147bce9 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 11 Jun 2026 12:38:36 +0200 Subject: [PATCH 05/32] Patch Your spend totals only for amount edits, not currency changes --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 5 ++++- src/libs/actions/IOU/YourSpendSnapshotUpdate.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 6a1a11b8a98c..ae087bebcb44 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -1793,7 +1793,10 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U }); } - if ((hasModifiedAmount || hasModifiedCurrency) && transaction && updatedTransaction && iouReport) { + // Only amount edits can be patched into the Your spend totals offline. A currency change would require converting + // between currencies (rates are only available server-side), so those edits are intentionally left to the next + // online search() refresh and handled by the early return in getYourSpendSnapshotTotalUpdates. + if (hasModifiedAmount && transaction && updatedTransaction && iouReport) { const yourSpendSnapshotTotalUpdates = getYourSpendSnapshotTotalUpdates({ transaction, updatedTransaction, diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 6d07597f5f54..8d4931914864 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -150,6 +150,8 @@ function calculateYourSpendTotalDiff(iouReport: OnyxEntry, updatedTransa const currentCurrency = getCurrency(transaction); const updatedCurrency = getCurrency(updatedTransaction); + // A currency change can't be expressed as a simple amount delta on the snapshot total without converting between + // currencies, and FX rates aren't available offline (totals are converted server-side). Skip patching in that case. if (currentCurrency !== updatedCurrency) { return null; } From 6831b5fdef3d6be8c5f7dc6213a667220f57ec7d Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 12 Jun 2026 13:46:29 +0200 Subject: [PATCH 06/32] Add Your spend report-move snapshot helper and tests Add getYourSpendSnapshotReportMoveUpdates, which optimistically patches the Home "Your spend" snapshot totals when a report moves between states (submit, retract, reject, unapprove, cancel payment): the report's reimbursable total is added to the section it enters and removed from the one it leaves, scoped to the user's own reimbursable expenses on paid group workspaces and skipped on currency mismatch. Cover the helper with unit tests for each move and the scoping no-ops, plus a regression test pinning the currency-only edit no-op. --- .../actions/IOU/YourSpendSnapshotUpdate.ts | 91 +++++- ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 281 ++++++++++++++++++ .../GetYourSpendSnapshotTotalUpdatesTest.ts | 29 ++ 3 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 8d4931914864..6a7ca6371c7c 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -166,6 +166,95 @@ function calculateYourSpendTotalDiff(iouReport: OnyxEntry, updatedTransa return updatedTotal - currentTotal; } +type ReportStatus = Pick; + +type GetYourSpendSnapshotReportMoveUpdatesParams = { + iouReport: OnyxEntry; + reportTransactions: Transaction[]; + fromStatus: ReportStatus; + toStatus: ReportStatus; + currentUserAccountID: number; +}; + +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; +} + +/** Sums the absolute reimbursable amount of a report's transactions, optionally restricted to the last 30 days. */ +function getReportReimbursableTotal(iouReport: OnyxEntry, reportTransactions: Transaction[], onlyWithinLast30Days: boolean): number { + const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + let total = 0; + 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; + } + } + total += Math.abs(getAmount(reportTransaction, isExpenseReportLocal)); + } + return total; +} + +/** + * Optimistically patches Your spend snapshot aggregates when a report moves between states (e.g. submit, retract, + * reject, unapprove, cancel payment). The report's reimbursable total is added to the section it enters and removed + * from the section it leaves, since Home reads totals from `snapshot.search.total` which is only refreshed online. + */ +function getYourSpendSnapshotReportMoveUpdates({ + iouReport, + reportTransactions, + fromStatus, + toStatus, + currentUserAccountID, +}: GetYourSpendSnapshotReportMoveUpdatesParams): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (!iouReport) { + return result; + } + + const currency = iouReport.currency ?? CONST.CURRENCY.USD; + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + + if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs)) { + const total = getReportReimbursableTotal(iouReport, reportTransactions, false); + const diff = (isAwaitingApprovalStatus(toStatus) ? total : 0) - (isAwaitingApprovalStatus(fromStatus) ? total : 0); + if (diff !== 0) { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, currency)); + } + } + + if (reportInRepaidScope(iouReport, currentUserAccountID)) { + const total = getReportReimbursableTotal(iouReport, reportTransactions, true); + const diff = (isRepaidStatus(toStatus) ? total : 0) - (isRepaidStatus(fromStatus) ? total : 0); + if (diff !== 0) { + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, currency)); + } + } + + return result; +} + /** * Optimistically patches Your spend snapshot aggregates when a transaction amount changes. * Home reads totals from `snapshot.search.total`, which is only refreshed via search() while online. @@ -198,4 +287,4 @@ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouR return result; } -export {getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery}; +export {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery}; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts new file mode 100644 index 000000000000..a7fb49f157a9 --- /dev/null +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -0,0 +1,281 @@ +import Onyx from 'react-native-onyx'; +import {getYourSpendSnapshotReportMoveUpdates} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +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 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. */ +function buildSnapshotSearchResults(total: number, currency: string): SearchResults { + return { + search: { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING, + hasMoreResults: false, + hasResults: true, + isLoading: false, + count: 1, + 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; +} + +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; +} + +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); +}); + +async function seedAwaitingApprovalSnapshot(total: number, currency: string = CONST.CURRENCY.USD) { + 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(total, currency)); + await waitForBatchedUpdates(); + return snapshotKey; +} + +async function seedRepaidSnapshot(total: number, currency: string = CONST.CURRENCY.USD) { + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(ACCOUNT_ID)); + const snapshotKey = getSnapshotKey(paymentQueryJSON?.hash ?? 0); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(total, currency)); + await waitForBatchedUpdates(); + return snapshotKey; +} + +describe('getYourSpendSnapshotReportMoveUpdates', () => { + it('adds the report total to awaiting approval when a report is submitted (OPEN -> SUBMITTED)', async () => { + const snapshotKey = await seedAwaitingApprovalSnapshot(10000); + + const {optimisticData, failureData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 10000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + it('subtracts the report total from awaiting approval when a report is retracted (SUBMITTED -> OPEN)', async () => { + const snapshotKey = await seedAwaitingApprovalSnapshot(30000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(OPEN_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: SUBMITTED_STATUS, + toStatus: OPEN_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + it('subtracts the report total from awaiting approval when a report is rejected (SUBMITTED -> OPEN)', async () => { + const snapshotKey = await seedAwaitingApprovalSnapshot(30000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(OPEN_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: SUBMITTED_STATUS, + toStatus: OPEN_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + it('adds the report total back to awaiting approval when a report is unapproved (APPROVED -> SUBMITTED)', async () => { + const snapshotKey = await seedAwaitingApprovalSnapshot(10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: APPROVED_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + it('subtracts the report total from repaid when a payment is cancelled (REIMBURSED -> APPROVED)', async () => { + const snapshotKey = await seedRepaidSnapshot(30000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(APPROVED_STATUS), + reportTransactions: [buildTransaction({created: getRecentCreatedDate()})], + fromStatus: REIMBURSED_STATUS, + toStatus: APPROVED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + it('does not patch when the report is not owned by the current user', async () => { + await seedAwaitingApprovalSnapshot(10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport({...SUBMITTED_STATUS, ownerAccountID: OTHER_ACCOUNT_ID}), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('does not patch when the report transactions are non-reimbursable', async () => { + await seedAwaitingApprovalSnapshot(10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction({reimbursable: false})], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('does not patch when the workspace is not a paid group policy', async () => { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); + const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); + await Onyx.set(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD)); + await waitForBatchedUpdates(); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toHaveLength(0); + }); + + it('does not patch when the snapshot currency differs from the report currency', async () => { + await seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.EUR); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toHaveLength(0); + }); +}); diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index bdb5e7751167..cc41a74ea2fa 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -201,4 +201,33 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { }), ); }); + + 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, + }); + + const snapshotOptimisticUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey); + expect(snapshotOptimisticUpdate).toBeUndefined(); + }); }); From a377443e67751bf32a9b358bcbed9ee00ccd7509 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 12 Jun 2026 14:40:19 +0200 Subject: [PATCH 07/32] Patch Your spend totals on retract, unapprove and submit Wire getYourSpendSnapshotReportMoveUpdates into the report-state moves in ReportWorkflow so the Home "Your spend" Awaiting approval total updates offline: submit adds the report's reimbursable total, retract removes it, and unapprove adds it back. DEW policies are skipped because they don't optimistically change the report state. --- src/libs/actions/IOU/ReportWorkflow.ts | 49 ++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 5faa892d377b..40a76c0646e7 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -73,6 +73,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllReportNameValuePairs, getAllTransactionViolations} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; +import {getYourSpendSnapshotReportMoveUpdates} from './YourSpendSnapshotUpdate'; type ApproveMoneyRequestFunctionParams = { expenseReport: OnyxEntry; @@ -1121,7 +1122,19 @@ function retractReport( reportActionID: optimisticRetractReportAction.reportActionID, }; - API.write(WRITE_COMMANDS.RETRACT_REPORT, parameters, {optimisticData, successData, failureData}); + const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, + currentUserAccountID: currentUserAccountIDParam, + }); + + API.write(WRITE_COMMANDS.RETRACT_REPORT, parameters, { + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }); } function unapproveExpenseReport( @@ -1284,7 +1297,19 @@ function unapproveExpenseReport( reportActionID: optimisticUnapprovedReportAction.reportActionID, }; - API.write(WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT, parameters, {optimisticData, successData, failureData}); + 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, + }); + + API.write(WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT, parameters, { + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }); } function submitReport({ @@ -1563,8 +1588,26 @@ function submitReport({ reportActionID: optimisticSubmittedReportAction.reportActionID, }; + // 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, + }); + onSubmitted?.(); - API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, { + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }); } function assignReportToMe( From 33aabfdc5bfc0bf188d2f5ddf7370e672f20847d Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 12 Jun 2026 14:46:30 +0200 Subject: [PATCH 08/32] Patch Your spend totals on delete, reject and cancel payment Add getYourSpendSnapshotTransactionRemovalUpdates, which subtracts a single removed transaction's reimbursable amount from the section the report currently belongs to, and wire it into delete and reject so the Home "Your spend" Awaiting approval total updates offline. Also wire cancel payment through the report-move helper so the Repaid total drops when a reimbursement is reversed. --- src/libs/actions/IOU/DeleteMoneyRequest.ts | 13 ++++- src/libs/actions/IOU/PayMoneyRequest.ts | 15 +++++- src/libs/actions/IOU/RejectMoneyRequest.ts | 13 +++++ .../actions/IOU/YourSpendSnapshotUpdate.ts | 41 +++++++++++++++- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 49 ++++++++++++++++++- 5 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index bd4624853e33..885dfbae990a 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -34,6 +34,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; import {getAllReportActionsFromIOU, getAllReportNameValuePairs, getAllReports, getAllTransactions, getAllTransactionViolations} from '.'; import {getReportPreviewAction} from './MoneyRequestBuilder'; +import {getYourSpendSnapshotTransactionRemovalUpdates} from './YourSpendSnapshotUpdate'; type DeleteMoneyRequestFunctionParams = { transactionID: string | undefined; @@ -957,8 +958,18 @@ function deleteMoneyRequest({ reportActionID: reportAction.reportActionID, }; + const yourSpendSnapshotUpdates = getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport, + currentUserAccountID, + }); + // 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, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }); clearPdfByOnyxKey(transactionID); return urlToNavigateBack; diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index acf813ee4d69..c1153bfe5b48 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -41,6 +41,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllPersonalDetails, getAllTransactionViolations} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; import {getReportPreviewAction} from './MoneyRequestBuilder'; +import {getYourSpendSnapshotReportMoveUpdates} from './YourSpendSnapshotUpdate'; type PayInvoiceArgs = { paymentMethodType: PaymentMethodType; @@ -712,6 +713,14 @@ function cancelPayment( }), }); + const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions: getReportTransactions(expenseReport.reportID), + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum, statusNum}, + currentUserAccountID: currentUserAccountIDParam, + }); + API.write( WRITE_COMMANDS.CANCEL_PAYMENT, { @@ -720,7 +729,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); diff --git a/src/libs/actions/IOU/RejectMoneyRequest.ts b/src/libs/actions/IOU/RejectMoneyRequest.ts index d19c1f7b83ba..0232b3f4dbd9 100644 --- a/src/libs/actions/IOU/RejectMoneyRequest.ts +++ b/src/libs/actions/IOU/RejectMoneyRequest.ts @@ -41,6 +41,7 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllReports, getAllTransactions, getAllTransactionViolations} from '.'; +import {getYourSpendSnapshotTransactionRemovalUpdates} from './YourSpendSnapshotUpdate'; type RejectMoneyRequestData = { optimisticData: Array< @@ -52,6 +53,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< @@ -66,6 +68,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; @@ -179,6 +182,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 > > = []; @@ -202,6 +206,7 @@ function prepareRejectMoneyRequestData( | typeof ONYXKEYS.COLLECTION.REPORT_METADATA | typeof ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT > > = []; @@ -895,6 +900,14 @@ function prepareRejectMoneyRequestData( expenseCreatedReportActionID, }; + const yourSpendSnapshotUpdates = getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport: report, + currentUserAccountID: currentUserAccountIDParam, + }); + optimisticData.push(...yourSpendSnapshotUpdates.optimisticData); + failureData.push(...yourSpendSnapshotUpdates.failureData); + return {optimisticData, successData, failureData, parameters, urlToNavigateBack: urlToNavigateBack as Route}; } diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 6a7ca6371c7c..11bc0fa82476 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -255,6 +255,45 @@ function getYourSpendSnapshotReportMoveUpdates({ return result; } +type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { + transaction: OnyxEntry; + iouReport: OnyxEntry; + currentUserAccountID: number; +}; + +/** + * Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or + * reject), subtracting its reimbursable amount from whichever section the report currently belongs to. + */ +function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (!transaction || !iouReport) { + return result; + } + + const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + const amount = Math.abs(getAmount(transaction, isExpenseReportLocal)); + if (amount === 0) { + return result; + } + + const diff = -amount; + const currency = getCurrency(transaction); + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + + if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, currency)); + } + + if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, currency)); + } + + return result; +} + /** * Optimistically patches Your spend snapshot aggregates when a transaction amount changes. * Home reads totals from `snapshot.search.total`, which is only refreshed via search() while online. @@ -287,4 +326,4 @@ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouR return result; } -export {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery}; +export {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotTotalUpdates, getYourSpendSnapshotTransactionRemovalUpdates, transactionMatchesAwaitingApprovalQuery}; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index cc41a74ea2fa..e14a3646109b 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import {getUpdateMoneyRequestParams} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {getYourSpendSnapshotTotalUpdates, transactionMatchesAwaitingApprovalQuery} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import {getYourSpendSnapshotTotalUpdates, getYourSpendSnapshotTransactionRemovalUpdates, transactionMatchesAwaitingApprovalQuery} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {buildAwaitingApprovalQuery} from '@libs/YourSpendQueryUtils'; @@ -163,6 +163,53 @@ describe('getYourSpendSnapshotTotalUpdates', () => { }); }); +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(); + + const {optimisticData, failureData} = getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport: expenseReport, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + }), + ]); + expect(failureData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 30000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + 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, + }); + + 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])); From 421fed671ed89adc8b3c262eb6717efd4b0f543d Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 12 Jun 2026 16:35:01 +0200 Subject: [PATCH 09/32] Fix Your spend offline patches for multi-currency reports --- .../actions/IOU/YourSpendSnapshotUpdate.ts | 98 +++++++++++++------ ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 21 +++- 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 11bc0fa82476..faf6f8dbe7cd 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -3,7 +3,7 @@ import Onyx from 'react-native-onyx'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; -import {getAmount, getCurrency} from '@libs/TransactionUtils'; +import {getAmount, getConvertedAmount, getCurrency} from '@libs/TransactionUtils'; import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDateString} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -195,10 +195,34 @@ function reportInRepaidScope(iouReport: OnyxEntry, accountID: number): b return !!iouReport && iouReport.ownerAccountID === accountID; } -/** Sums the absolute reimbursable amount of a report's transactions, optionally restricted to the last 30 days. */ -function getReportReimbursableTotal(iouReport: OnyxEntry, reportTransactions: Transaction[], onlyWithinLast30Days: boolean): number { +function getSnapshotSearchResults(snapshotHash: number | undefined) { + if (!snapshotHash) { + return undefined; + } + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; + return allSnapshots?.[snapshotKey]?.search; +} + +/** Returns a transaction's reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. */ +function getReimbursableTransactionAmountInCurrency(transaction: Transaction, iouReport: OnyxEntry, targetCurrency: string): number | null { const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + const transactionCurrency = getCurrency(transaction); + + if (transactionCurrency === targetCurrency) { + return Math.abs(getAmount(transaction, isExpenseReportLocal)); + } + if (transaction.convertedAmount != null) { + return Math.abs(getConvertedAmount(transaction, isExpenseReportLocal)); + } + + return null; +} + +/** Sums reimbursable transactions in the snapshot currency, optionally restricted to the last 30 days. */ +function getReportReimbursableTotal(iouReport: OnyxEntry, reportTransactions: Transaction[], onlyWithinLast30Days: boolean, targetCurrency: string): number | null { let total = 0; + let hasReimbursableTransaction = false; + for (const reportTransaction of reportTransactions) { if (!reportTransaction || reportTransaction.reimbursable === false) { continue; @@ -209,9 +233,16 @@ function getReportReimbursableTotal(iouReport: OnyxEntry, reportTransact continue; } } - total += Math.abs(getAmount(reportTransaction, isExpenseReportLocal)); + + hasReimbursableTransaction = true; + const amount = getReimbursableTransactionAmountInCurrency(reportTransaction, iouReport, targetCurrency); + if (amount === null) { + return null; + } + total += amount; } - return total; + + return hasReimbursableTransaction ? total : 0; } /** @@ -231,24 +262,31 @@ function getYourSpendSnapshotReportMoveUpdates({ return result; } - const currency = iouReport.currency ?? CONST.CURRENCY.USD; const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); - - if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs)) { - const total = getReportReimbursableTotal(iouReport, reportTransactions, false); - const diff = (isAwaitingApprovalStatus(toStatus) ? total : 0) - (isAwaitingApprovalStatus(fromStatus) ? total : 0); - if (diff !== 0) { - const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, currency)); + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + const approvalSnapshotCurrency = getSnapshotSearchResults(approvalQueryJSON?.hash)?.currency; + + if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotCurrency) { + const total = getReportReimbursableTotal(iouReport, reportTransactions, false, approvalSnapshotCurrency); + if (total !== null) { + const diff = (isAwaitingApprovalStatus(toStatus) ? total : 0) - (isAwaitingApprovalStatus(fromStatus) ? total : 0); + if (diff !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalSnapshotCurrency)); + } } } if (reportInRepaidScope(iouReport, currentUserAccountID)) { - const total = getReportReimbursableTotal(iouReport, reportTransactions, true); - const diff = (isRepaidStatus(toStatus) ? total : 0) - (isRepaidStatus(fromStatus) ? total : 0); - if (diff !== 0) { - const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, currency)); + const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); + const paymentSnapshotCurrency = getSnapshotSearchResults(paymentQueryJSON?.hash)?.currency; + if (paymentSnapshotCurrency) { + const total = getReportReimbursableTotal(iouReport, reportTransactions, true, paymentSnapshotCurrency); + if (total !== null) { + const diff = (isRepaidStatus(toStatus) ? total : 0) - (isRepaidStatus(fromStatus) ? total : 0); + if (diff !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentSnapshotCurrency)); + } + } } } @@ -271,24 +309,28 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, return result; } - const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); - const amount = Math.abs(getAmount(transaction, isExpenseReportLocal)); - if (amount === 0) { - return result; - } - - const diff = -amount; - const currency = getCurrency(transaction); const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, currency)); + const approvalSnapshotCurrency = getSnapshotSearchResults(approvalQueryJSON?.hash)?.currency; + if (approvalSnapshotCurrency) { + const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, approvalSnapshotCurrency); + if (amount !== null && amount !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, -amount, approvalSnapshotCurrency)); + } + } } if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, currency)); + const paymentSnapshotCurrency = getSnapshotSearchResults(paymentQueryJSON?.hash)?.currency; + if (paymentSnapshotCurrency) { + const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, paymentSnapshotCurrency); + if (amount !== null && amount !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, -amount, paymentSnapshotCurrency)); + } + } } return result; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index a7fb49f157a9..bf6fa661f49b 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -265,7 +265,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toHaveLength(0); }); - it('does not patch when the snapshot currency differs from the report currency', async () => { + it('does not patch when the transaction cannot be converted into the snapshot currency', async () => { await seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.EUR); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ @@ -278,4 +278,23 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toHaveLength(0); }); + + it('patches using convertedAmount when the snapshot currency differs from the transaction currency', async () => { + const snapshotKey = await seedAwaitingApprovalSnapshot(10000); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport({...SUBMITTED_STATUS, currency: CONST.CURRENCY.EUR}), + reportTransactions: [buildTransaction({currency: CONST.CURRENCY.EUR, amount: -10000, convertedAmount: -5000})], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 15000, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); }); From fa6b8baa584fdd31c2f9222a7521814e40a437b5 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 17 Jun 2026 14:29:07 +0200 Subject: [PATCH 10/32] Patch Your spend totals on approve Wire approveMoneyRequest to getYourSpendSnapshotReportMoveUpdates so an offline self-approval optimistically removes the report's reimbursable total from the "Awaiting approval" section, with rollback on failure. DEW policies are skipped since they don't optimistically move report state. --- src/libs/actions/IOU/ReportWorkflow.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index c52c209c9ff2..39da3e0ee940 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -753,9 +753,25 @@ 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 yourSpendSnapshotUpdates = isDEWPolicy + ? {optimisticData: [], successData: [], failureData: []} + : getYourSpendSnapshotReportMoveUpdates({ + iouReport: expenseReport, + reportTransactions, + fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, + toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, + currentUserAccountID: currentUserAccountIDParam, + }); + onApproved?.(); playSound(SOUNDS.SUCCESS); - API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST, parameters, { + optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], + successData: [...successData, ...yourSpendSnapshotUpdates.successData], + failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + }); return optimisticHoldReportID; } From c3d8ad16f369138e87e1a4889a6dbc9ed1e4dbce Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 17 Jun 2026 14:47:17 +0200 Subject: [PATCH 11/32] Patch Your spend totals on report-level reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire getYourSpendSnapshotReportMoveUpdates into rejectExpenseReport so the Home "Your spend" → "Awaiting approval" total updates optimistically offline when a submitted report is rejected, with success/failure rollback data. --- src/libs/actions/IOU/RejectMoneyRequest.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/RejectMoneyRequest.ts b/src/libs/actions/IOU/RejectMoneyRequest.ts index 0232b3f4dbd9..cc6212935774 100644 --- a/src/libs/actions/IOU/RejectMoneyRequest.ts +++ b/src/libs/actions/IOU/RejectMoneyRequest.ts @@ -41,7 +41,7 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllReports, getAllTransactions, getAllTransactionViolations} from '.'; -import {getYourSpendSnapshotTransactionRemovalUpdates} from './YourSpendSnapshotUpdate'; +import {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotTransactionRemovalUpdates} from './YourSpendSnapshotUpdate'; type RejectMoneyRequestData = { optimisticData: Array< @@ -1188,7 +1188,19 @@ 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, + }); + + 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}; From 6035a7afa2c987eeae97ad8a1af34d8a5d6d2a8b Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 17 Jun 2026 15:15:28 +0200 Subject: [PATCH 12/32] Patch Your spend snapshot count alongside total The Home "Your spend" row visibility is driven by search.count, not total (see getYourSpendRowState), so patching only total desynced two cases: adding into a previously empty bucket stayed hidden, and removing the last item showed a stale $0.00 instead of hiding. Thread a count delta through buildSnapshotTotalUpdatesForHash (clamped at 0, restored on rollback): report moves add/remove the report's reimbursable transaction count, transaction removals decrement by one, and amount edits stay count-neutral. --- .../actions/IOU/YourSpendSnapshotUpdate.ts | 67 +++++++++++++------ ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 43 +++++++++--- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 7 +- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index faf6f8dbe7cd..0c537b5a005d 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -1,5 +1,6 @@ import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +// eslint-disable-next-line no-restricted-imports -- Your spend "Awaiting approval"/"Repaid" totals are a billing/paid-only feature (Collect/Control), so paid-group scoping is intentional here. import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; @@ -88,8 +89,8 @@ function transactionMatchesRepaidLast30DaysQuery(iouReport: OnyxEntry, t return created >= get30DaysAgoDateString(); } -function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff: number, currency: string): YourSpendSnapshotOnyxData { - if (!snapshotHash || diff === 0) { +function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff: number, currency: string, countDiff = 0): YourSpendSnapshotOnyxData { + if (!snapshotHash || (diff === 0 && countDiff === 0)) { return {optimisticData: [], successData: [], failureData: []}; } @@ -97,6 +98,7 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff const currentSnapshot = allSnapshots?.[snapshotKey]; const currentTotal = currentSnapshot?.search?.total; const currentCurrency = currentSnapshot?.search?.currency; + const currentCount = currentSnapshot?.search?.count ?? 0; if (currentTotal === undefined || currentTotal === null) { return {optimisticData: [], successData: [], failureData: []}; @@ -106,6 +108,9 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff } const updatedTotal = currentTotal + diff; + // `count` drives the Home row's visibility (see getYourSpendRowState): it must move in lockstep with `total`, + // otherwise an add into a previously empty bucket stays hidden and a removal of the last item shows a stale $0.00. + const updatedCount = Math.max(0, currentCount + countDiff); return { optimisticData: [ { @@ -114,6 +119,7 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff value: { search: { total: updatedTotal, + count: updatedCount, currency, }, }, @@ -127,6 +133,7 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff value: { search: { total: currentTotal, + count: currentCount, currency: currentCurrency ?? currency, }, }, @@ -218,10 +225,22 @@ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, io return null; } -/** Sums reimbursable transactions in the snapshot currency, optionally restricted to the last 30 days. */ -function getReportReimbursableTotal(iouReport: OnyxEntry, reportTransactions: Transaction[], onlyWithinLast30Days: boolean, targetCurrency: string): number | null { +type ReportReimbursableAggregate = { + total: number; + // Number of reimbursable transactions contributing to `total`. Kept alongside `total` so the snapshot's + // result `count` can be patched in lockstep when the report enters or leaves a Your spend section. + count: number; +}; + +/** Sums reimbursable transactions (and counts them) in the snapshot currency, optionally restricted to the last 30 days. */ +function getReportReimbursableTotal( + iouReport: OnyxEntry, + reportTransactions: Transaction[], + onlyWithinLast30Days: boolean, + targetCurrency: string, +): ReportReimbursableAggregate | null { let total = 0; - let hasReimbursableTransaction = false; + let count = 0; for (const reportTransaction of reportTransactions) { if (!reportTransaction || reportTransaction.reimbursable === false) { @@ -234,15 +253,15 @@ function getReportReimbursableTotal(iouReport: OnyxEntry, reportTransact } } - hasReimbursableTransaction = true; const amount = getReimbursableTransactionAmountInCurrency(reportTransaction, iouReport, targetCurrency); if (amount === null) { return null; } total += amount; + count += 1; } - return hasReimbursableTransaction ? total : 0; + return {total, count}; } /** @@ -267,11 +286,14 @@ function getYourSpendSnapshotReportMoveUpdates({ const approvalSnapshotCurrency = getSnapshotSearchResults(approvalQueryJSON?.hash)?.currency; if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotCurrency) { - const total = getReportReimbursableTotal(iouReport, reportTransactions, false, approvalSnapshotCurrency); - if (total !== null) { - const diff = (isAwaitingApprovalStatus(toStatus) ? total : 0) - (isAwaitingApprovalStatus(fromStatus) ? total : 0); - if (diff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalSnapshotCurrency)); + const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, false, approvalSnapshotCurrency); + if (aggregate !== null) { + const enters = isAwaitingApprovalStatus(toStatus); + const leaves = isAwaitingApprovalStatus(fromStatus); + const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); + const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); + if (diff !== 0 || countDiff !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalSnapshotCurrency, countDiff)); } } } @@ -280,11 +302,14 @@ function getYourSpendSnapshotReportMoveUpdates({ const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); const paymentSnapshotCurrency = getSnapshotSearchResults(paymentQueryJSON?.hash)?.currency; if (paymentSnapshotCurrency) { - const total = getReportReimbursableTotal(iouReport, reportTransactions, true, paymentSnapshotCurrency); - if (total !== null) { - const diff = (isRepaidStatus(toStatus) ? total : 0) - (isRepaidStatus(fromStatus) ? total : 0); - if (diff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentSnapshotCurrency)); + const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, true, paymentSnapshotCurrency); + if (aggregate !== null) { + const enters = isRepaidStatus(toStatus); + const leaves = isRepaidStatus(fromStatus); + const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); + const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); + if (diff !== 0 || countDiff !== 0) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentSnapshotCurrency, countDiff)); } } } @@ -316,8 +341,8 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, const approvalSnapshotCurrency = getSnapshotSearchResults(approvalQueryJSON?.hash)?.currency; if (approvalSnapshotCurrency) { const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, approvalSnapshotCurrency); - if (amount !== null && amount !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, -amount, approvalSnapshotCurrency)); + if (amount !== null) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, -amount, approvalSnapshotCurrency, -1)); } } } @@ -327,8 +352,8 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, const paymentSnapshotCurrency = getSnapshotSearchResults(paymentQueryJSON?.hash)?.currency; if (paymentSnapshotCurrency) { const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, paymentSnapshotCurrency); - if (amount !== null && amount !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, -amount, paymentSnapshotCurrency)); + if (amount !== null) { + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, -amount, paymentSnapshotCurrency, -1)); } } } diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index bf6fa661f49b..eb2098dd4b30 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -33,8 +33,8 @@ function getSnapshotKey(hash: number): `${typeof ONYXKEYS.COLLECTION.SNAPSHOT}${ return `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`; } -/** Builds a fully-typed `SearchResults` snapshot with the given total/currency. */ -function buildSnapshotSearchResults(total: number, currency: string): SearchResults { +/** 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, @@ -43,7 +43,7 @@ function buildSnapshotSearchResults(total: number, currency: string): SearchResu hasMoreResults: false, hasResults: true, isLoading: false, - count: 1, + count, total, currency, }, @@ -133,13 +133,13 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 20000, count: 2, currency: CONST.CURRENCY.USD}}, }), ]); expect(failureData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 10000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 10000, count: 1, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -158,7 +158,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -177,7 +177,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -196,7 +196,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 20000, count: 2, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -215,7 +215,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -293,7 +293,30 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 15000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 15000, count: 2, currency: CONST.CURRENCY.USD}}, + }), + ]); + }); + + it('adds the report total to a previously empty awaiting approval bucket (count 0 -> visible)', 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(0, CONST.CURRENCY.USD, 0)); + await waitForBatchedUpdates(); + + const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ + iouReport: buildExpenseReport(SUBMITTED_STATUS), + reportTransactions: [buildTransaction()], + fromStatus: OPEN_STATUS, + toStatus: SUBMITTED_STATUS, + currentUserAccountID: ACCOUNT_ID, + }); + + expect(optimisticData).toEqual([ + expect.objectContaining({ + key: snapshotKey, + value: {search: {total: 10000, count: 1, currency: CONST.CURRENCY.USD}}, }), ]); }); diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index e14a3646109b..5131681be0c2 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -121,6 +121,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { value: { search: { total: 20000, + count: 1, currency: CONST.CURRENCY.USD, }, }, @@ -132,6 +133,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { value: { search: { total: 10000, + count: 1, currency: CONST.CURRENCY.USD, }, }, @@ -181,13 +183,13 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, }), ]); expect(failureData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 30000, currency: CONST.CURRENCY.USD}}, + value: {search: {total: 30000, count: 1, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -242,6 +244,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { value: { search: { total: 20000, + count: 1, currency: CONST.CURRENCY.USD, }, }, From 2bf9c548f86e1c125ccfb32a1774f29c28fc9310 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 18 Jun 2026 13:24:21 +0200 Subject: [PATCH 13/32] Fix Your spend offline patch using convertedAmount in wrong currency convertedAmount is denominated in the report's policy output currency, which may differ from the Your spend snapshot currency. Only trust it when the policy outputCurrency matches the snapshot currency, otherwise skip the optimistic patch and let the next online refresh reconcile. --- .../actions/IOU/YourSpendSnapshotUpdate.ts | 5 ++++- ...etYourSpendSnapshotReportMoveUpdatesTest.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 0c537b5a005d..01858be5398e 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -218,7 +218,10 @@ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, io if (transactionCurrency === targetCurrency) { return Math.abs(getAmount(transaction, isExpenseReportLocal)); } - if (transaction.convertedAmount != null) { + // `convertedAmount` is denominated in the report's policy output currency, not necessarily the snapshot + // currency. Only trust it when those match; otherwise we'd add a value in the wrong currency to the total. + const policyOutputCurrency = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]?.outputCurrency : undefined; + if (transaction.convertedAmount != null && policyOutputCurrency === targetCurrency) { return Math.abs(getConvertedAmount(transaction, isExpenseReportLocal)); } diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index eb2098dd4b30..21617ebb201f 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -298,6 +298,24 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { ]); }); + it('does not patch with convertedAmount when the policy output currency differs from the snapshot currency', async () => { + // 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. + await seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.USD); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...paidPolicy, outputCurrency: CONST.CURRENCY.EUR}); + await waitForBatchedUpdates(); + + 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, + }); + + expect(optimisticData).toHaveLength(0); + }); + it('adds the report total to a previously empty awaiting approval bucket (count 0 -> visible)', async () => { const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(ACCOUNT_ID, [POLICY_ID])); const snapshotKey = getSnapshotKey(approvalQueryJSON?.hash ?? 0); From 52e56dd639a655571634cede5f14ceb2dfd05ca6 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 19 Jun 2026 14:15:55 +0200 Subject: [PATCH 14/32] Fix Your spend section not appearing when submitting first report offline When the awaiting-approval section was empty, its snapshot existed with null currency/count/total. The optimistic patch bailed (currency guard) and the builder bailed on a null total, so submitting the first report offline never populated the snapshot count and the row stayed hidden. Treat a loaded-but-empty snapshot as a zero base and fall back to the report currency so the row appears. --- .../actions/IOU/YourSpendSnapshotUpdate.ts | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 01858be5398e..fee758171622 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -96,13 +96,20 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; const currentSnapshot = allSnapshots?.[snapshotKey]; - const currentTotal = currentSnapshot?.search?.total; - const currentCurrency = currentSnapshot?.search?.currency; - const currentCount = currentSnapshot?.search?.count ?? 0; + const search = currentSnapshot?.search; - if (currentTotal === undefined || currentTotal === null) { + // Only bail when the snapshot hasn't been loaded at all — writing into a non-existent snapshot would create a + // partial/garbage entry. A loaded-but-empty snapshot (e.g. the section had nothing awaiting approval, so its + // `total`/`count`/`currency` are still null) is a valid zero base: the first report submitted offline should + // populate and reveal the row. + 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: []}; } @@ -132,9 +139,11 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff key: snapshotKey, value: { search: { - total: currentTotal, - count: currentCount, - currency: currentCurrency ?? currency, + // Restore the exact prior state. When the section was empty these were null, so rolling back + // to null (Onyx MERGE removes the key) cleanly re-hides the row. + total: search.total ?? null, + count: search.count ?? null, + currency: currentCurrency ?? null, }, }, }, @@ -286,33 +295,39 @@ function getYourSpendSnapshotReportMoveUpdates({ const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - const approvalSnapshotCurrency = getSnapshotSearchResults(approvalQueryJSON?.hash)?.currency; - - if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotCurrency) { - const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, false, approvalSnapshotCurrency); + const approvalSnapshotSearch = getSnapshotSearchResults(approvalQueryJSON?.hash); + const approvalSnapshotCurrency = approvalSnapshotSearch?.currency; + // When the section is currently empty its snapshot has no currency yet, so fall back to the report's currency. + // This lets the first report submitted offline populate (and reveal) the row; the backend corrects the + // currency/total on the next online refresh. + const approvalTargetCurrency = approvalSnapshotCurrency ?? iouReport.currency; + + if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotSearch && approvalTargetCurrency) { + const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, false, approvalTargetCurrency); if (aggregate !== null) { const enters = isAwaitingApprovalStatus(toStatus); const leaves = isAwaitingApprovalStatus(fromStatus); const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); if (diff !== 0 || countDiff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalSnapshotCurrency, countDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalTargetCurrency, countDiff)); } } } if (reportInRepaidScope(iouReport, currentUserAccountID)) { const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - const paymentSnapshotCurrency = getSnapshotSearchResults(paymentQueryJSON?.hash)?.currency; - if (paymentSnapshotCurrency) { - const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, true, paymentSnapshotCurrency); + const paymentSnapshotSearch = getSnapshotSearchResults(paymentQueryJSON?.hash); + const paymentTargetCurrency = paymentSnapshotSearch?.currency ?? iouReport.currency; + if (paymentSnapshotSearch && paymentTargetCurrency) { + const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, true, paymentTargetCurrency); if (aggregate !== null) { const enters = isRepaidStatus(toStatus); const leaves = isRepaidStatus(fromStatus); const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); if (diff !== 0 || countDiff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentSnapshotCurrency, countDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentTargetCurrency, countDiff)); } } } From a5d907480ba91c1e58faca8e9edbd493a61e9353 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 19 Jun 2026 14:38:37 +0200 Subject: [PATCH 15/32] Patch Your spend "Awaiting approval" total when splitting a submitted expense offline Splitting a SUBMITTED expense changed the report's reimbursable total but never patched the cached "Awaiting approval" snapshot, so the Home total stayed stale offline. Add getYourSpendSnapshotSplitUpdates and wire it into updateSplitTransactions using the existing report-total delta. --- .../actions/IOU/SplitTransactionUpdate.ts | 15 +++++++ .../actions/IOU/YourSpendSnapshotUpdate.ts | 41 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 45b1f71b6466..a11141f8cfcd 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -66,6 +66,7 @@ import {addPendingNewTransactionIDs} from './PendingNewTransactions'; import {getDeleteTrackExpenseInformation} from './TrackExpense'; import {getUpdateMoneyRequestParams} from './UpdateMoneyRequest'; import type {UpdateMoneyRequestDataKeys} from './UpdateMoneyRequest'; +import {getYourSpendSnapshotSplitUpdates} from './YourSpendSnapshotUpdate'; type UpdateSplitTransactionsParams = { allTransactionsList: OnyxCollection; @@ -1787,6 +1788,20 @@ function updateSplitTransactions({ } } + // A split keeps a SUBMITTED report in the "Awaiting approval" section but changes its reimbursable total + // (e.g. split to a lower amount). Home reads that total from the cached snapshot, which is only refreshed + // online, so patch the same-currency delta optimistically. `changesInReportTotal` is the signed report-total + // delta (new split total minus the pre-split total) in the transaction currency. + const yourSpendSplitUpdates = getYourSpendSnapshotSplitUpdates({ + iouReport: expenseReport, + originalTransaction, + reimbursableDiff: changesInReportTotal, + currentUserAccountID: currentUserPersonalDetails.accountID, + }); + onyxData.optimisticData?.push(...yourSpendSplitUpdates.optimisticData); + onyxData.successData?.push(...yourSpendSplitUpdates.successData); + onyxData.failureData?.push(...yourSpendSplitUpdates.failureData); + if (isReverseSplitOperation) { const parameters = { ...splits.at(0), diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index fee758171622..1a398cad0440 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -411,4 +411,43 @@ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouR return result; } -export {getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotTotalUpdates, getYourSpendSnapshotTransactionRemovalUpdates, transactionMatchesAwaitingApprovalQuery}; +type GetYourSpendSnapshotSplitUpdatesParams = { + iouReport: OnyxEntry; + originalTransaction: OnyxEntry; + // Signed change to the report's reimbursable total, in the transaction/report currency + // (new split total minus the pre-split total). Negative when the expense is split to a lower amount. + reimbursableDiff: number; + currentUserAccountID: number; +}; + +/** + * Optimistically patches the "Awaiting approval" snapshot total when a SUBMITTED expense is split and the resulting + * reimbursable total changes (e.g. split to a lower amount). The report stays in the awaiting-approval section, so this + * is a same-section amount delta rather than a state move. Home reads totals from `snapshot.search.total`, which is only + * refreshed online. Mirrors the amount-edit path: same-currency only (the currency guard in + * `buildSnapshotTotalUpdatesForHash` skips a mismatch, since FX conversion isn't available offline). + */ +function getYourSpendSnapshotSplitUpdates({iouReport, originalTransaction, reimbursableDiff, currentUserAccountID}: GetYourSpendSnapshotSplitUpdatesParams): YourSpendSnapshotOnyxData { + const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; + if (!iouReport || !originalTransaction || reimbursableDiff === 0) { + return result; + } + + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + if (!transactionMatchesAwaitingApprovalQuery(iouReport, originalTransaction, currentUserAccountID, paidGroupPolicyIDs)) { + return result; + } + + const currency = getCurrency(originalTransaction); + const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, reimbursableDiff, currency)); + return result; +} + +export { + getYourSpendSnapshotReportMoveUpdates, + getYourSpendSnapshotSplitUpdates, + getYourSpendSnapshotTotalUpdates, + getYourSpendSnapshotTransactionRemovalUpdates, + transactionMatchesAwaitingApprovalQuery, +}; From 4e6f872a4e0044ade3281eb01176a5a8917931ca Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 25 Jun 2026 15:38:24 +0200 Subject: [PATCH 16/32] Fix Your spend offline total sign and empty Search page after submit Use signed getAmount instead of Math.abs when patching Your spend snapshot totals so the section reflects spend as negative (and credit lines reduce the total) instead of a positive, inflated value. Also inject a moving report's reimbursable transactions into the snapshot data when it enters a section so the linked Search page is not empty offline, and remove them on leave/rollback. Update the related unit tests to the corrected signed convention and data writes. --- .../actions/IOU/YourSpendSnapshotUpdate.ts | 65 ++++++++++++++-- ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 77 ++++++++++++++----- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 24 +++--- 3 files changed, 131 insertions(+), 35 deletions(-) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 1a398cad0440..9cbbd650edcd 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -1,4 +1,4 @@ -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports -- Your spend "Awaiting approval"/"Repaid" totals are a billing/paid-only feature (Collect/Control), so paid-group scoping is intentional here. import {isPaidGroupPolicy} from '@libs/PolicyUtils'; @@ -9,6 +9,7 @@ import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDate import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; type YourSpendSnapshotOnyxData = { optimisticData: Array>; @@ -172,8 +173,10 @@ function calculateYourSpendTotalDiff(iouReport: OnyxEntry, updatedTransa return null; } - const currentTotal = Math.abs(getAmount(transaction, isExpenseReportLocal)); - const updatedTotal = Math.abs(getAmount(updatedTransaction, isExpenseReportLocal)); + // Signed to match the snapshot `total` convention (spend negative, credits positive), so the delta moves the + // snapshot total in the correct direction (e.g. raising a spend amount makes the negative total more negative). + const currentTotal = getAmount(transaction, isExpenseReportLocal); + const updatedTotal = getAmount(updatedTransaction, isExpenseReportLocal); if (currentTotal === updatedTotal) { return 0; @@ -219,19 +222,23 @@ function getSnapshotSearchResults(snapshotHash: number | undefined) { return allSnapshots?.[snapshotKey]?.search; } -/** Returns a transaction's reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. */ +/** + * Returns a transaction's reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. + * The value is signed to match the search `total` convention (spend is negative, credits positive), i.e. it mirrors + * `getAmount(transaction, isFromExpenseReport)` rather than its magnitude. + */ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, iouReport: OnyxEntry, targetCurrency: string): number | null { const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); const transactionCurrency = getCurrency(transaction); if (transactionCurrency === targetCurrency) { - return Math.abs(getAmount(transaction, isExpenseReportLocal)); + return getAmount(transaction, isExpenseReportLocal); } // `convertedAmount` is denominated in the report's policy output currency, not necessarily the snapshot // currency. Only trust it when those match; otherwise we'd add a value in the wrong currency to the total. const policyOutputCurrency = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]?.outputCurrency : undefined; if (transaction.convertedAmount != null && policyOutputCurrency === targetCurrency) { - return Math.abs(getConvertedAmount(transaction, isExpenseReportLocal)); + return getConvertedAmount(transaction, isExpenseReportLocal); } return null; @@ -242,6 +249,10 @@ type ReportReimbursableAggregate = { // Number of reimbursable transactions contributing to `total`. Kept alongside `total` so the snapshot's // result `count` can be patched in lockstep when the report enters or leaves a Your spend section. count: number; + // The reimbursable transactions that contributed to `total`. The Home row reads its amount from + // `snapshot.search.total`, but the Search page renders its list from `snapshot.data`. These are injected into + // `data` so an offline-submitted report's expenses are visible when the user opens the section offline. + transactions: Transaction[]; }; /** Sums reimbursable transactions (and counts them) in the snapshot currency, optionally restricted to the last 30 days. */ @@ -253,6 +264,7 @@ function getReportReimbursableTotal( ): ReportReimbursableAggregate | null { let total = 0; let count = 0; + const transactions: Transaction[] = []; for (const reportTransaction of reportTransactions) { if (!reportTransaction || reportTransaction.reimbursable === false) { @@ -271,9 +283,46 @@ function getReportReimbursableTotal( } total += amount; count += 1; + transactions.push(reportTransaction); + } + + return {total, count, transactions}; +} + +/** + * Builds the snapshot `data` writes that add (or remove) a report's reimbursable transactions when it enters (or + * leaves) a Your spend section. The Home row only needs `search.total`/`count`, but the Search page the row links to + * renders its list from `snapshot.data`; without these writes an offline-submitted report's section opens empty. + */ +function buildSnapshotDataUpdatesForHash(snapshotHash: number | undefined, transactions: Transaction[], enters: boolean, leaves: boolean): YourSpendSnapshotOnyxData { + // Only a section crossing (enter XOR leave) changes membership. Staying put, or no transactions, is a no-op. + if (!snapshotHash || transactions.length === 0 || enters === leaves) { + return {optimisticData: [], successData: [], failureData: []}; } - return {total, count}; + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; + // Only patch a snapshot that's actually been loaded; writing into a missing one would create a partial entry. + if (!allSnapshots?.[snapshotKey]?.search) { + 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; + } + + // Entering adds the transactions (rollback removes them); leaving removes them (rollback restores them). + 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}}], + }; } /** @@ -311,6 +360,7 @@ function getYourSpendSnapshotReportMoveUpdates({ const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); if (diff !== 0 || countDiff !== 0) { mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalTargetCurrency, countDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(approvalQueryJSON?.hash, aggregate.transactions, enters, leaves)); } } } @@ -328,6 +378,7 @@ function getYourSpendSnapshotReportMoveUpdates({ const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); if (diff !== 0 || countDiff !== 0) { mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentTargetCurrency, countDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(paymentQueryJSON?.hash, aggregate.transactions, enters, leaves)); } } } diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index 21617ebb201f..61f27e86e368 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -70,11 +70,13 @@ function buildExpenseReport(overrides: Partial = {}): Report { } 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, + amount: 10000, currency: CONST.CURRENCY.USD, reimbursable: true, created: '2026-01-15', @@ -83,6 +85,8 @@ function buildTransaction(overrides: Partial = {}): Transaction { } as Transaction; } +const TRANSACTION_KEY = `${ONYXKEYS.COLLECTION.TRANSACTION}reportMoveTxn` as const; + beforeAll(() => { Onyx.init({ keys: ONYXKEYS, @@ -120,7 +124,7 @@ async function seedRepaidSnapshot(total: number, currency: string = CONST.CURREN describe('getYourSpendSnapshotReportMoveUpdates', () => { it('adds the report total to awaiting approval when a report is submitted (OPEN -> SUBMITTED)', async () => { - const snapshotKey = await seedAwaitingApprovalSnapshot(10000); + const snapshotKey = await seedAwaitingApprovalSnapshot(-10000); const {optimisticData, failureData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(SUBMITTED_STATUS), @@ -130,22 +134,32 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { currentUserAccountID: ACCOUNT_ID, }); + // 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}}, + 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}}, + 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 = await seedAwaitingApprovalSnapshot(30000); + const snapshotKey = await seedAwaitingApprovalSnapshot(-30000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(OPEN_STATUS), @@ -155,16 +169,21 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { currentUserAccountID: ACCOUNT_ID, }); + // 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}}, + 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 = await seedAwaitingApprovalSnapshot(30000); + const snapshotKey = await seedAwaitingApprovalSnapshot(-30000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(OPEN_STATUS), @@ -177,13 +196,17 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, + 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 = await seedAwaitingApprovalSnapshot(10000); + const snapshotKey = await seedAwaitingApprovalSnapshot(-10000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(SUBMITTED_STATUS), @@ -196,17 +219,22 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, count: 2, currency: CONST.CURRENCY.USD}}, + 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 = await seedRepaidSnapshot(30000); + const snapshotKey = await seedRepaidSnapshot(-30000); + const recentTransaction = buildTransaction({created: getRecentCreatedDate()}); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(APPROVED_STATUS), - reportTransactions: [buildTransaction({created: getRecentCreatedDate()})], + reportTransactions: [recentTransaction], fromStatus: REIMBURSED_STATUS, toStatus: APPROVED_STATUS, currentUserAccountID: ACCOUNT_ID, @@ -215,7 +243,11 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: null}}, }), ]); }); @@ -280,11 +312,12 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { }); it('patches using convertedAmount when the snapshot currency differs from the transaction currency', async () => { - const snapshotKey = await seedAwaitingApprovalSnapshot(10000); + const snapshotKey = await 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: [buildTransaction({currency: CONST.CURRENCY.EUR, amount: -10000, convertedAmount: -5000})], + reportTransactions: [convertedTransaction], fromStatus: OPEN_STATUS, toStatus: SUBMITTED_STATUS, currentUserAccountID: ACCOUNT_ID, @@ -293,7 +326,11 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 15000, count: 2, currency: CONST.CURRENCY.USD}}, + value: {search: {total: -15000, count: 2, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: convertedTransaction}}, }), ]); }); @@ -307,7 +344,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport({...SUBMITTED_STATUS, currency: CONST.CURRENCY.GBP}), - reportTransactions: [buildTransaction({currency: CONST.CURRENCY.GBP, amount: -10000, convertedAmount: -5000})], + reportTransactions: [buildTransaction({currency: CONST.CURRENCY.GBP, amount: 10000, convertedAmount: 5000})], fromStatus: OPEN_STATUS, toStatus: SUBMITTED_STATUS, currentUserAccountID: ACCOUNT_ID, @@ -334,7 +371,11 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 10000, count: 1, currency: CONST.CURRENCY.USD}}, + value: {search: {total: -10000, count: 1, currency: CONST.CURRENCY.USD}}, + }), + expect.objectContaining({ + key: snapshotKey, + value: {data: {[TRANSACTION_KEY]: buildTransaction()}}, }), ]); }); diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index 5131681be0c2..dfa3de99c974 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -65,10 +65,12 @@ const transactionThreadReport: Report = { 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, + amount: 10000, currency: CONST.CURRENCY.USD, reimbursable: true, created: '2026-01-15', @@ -98,7 +100,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { 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 Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); await waitForBatchedUpdates(); expect(transactionMatchesAwaitingApprovalQuery(expenseReport, transaction, ACCOUNT_ID, [POLICY_ID])).toBe(true); @@ -108,6 +110,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { modifiedAmount: 20000, }; + // Raising the spend from 100 to 200 makes the (negative) section total more negative: -100 -> -200. const {optimisticData, failureData} = getYourSpendSnapshotTotalUpdates({ transaction, updatedTransaction, @@ -120,7 +123,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { key: snapshotKey, value: { search: { - total: 20000, + total: -20000, count: 1, currency: CONST.CURRENCY.USD, }, @@ -132,7 +135,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { key: snapshotKey, value: { search: { - total: 10000, + total: -10000, count: 1, currency: CONST.CURRENCY.USD, }, @@ -171,9 +174,10 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { 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 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, @@ -183,13 +187,13 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { expect(optimisticData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 20000, count: 0, currency: CONST.CURRENCY.USD}}, + value: {search: {total: -20000, count: 0, currency: CONST.CURRENCY.USD}}, }), ]); expect(failureData).toEqual([ expect.objectContaining({ key: snapshotKey, - value: {search: {total: 30000, count: 1, currency: CONST.CURRENCY.USD}}, + value: {search: {total: -30000, count: 1, currency: CONST.CURRENCY.USD}}, }), ]); }); @@ -219,7 +223,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { 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 Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); await waitForBatchedUpdates(); const {onyxData} = getUpdateMoneyRequestParams({ @@ -243,7 +247,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { expect.objectContaining({ value: { search: { - total: 20000, + total: -20000, count: 1, currency: CONST.CURRENCY.USD, }, @@ -258,7 +262,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { 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 Onyx.set(snapshotKey, buildSnapshotSearchResults(-10000, CONST.CURRENCY.USD)); await waitForBatchedUpdates(); const {onyxData} = getUpdateMoneyRequestParams({ From 72448a2a10a868467887a56c8bb1867d70729be9 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 25 Jun 2026 16:03:35 +0200 Subject: [PATCH 17/32] Fix Your Spend section disappearing after deleting a split expense offline --- .../actions/IOU/SplitTransactionUpdate.ts | 13 +++++++++ .../actions/IOU/YourSpendSnapshotUpdate.ts | 28 +++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index a11141f8cfcd..130da96934bd 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1792,10 +1792,23 @@ function updateSplitTransactions({ // (e.g. split to a lower amount). Home reads that total from the cached snapshot, which is only refreshed // online, so patch the same-currency delta optimistically. `changesInReportTotal` is the signed report-total // delta (new split total minus the pre-split total) in the transaction currency. + // The split also changes how many reimbursable transactions the report holds (e.g. one expense becomes several). + // `count` drives the Home "Awaiting approval" row visibility, so it must move in lockstep with the membership + // change — otherwise a later deletion of one split child decrements a stale `count` to 0 and hides the row even + // though split children remain. The replaced set is the original transaction (creation of splits) or the existing + // split children (re-splitting); the new set is `splits`. A `reimbursable !== false` transaction counts (matching + // the snapshot query convention). + 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 yourSpendSplitUpdates = getYourSpendSnapshotSplitUpdates({ iouReport: expenseReport, originalTransaction, reimbursableDiff: changesInReportTotal, + reimbursableCountDiff, currentUserAccountID: currentUserPersonalDetails.accountID, }); onyxData.optimisticData?.push(...yourSpendSplitUpdates.optimisticData); diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 9cbbd650edcd..62322c4a8cf0 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -468,19 +468,31 @@ type GetYourSpendSnapshotSplitUpdatesParams = { // Signed change to the report's reimbursable total, in the transaction/report currency // (new split total minus the pre-split total). Negative when the expense is split to a lower amount. reimbursableDiff: number; + // Signed change to the number of reimbursable transactions in the report caused by the split + // (e.g. splitting one expense into two reimbursable children is +1). `count` drives the Home row's + // visibility, so it must move in lockstep with the membership change even when `reimbursableDiff` is 0 + // (an equal-amount split leaves the total unchanged but still adds transactions). + reimbursableCountDiff: number; currentUserAccountID: number; }; /** - * Optimistically patches the "Awaiting approval" snapshot total when a SUBMITTED expense is split and the resulting - * reimbursable total changes (e.g. split to a lower amount). The report stays in the awaiting-approval section, so this - * is a same-section amount delta rather than a state move. Home reads totals from `snapshot.search.total`, which is only - * refreshed online. Mirrors the amount-edit path: same-currency only (the currency guard in - * `buildSnapshotTotalUpdatesForHash` skips a mismatch, since FX conversion isn't available offline). + * Optimistically patches the "Awaiting approval" snapshot when a SUBMITTED expense is split. A split keeps the report + * in the awaiting-approval section but changes both its reimbursable total (e.g. split to a lower amount) and the number + * of reimbursable transactions it contains (one expense becomes several). Home reads `snapshot.search.total`/`count`, + * which are only refreshed online, and `count` drives the row's visibility — so both must be patched, even when the + * total is unchanged (an equal-amount split still changes `count`). Mirrors the amount-edit path: same-currency only + * (the currency guard in `buildSnapshotTotalUpdatesForHash` skips a mismatch, since FX conversion isn't available offline). */ -function getYourSpendSnapshotSplitUpdates({iouReport, originalTransaction, reimbursableDiff, currentUserAccountID}: GetYourSpendSnapshotSplitUpdatesParams): YourSpendSnapshotOnyxData { +function getYourSpendSnapshotSplitUpdates({ + iouReport, + originalTransaction, + reimbursableDiff, + reimbursableCountDiff, + currentUserAccountID, +}: GetYourSpendSnapshotSplitUpdatesParams): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; - if (!iouReport || !originalTransaction || reimbursableDiff === 0) { + if (!iouReport || !originalTransaction || (reimbursableDiff === 0 && reimbursableCountDiff === 0)) { return result; } @@ -491,7 +503,7 @@ function getYourSpendSnapshotSplitUpdates({iouReport, originalTransaction, reimb const currency = getCurrency(originalTransaction); const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, reimbursableDiff, currency)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, reimbursableDiff, currency, reimbursableCountDiff)); return result; } From f6d66d364f54abbf23ce6e978d42e83a516fdc93 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 25 Jun 2026 22:03:55 +0200 Subject: [PATCH 18/32] Fix offline Your Spend repaid section after paying reports --- src/libs/actions/IOU/PayMoneyRequest.ts | 15 ++++++++ .../home/YourSpendSection/useYourSpendData.ts | 38 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 3de9bab0b4f5..e66a97e9961f 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -194,6 +194,7 @@ function getPayMoneyRequestParams({ | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT | BuildPolicyDataKeys > = { optimisticData: [], @@ -497,6 +498,20 @@ function getPayMoneyRequestParams({ optimisticHoldReportExpenseActionIDs = JSON.stringify(holdReportOnyxData.optimisticHoldReportExpenseActionIDs); } + // Paying a report moves it out of "Awaiting approval" and into "Repaid (last 30 days)" in the Your spend widget. + // Home reads those section totals from cached snapshots that are only refreshed online, and the linked Search page + // renders its list from `snapshot.data` — so without this optimistic patch the repaid section opens empty offline. + const 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, + }); + onyxData.optimisticData?.push(...yourSpendSnapshotUpdates.optimisticData); + onyxData.successData?.push(...yourSpendSnapshotUpdates.successData); + onyxData.failureData?.push(...yourSpendSnapshotUpdates.failureData); + return { params: { iouReportID: iouReport?.reportID, diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 95fb10a56bd5..75356d3a8092 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -8,6 +8,7 @@ import useNetwork from '@hooks/useNetwork'; 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 {getSuggestedSearches, getSuggestedSearchesVisibility, TODO_SEARCH_KEYS} from '@libs/SearchUIUtils'; @@ -113,6 +114,24 @@ function getOutstandingReportsSignature(reports: OnyxCollection | undefi return ids.sort().join(','); } +// Signature of the reports the user owns that are currently REPAID (reimbursed). The home query results are +// cached snapshots that are not patched when a report's state changes, so without this the cached "Repaid" +// total can be resurrected after the user cancels the payment of their last repaid expense (the snapshot count +// gets wiped to null by `shouldCalculateTotals: false` searches, which would otherwise re-show the stale total). +// Mirrors `getOutstandingReportsSignature` for the "Awaiting approval" row. +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; @@ -158,6 +177,12 @@ function useYourSpendData(): UseYourSpendDataReturn { selector: (reports) => getOutstandingReportsSignature(reports, paidGroupPolicyIDs, accountID), }); + // Signature of the reports the user owns that are currently REPAID. Lets the "Repaid" cached-total fallback + // drop its stale value once the user no longer owns any repaid report (e.g. after cancelling a payment). + 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(); @@ -355,7 +380,16 @@ 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. + // An empty signature means nothing is repaid, so the row must hide immediately after cancelling the + // payment of the last repaid expense — otherwise the stale cached total is resurrected when a + // `shouldCalculateTotals: false` search wipes the snapshot count to null. + 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; @@ -457,5 +491,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}; From f7fbe84d1bd55f69fda39237e66d47101374d8cc Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 26 Jun 2026 11:36:41 +0200 Subject: [PATCH 19/32] Add SNAPSHOT key to PayMoneyRequest onyxData return type --- src/libs/actions/IOU/PayMoneyRequest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index e66a97e9961f..3bd8bfba8da5 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -75,6 +75,7 @@ type PayMoneyRequestData = { | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT | BuildPolicyDataKeys >; }; From 3a835ddb17bf54aafcd84c546b7c12a01ecf0dde Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 3 Jul 2026 14:49:36 +0200 Subject: [PATCH 20/32] Trim verbose comments in Your spend snapshot updates --- src/libs/actions/IOU/PayMoneyRequest.ts | 4 +- .../actions/IOU/SplitTransactionUpdate.ts | 12 +-- src/libs/actions/IOU/UpdateMoneyRequest.ts | 4 +- .../actions/IOU/YourSpendSnapshotUpdate.ts | 86 +++++-------------- .../home/YourSpendSection/useYourSpendData.ts | 15 +--- 5 files changed, 29 insertions(+), 92 deletions(-) diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 1de690f1ee36..742e99d19108 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -512,9 +512,7 @@ function getPayMoneyRequestParams({ optimisticHoldReportExpenseActionIDs = JSON.stringify(holdReportOnyxData.optimisticHoldReportExpenseActionIDs); } - // Paying a report moves it out of "Awaiting approval" and into "Repaid (last 30 days)" in the Your spend widget. - // Home reads those section totals from cached snapshots that are only refreshed online, and the linked Search page - // renders its list from `snapshot.data` — so without this optimistic patch the repaid section opens empty offline. + // Paying a report moves it from "Awaiting approval" to "Repaid" in the Your spend widget; patch both snapshots offline. const yourSpendSnapshotUpdates = getYourSpendSnapshotReportMoveUpdates({ iouReport, reportTransactions, diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index e55610c0ef8f..4c5d86b99442 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1812,16 +1812,8 @@ function updateSplitTransactions({ } } - // A split keeps a SUBMITTED report in the "Awaiting approval" section but changes its reimbursable total - // (e.g. split to a lower amount). Home reads that total from the cached snapshot, which is only refreshed - // online, so patch the same-currency delta optimistically. `changesInReportTotal` is the signed report-total - // delta (new split total minus the pre-split total) in the transaction currency. - // The split also changes how many reimbursable transactions the report holds (e.g. one expense becomes several). - // `count` drives the Home "Awaiting approval" row visibility, so it must move in lockstep with the membership - // change — otherwise a later deletion of one split child decrements a stale `count` to 0 and hides the row even - // though split children remain. The replaced set is the original transaction (creation of splits) or the existing - // split children (re-splitting); the new set is `splits`. A `reimbursable !== false` transaction counts (matching - // the snapshot query convention). + // Patch the "Awaiting approval" snapshot total and count for the split (both the reimbursable amount and the + // number of reimbursable transactions change). `count` must move in lockstep with membership to keep the row visible. const reimbursableSplitsCount = splits.filter((split) => split.reimbursable !== false).length; const previousReimbursableCount = isCreationOfSplits ? Number(originalTransaction?.reimbursable !== false) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index c53ae9a25695..3f1d14b8896b 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -2058,9 +2058,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U }); } - // Only amount edits can be patched into the Your spend totals offline. A currency change would require converting - // between currencies (rates are only available server-side), so those edits are intentionally left to the next - // online search() refresh and handled by the early return in getYourSpendSnapshotTotalUpdates. + // Only amount edits can be patched offline; currency changes need server-side FX rates. if (hasModifiedAmount && transaction && updatedTransaction && iouReport) { const yourSpendSnapshotTotalUpdates = getYourSpendSnapshotTotalUpdates({ transaction, diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 62322c4a8cf0..a165a9fe731b 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -1,16 +1,19 @@ -import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; // eslint-disable-next-line no-restricted-imports -- Your spend "Awaiting approval"/"Repaid" totals are a billing/paid-only feature (Collect/Control), so paid-group scoping is intentional here. import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getAmount, getConvertedAmount, getCurrency} from '@libs/TransactionUtils'; import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDateString} from '@libs/YourSpendQueryUtils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, SearchResults, 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'; + type YourSpendSnapshotOnyxData = { optimisticData: Array>; successData: Array>; @@ -24,7 +27,6 @@ type GetYourSpendSnapshotTotalUpdatesParams = { currentUserAccountID: number; }; -// connectWithoutView: snapshot/policy reads are for optimistic Your spend total patches only. let allSnapshots: OnyxCollection = {}; Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.SNAPSHOT, @@ -99,10 +101,7 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff const currentSnapshot = allSnapshots?.[snapshotKey]; const search = currentSnapshot?.search; - // Only bail when the snapshot hasn't been loaded at all — writing into a non-existent snapshot would create a - // partial/garbage entry. A loaded-but-empty snapshot (e.g. the section had nothing awaiting approval, so its - // `total`/`count`/`currency` are still null) is a valid zero base: the first report submitted offline should - // populate and reveal the row. + // Skip when the snapshot isn't loaded; a loaded-but-empty snapshot is a valid zero base. if (!search) { return {optimisticData: [], successData: [], failureData: []}; } @@ -116,8 +115,7 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff } const updatedTotal = currentTotal + diff; - // `count` drives the Home row's visibility (see getYourSpendRowState): it must move in lockstep with `total`, - // otherwise an add into a previously empty bucket stays hidden and a removal of the last item shows a stale $0.00. + // `count` drives row visibility, so it must move in lockstep with `total`. const updatedCount = Math.max(0, currentCount + countDiff); return { optimisticData: [ @@ -140,8 +138,6 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff key: snapshotKey, value: { search: { - // Restore the exact prior state. When the section was empty these were null, so rolling back - // to null (Onyx MERGE removes the key) cleanly re-hides the row. total: search.total ?? null, count: search.count ?? null, currency: currentCurrency ?? null, @@ -167,14 +163,12 @@ function calculateYourSpendTotalDiff(iouReport: OnyxEntry, updatedTransa const currentCurrency = getCurrency(transaction); const updatedCurrency = getCurrency(updatedTransaction); - // A currency change can't be expressed as a simple amount delta on the snapshot total without converting between - // currencies, and FX rates aren't available offline (totals are converted server-side). Skip patching in that case. + // 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), so the delta moves the - // snapshot total in the correct direction (e.g. raising a spend amount makes the negative total more negative). + // Signed to match the snapshot `total` convention (spend negative, credits positive). const currentTotal = getAmount(transaction, isExpenseReportLocal); const updatedTotal = getAmount(updatedTransaction, isExpenseReportLocal); @@ -222,11 +216,7 @@ function getSnapshotSearchResults(snapshotHash: number | undefined) { return allSnapshots?.[snapshotKey]?.search; } -/** - * Returns a transaction's reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. - * The value is signed to match the search `total` convention (spend is negative, credits positive), i.e. it mirrors - * `getAmount(transaction, isFromExpenseReport)` rather than its magnitude. - */ +/** Returns a transaction's signed reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. */ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, iouReport: OnyxEntry, targetCurrency: string): number | null { const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); const transactionCurrency = getCurrency(transaction); @@ -234,8 +224,7 @@ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, io if (transactionCurrency === targetCurrency) { return getAmount(transaction, isExpenseReportLocal); } - // `convertedAmount` is denominated in the report's policy output currency, not necessarily the snapshot - // currency. Only trust it when those match; otherwise we'd add a value in the wrong currency to the total. + // `convertedAmount` is in the policy output currency; only trust it when that matches the target currency. const policyOutputCurrency = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]?.outputCurrency : undefined; if (transaction.convertedAmount != null && policyOutputCurrency === targetCurrency) { return getConvertedAmount(transaction, isExpenseReportLocal); @@ -246,12 +235,8 @@ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, io type ReportReimbursableAggregate = { total: number; - // Number of reimbursable transactions contributing to `total`. Kept alongside `total` so the snapshot's - // result `count` can be patched in lockstep when the report enters or leaves a Your spend section. count: number; - // The reimbursable transactions that contributed to `total`. The Home row reads its amount from - // `snapshot.search.total`, but the Search page renders its list from `snapshot.data`. These are injected into - // `data` so an offline-submitted report's expenses are visible when the user opens the section offline. + // Contributing transactions, injected into `snapshot.data` so the Search page isn't empty offline. transactions: Transaction[]; }; @@ -289,19 +274,14 @@ function getReportReimbursableTotal( return {total, count, transactions}; } -/** - * Builds the snapshot `data` writes that add (or remove) a report's reimbursable transactions when it enters (or - * leaves) a Your spend section. The Home row only needs `search.total`/`count`, but the Search page the row links to - * renders its list from `snapshot.data`; without these writes an offline-submitted report's section opens empty. - */ +/** 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(snapshotHash: number | undefined, transactions: Transaction[], enters: boolean, leaves: boolean): YourSpendSnapshotOnyxData { - // Only a section crossing (enter XOR leave) changes membership. Staying put, or no transactions, is a no-op. + // 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; - // Only patch a snapshot that's actually been loaded; writing into a missing one would create a partial entry. if (!allSnapshots?.[snapshotKey]?.search) { return {optimisticData: [], successData: [], failureData: []}; } @@ -314,7 +294,6 @@ function buildSnapshotDataUpdatesForHash(snapshotHash: number | undefined, trans absentData[transactionKey] = null; } - // Entering adds the transactions (rollback removes them); leaving removes them (rollback restores them). const optimisticDataValue = enters ? presentData : absentData; const failureDataValue = enters ? absentData : presentData; @@ -325,11 +304,7 @@ function buildSnapshotDataUpdatesForHash(snapshotHash: number | undefined, trans }; } -/** - * Optimistically patches Your spend snapshot aggregates when a report moves between states (e.g. submit, retract, - * reject, unapprove, cancel payment). The report's reimbursable total is added to the section it enters and removed - * from the section it leaves, since Home reads totals from `snapshot.search.total` which is only refreshed online. - */ +/** Optimistically patches Your spend snapshot aggregates when a report moves between states (e.g. submit, retract, reject, unapprove, cancel payment). */ function getYourSpendSnapshotReportMoveUpdates({ iouReport, reportTransactions, @@ -346,9 +321,7 @@ function getYourSpendSnapshotReportMoveUpdates({ const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); const approvalSnapshotSearch = getSnapshotSearchResults(approvalQueryJSON?.hash); const approvalSnapshotCurrency = approvalSnapshotSearch?.currency; - // When the section is currently empty its snapshot has no currency yet, so fall back to the report's currency. - // This lets the first report submitted offline populate (and reveal) the row; the backend corrects the - // currency/total on the next online refresh. + // An empty section's snapshot has no currency yet, so fall back to the report's currency. const approvalTargetCurrency = approvalSnapshotCurrency ?? iouReport.currency; if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotSearch && approvalTargetCurrency) { @@ -393,10 +366,7 @@ type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { currentUserAccountID: number; }; -/** - * Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or - * reject), subtracting its reimbursable amount from whichever section the report currently belongs to. - */ +/** Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or reject). */ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; if (!transaction || !iouReport) { @@ -430,10 +400,7 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, return result; } -/** - * Optimistically patches Your spend snapshot aggregates when a transaction amount changes. - * Home reads totals from `snapshot.search.total`, which is only refreshed via search() while online. - */ +/** Optimistically patches Your spend snapshot aggregates when a transaction amount changes. */ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTotalUpdatesParams): YourSpendSnapshotOnyxData { const emptyResult: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; if (!transaction || !updatedTransaction || !iouReport) { @@ -465,25 +432,14 @@ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouR type GetYourSpendSnapshotSplitUpdatesParams = { iouReport: OnyxEntry; originalTransaction: OnyxEntry; - // Signed change to the report's reimbursable total, in the transaction/report currency - // (new split total minus the pre-split total). Negative when the expense is split to a lower amount. + // Signed change to the report's reimbursable total, in the report currency. reimbursableDiff: number; - // Signed change to the number of reimbursable transactions in the report caused by the split - // (e.g. splitting one expense into two reimbursable children is +1). `count` drives the Home row's - // visibility, so it must move in lockstep with the membership change even when `reimbursableDiff` is 0 - // (an equal-amount split leaves the total unchanged but still adds transactions). + // Signed change to the number of reimbursable transactions in the report; drives row visibility in lockstep with the total. reimbursableCountDiff: number; currentUserAccountID: number; }; -/** - * Optimistically patches the "Awaiting approval" snapshot when a SUBMITTED expense is split. A split keeps the report - * in the awaiting-approval section but changes both its reimbursable total (e.g. split to a lower amount) and the number - * of reimbursable transactions it contains (one expense becomes several). Home reads `snapshot.search.total`/`count`, - * which are only refreshed online, and `count` drives the row's visibility — so both must be patched, even when the - * total is unchanged (an equal-amount split still changes `count`). Mirrors the amount-edit path: same-currency only - * (the currency guard in `buildSnapshotTotalUpdatesForHash` skips a mismatch, since FX conversion isn't available offline). - */ +/** Optimistically patches the "Awaiting approval" snapshot when a SUBMITTED expense is split (same-currency only). */ function getYourSpendSnapshotSplitUpdates({ iouReport, originalTransaction, diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 510afd45292d..d5e86e21c928 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -119,11 +119,8 @@ function getOutstandingReportsSignature(reports: OnyxCollection | undefi return ids.sort().join(','); } -// Signature of the reports the user owns that are currently REPAID (reimbursed). The home query results are -// cached snapshots that are not patched when a report's state changes, so without this the cached "Repaid" -// total can be resurrected after the user cancels the payment of their last repaid expense (the snapshot count -// gets wiped to null by `shouldCalculateTotals: false` searches, which would otherwise re-show the stale total). -// Mirrors `getOutstandingReportsSignature` for the "Awaiting approval" row. +// Signature of the reports the user owns that are currently REPAID, used to drop the stale cached "Repaid" total +// once the user no longer owns any repaid report. Mirrors `getOutstandingReportsSignature`. function getRepaidReportsSignature(reports: OnyxCollection | undefined, accountID: number): string { if (!reports) { return ''; @@ -182,8 +179,6 @@ function useYourSpendData(): UseYourSpendDataReturn { selector: (reports) => getOutstandingReportsSignature(reports, paidGroupPolicyIDs, accountID), }); - // Signature of the reports the user owns that are currently REPAID. Lets the "Repaid" cached-total fallback - // drop its stale value once the user no longer owns any repaid report (e.g. after cancelling a payment). const [repaidReportsSignature] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { selector: (reports) => getRepaidReportsSignature(reports, accountID), }); @@ -385,10 +380,8 @@ function useYourSpendData(): UseYourSpendDataReturn { cachedApprovalReady !== null && cachedApprovalHash === approvalHash && outstandingReportsSignature !== ''; - // Only bridge a wiped/missing count with the cached total while the user still owns a REPAID report. - // An empty signature means nothing is repaid, so the row must hide immediately after cancelling the - // payment of the last repaid expense — otherwise the stale cached total is resurrected when a - // `shouldCalculateTotals: false` search wipes the snapshot count to 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 && From 2946111092b06e1830f14c8039b5b761812eb8e3 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 3 Jul 2026 15:52:14 +0200 Subject: [PATCH 21/32] Sync Your spend snapshot data on transaction removal and bound memory - Drop removed transactions from snapshot.data so the Search page stays in sync offline - Mirror only each snapshot's search aggregates to keep memory bounded - Align 30-day boundary checks with the exclusive date> filter semantics --- .../actions/IOU/SplitTransactionUpdate.ts | 2 -- .../actions/IOU/YourSpendSnapshotUpdate.ts | 25 +++++++++++++------ .../home/YourSpendSection/useYourSpendData.ts | 2 -- ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 5 +++- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 15 ++++++++++- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 4c5d86b99442..3f83ae54758d 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1812,8 +1812,6 @@ function updateSplitTransactions({ } } - // Patch the "Awaiting approval" snapshot total and count for the split (both the reimbursable amount and the - // number of reimbursable transactions change). `count` must move in lockstep with membership to keep the row visible. const reimbursableSplitsCount = splits.filter((split) => split.reimbursable !== false).length; const previousReimbursableCount = isCreationOfSplits ? Number(originalTransaction?.reimbursable !== false) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index a165a9fe731b..b5eddd305ec2 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -27,12 +27,20 @@ type GetYourSpendSnapshotTotalUpdatesParams = { currentUserAccountID: number; }; -let allSnapshots: OnyxCollection = {}; +// Mirror only each snapshot's `search` aggregates; dropping the large `data` blob keeps this bounded regardless of how many searches are cached. +type SnapshotSearch = SearchResults['search']; +let allSnapshotSearches: Record = {}; Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, callback: (value) => { - allSnapshots = value ?? {}; + const next: Record = {}; + for (const [key, snapshot] of Object.entries(value ?? {})) { + if (snapshot?.search) { + next[key] = snapshot.search; + } + } + allSnapshotSearches = next; }, }); @@ -89,7 +97,7 @@ function transactionMatchesRepaidLast30DaysQuery(iouReport: OnyxEntry, t if (!created) { return false; } - return created >= get30DaysAgoDateString(); + return created > get30DaysAgoDateString(); } function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff: number, currency: string, countDiff = 0): YourSpendSnapshotOnyxData { @@ -98,8 +106,7 @@ function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff } const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; - const currentSnapshot = allSnapshots?.[snapshotKey]; - const search = currentSnapshot?.search; + const search = allSnapshotSearches[snapshotKey]; // Skip when the snapshot isn't loaded; a loaded-but-empty snapshot is a valid zero base. if (!search) { @@ -213,7 +220,7 @@ function getSnapshotSearchResults(snapshotHash: number | undefined) { return undefined; } const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; - return allSnapshots?.[snapshotKey]?.search; + return allSnapshotSearches[snapshotKey]; } /** Returns a transaction's signed reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. */ @@ -257,7 +264,7 @@ function getReportReimbursableTotal( } if (onlyWithinLast30Days) { const created = reportTransaction.created?.slice(0, 10); - if (!created || created < get30DaysAgoDateString()) { + if (!created || created <= get30DaysAgoDateString()) { continue; } } @@ -282,7 +289,7 @@ function buildSnapshotDataUpdatesForHash(snapshotHash: number | undefined, trans } const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; - if (!allSnapshots?.[snapshotKey]?.search) { + if (!allSnapshotSearches[snapshotKey]) { return {optimisticData: [], successData: [], failureData: []}; } @@ -382,6 +389,7 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, approvalSnapshotCurrency); if (amount !== null) { mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, -amount, approvalSnapshotCurrency, -1)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(approvalQueryJSON?.hash, [transaction], false, true)); } } } @@ -393,6 +401,7 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, paymentSnapshotCurrency); if (amount !== null) { mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, -amount, paymentSnapshotCurrency, -1)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(paymentQueryJSON?.hash, [transaction], false, true)); } } } diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index d5e86e21c928..7d5126eb0dd6 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -119,8 +119,6 @@ function getOutstandingReportsSignature(reports: OnyxCollection | undefi return ids.sort().join(','); } -// Signature of the reports the user owns that are currently REPAID, used to drop the stale cached "Repaid" total -// once the user no longer owns any repaid report. Mirrors `getOutstandingReportsSignature`. function getRepaidReportsSignature(reports: OnyxCollection | undefined, accountID: number): string { if (!reports) { return ''; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index 61f27e86e368..0185623cb9b5 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -1,12 +1,15 @@ -import Onyx from 'react-native-onyx'; import {getYourSpendSnapshotReportMoveUpdates} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; 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 Onyx from 'react-native-onyx'; + import createRandomPolicy from '../../utils/collections/policies'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index dfa3de99c974..e6afd78568db 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -1,13 +1,16 @@ -import Onyx from 'react-native-onyx'; import {getUpdateMoneyRequestParams} from '@libs/actions/IOU/UpdateMoneyRequest'; import {getYourSpendSnapshotTotalUpdates, getYourSpendSnapshotTransactionRemovalUpdates, transactionMatchesAwaitingApprovalQuery} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; 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'; @@ -184,17 +187,27 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { currentUserAccountID: ACCOUNT_ID, }); + 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}}, + }), ]); }); From 274a485816a5a2e0a44af1ee131c839229238ca5 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 7 Jul 2026 13:26:36 +0200 Subject: [PATCH 22/32] Fix Your spend for reimbursable-only split and reimbursable toggle Split updates now diff reimbursable portions only, and toggling an expense's reimbursable flag offline patches the Your spend totals. --- .../actions/IOU/SplitTransactionUpdate.ts | 34 ++++++- src/libs/actions/IOU/UpdateMoneyRequest.ts | 15 ++- .../actions/IOU/YourSpendSnapshotUpdate.ts | 60 +++++++++-- .../IOUTest/GetReimbursableSplitDiffTest.ts | 99 +++++++++++++++++++ 4 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 3f83ae54758d..eb61f14855b8 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -110,6 +110,28 @@ type UpdateSplitTransactionsParams = { isOffline: boolean; }; +type GetReimbursableSplitDiffParams = { + splits: SplitTransactionSplitsParam; + originalTransaction: OnyxEntry; + originalChildTransactions: Array>; + splitExpensesTotal: number; + isCreationOfSplits: boolean; +}; + +/** + * Returns the change to the report's reimbursable total from a split, in report-currency magnitude. + * 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); + return newReimbursableTotal - previousReimbursableTotal; +} + function updateSplitTransactions({ allTransactionsList, allReportsList, @@ -1818,10 +1840,18 @@ function updateSplitTransactions({ : 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: changesInReportTotal, + reimbursableDiff, reimbursableCountDiff, currentUserAccountID: currentUserPersonalDetails.accountID, }); @@ -2001,4 +2031,4 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac }); } -export {updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow}; +export {getReimbursableSplitDiff, updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow}; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 3f1d14b8896b..354f32f59369 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -58,7 +58,7 @@ import Onyx from 'react-native-onyx'; import {getAllReports, getAllTransactions, getAllTransactionViolations, getPolicyTagsData, getRecentAttendees} from '.'; import {getUpdatedMoneyRequestReportData, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from './MoneyRequestBuilder'; -import {getYourSpendSnapshotTotalUpdates} from './YourSpendSnapshotUpdate'; +import {getYourSpendSnapshotReimbursableUpdates, getYourSpendSnapshotTotalUpdates} from './YourSpendSnapshotUpdate'; type UpdateMoneyRequestData = { params: UpdateMoneyRequestParams; @@ -2071,6 +2071,19 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U 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, + }); + 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 index b5eddd305ec2..94438300d592 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -373,14 +373,23 @@ type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { currentUserAccountID: number; }; -/** Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or reject). */ -function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { +/** + * Optimistically patches Your spend snapshot aggregates when a single transaction enters or leaves the reimbursable-only sections. + * `enters` adds the transaction's amount/count (and its `data` row); otherwise it subtracts them. + */ +function getYourSpendSnapshotTransactionMembershipUpdates( + transaction: OnyxEntry, + iouReport: OnyxEntry, + currentUserAccountID: number, + enters: boolean, +): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; if (!transaction || !iouReport) { return result; } const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + const sign = enters ? 1 : -1; if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); @@ -388,8 +397,8 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, if (approvalSnapshotCurrency) { const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, approvalSnapshotCurrency); if (amount !== null) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, -amount, approvalSnapshotCurrency, -1)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(approvalQueryJSON?.hash, [transaction], false, true)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, sign * amount, approvalSnapshotCurrency, sign)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(approvalQueryJSON?.hash, [transaction], enters, !enters)); } } } @@ -400,8 +409,8 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, if (paymentSnapshotCurrency) { const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, paymentSnapshotCurrency); if (amount !== null) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, -amount, paymentSnapshotCurrency, -1)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(paymentQueryJSON?.hash, [transaction], false, true)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, sign * amount, paymentSnapshotCurrency, sign)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(paymentQueryJSON?.hash, [transaction], enters, !enters)); } } } @@ -409,6 +418,44 @@ function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, return result; } +/** Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or reject). */ +function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { + return getYourSpendSnapshotTransactionMembershipUpdates(transaction, iouReport, currentUserAccountID, false); +} + +type GetYourSpendSnapshotReimbursableUpdatesParams = { + transaction: OnyxEntry; + updatedTransaction: OnyxEntry; + iouReport: OnyxEntry; + currentUserAccountID: number; +}; + +/** + * 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, +}: 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 getYourSpendSnapshotTransactionMembershipUpdates(membershipTransaction, iouReport, currentUserAccountID, willBeReimbursable); +} + /** Optimistically patches Your spend snapshot aggregates when a transaction amount changes. */ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTotalUpdatesParams): YourSpendSnapshotOnyxData { const emptyResult: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; @@ -473,6 +520,7 @@ function getYourSpendSnapshotSplitUpdates({ } export { + getYourSpendSnapshotReimbursableUpdates, getYourSpendSnapshotReportMoveUpdates, getYourSpendSnapshotSplitUpdates, getYourSpendSnapshotTotalUpdates, diff --git a/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts b/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts new file mode 100644 index 000000000000..0ed46fbefb3b --- /dev/null +++ b/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts @@ -0,0 +1,99 @@ +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 Your spend should drop 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, + }); + + 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 Your spend should drop 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); + }); + }); +}); From 61f248f31490aa32f68b9792fc9b3e08e5edd31a Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 7 Jul 2026 13:38:29 +0200 Subject: [PATCH 23/32] Test Your spend snapshot updates for reimbursable toggle Cover getYourSpendSnapshotReimbursableUpdates in both directions and the getUpdateMoneyRequestParams branch that removes an expense toggled non-reimbursable. --- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index e6afd78568db..3c40a777838d 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -1,5 +1,10 @@ import {getUpdateMoneyRequestParams} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {getYourSpendSnapshotTotalUpdates, getYourSpendSnapshotTransactionRemovalUpdates, transactionMatchesAwaitingApprovalQuery} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +import { + getYourSpendSnapshotReimbursableUpdates, + getYourSpendSnapshotTotalUpdates, + getYourSpendSnapshotTransactionRemovalUpdates, + transactionMatchesAwaitingApprovalQuery, +} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {buildAwaitingApprovalQuery} from '@libs/YourSpendQueryUtils'; @@ -229,6 +234,82 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { }); }); +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, + }); + + 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, + }); + + 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, + }); + + 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])); @@ -269,6 +350,39 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { ); }); + 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, + }); + + 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); From 7a36b36f3d1a2965ead05a89963f8c618ba1790c Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 9 Jul 2026 17:12:16 +0200 Subject: [PATCH 24/32] Supply Your spend patch data via hook scoped to paid-group policies --- .../MoneyReportHeaderSecondaryActions.tsx | 14 +- .../PayPrimaryAction.tsx | 3 + .../SubmitPrimaryAction.tsx | 3 + .../useConfirmApproval.ts | 3 + .../MoneyRequestHeaderSecondaryActions.tsx | 3 + .../ApproveActionButton.tsx | 3 + .../PayActionButton.tsx | 4 + .../SubmitActionButton.tsx | 3 + .../ReportActionItem/MoneyRequestView.tsx | 3 + .../ListItem/ActionCell/PayActionCell.tsx | 3 + .../ListItem/ExpenseReportListItem.tsx | 4 + .../ListItem/ReportListItemHeader.tsx | 3 + .../ListItem/TransactionListItem/index.tsx | 3 + src/components/SettlementButton/index.tsx | 3 + src/hooks/useDeleteTransactions.ts | 5 + src/hooks/useHoldMenuSubmit.ts | 4 + src/hooks/useLifecycleActions.tsx | 10 +- src/hooks/useSearchBulkActions.ts | 6 + src/hooks/useSelectionModePayment.ts | 4 + src/hooks/useTransactionInlineEdit.ts | 3 + src/hooks/useYourSpendPatchData.ts | 40 +++++ src/libs/PaymentUtils.ts | 5 + src/libs/YourSpendPatchData.ts | 18 +++ src/libs/YourSpendQueryUtils.ts | 29 +++- src/libs/actions/IOU/DeleteMoneyRequest.ts | 4 + src/libs/actions/IOU/PayMoneyRequest.ts | 9 ++ src/libs/actions/IOU/RejectMoneyRequest.ts | 7 +- src/libs/actions/IOU/ReportWorkflow.ts | 11 ++ .../actions/IOU/SplitTransactionUpdate.ts | 4 + src/libs/actions/IOU/TrackExpense.ts | 4 + src/libs/actions/IOU/UpdateMoneyRequest.ts | 11 ++ .../actions/IOU/YourSpendSnapshotUpdate.ts | 153 +++++++++--------- src/libs/actions/Search.ts | 18 ++- src/libs/actions/TransactionInlineEdit.ts | 3 + src/pages/DynamicReportDetailsPage.tsx | 4 + src/pages/RejectExpenseReportPage.tsx | 3 + src/pages/ReportSubmitToContent.tsx | 4 + src/pages/Search/SearchRejectReasonPage.tsx | 4 + .../PopoverReportActionContextMenu.tsx | 4 + src/pages/iou/RejectReasonPage.tsx | 4 +- src/pages/iou/SplitExpensePage.tsx | 3 + .../iou/request/step/AmountSubmission.ts | 4 + .../iou/request/step/IOURequestStepAmount.tsx | 3 + ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 87 ++++++---- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 22 +++ 45 files changed, 423 insertions(+), 122 deletions(-) create mode 100644 src/hooks/useYourSpendPatchData.ts create mode 100644 src/libs/YourSpendPatchData.ts diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 769a1b3512cc..b19cc5b1e6a5 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import type {ButtonWithDropdownMenuRef} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; @@ -36,6 +36,7 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {search} from '@libs/actions/Search'; @@ -150,6 +151,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const yourSpendPatchData = useYourSpendPatchData(); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); @@ -220,6 +222,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData, onPaid: () => { startAnimation(); }, @@ -417,6 +420,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo amountOwed, ownerBillingGracePeriodEnd, delegateEmail, + yourSpendPatchData, }); }; @@ -455,13 +459,13 @@ function MoneyReportHeaderSecondaryActionsPlaceholder({primaryAction}: {primaryA return ( ); } diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 55e041f497bd..fa09c3440f9a 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -16,6 +16,7 @@ import usePayChatReportActions from '@hooks/usePayChatReportActions'; import usePolicy from '@hooks/usePolicy'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {search} from '@libs/actions/Search'; @@ -118,6 +119,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const yourSpendPatchData = useYourSpendPatchData(); const {openHoldMenu} = useMoneyReportHeaderModals(); @@ -176,6 +178,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData, onPaid: startAnimation, chatReportActions: getChatReportActions(false), }); diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 7f3017db533b..3fcbd8687e51 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -14,6 +14,7 @@ import usePermissions from '@hooks/usePermissions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -111,6 +112,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const yourSpendPatchData = useYourSpendPatchData(); const handleSubmit = () => { if (!moneyRequestReport || shouldBlockSubmit) { @@ -138,6 +140,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { expenseReportCurrentNextStepDeprecated: nextStep, userBillingGracePeriodEnds, amountOwed, + yourSpendPatchData, onSubmitted: startSubmittingAnimation, ownerBillingGracePeriodEnd, delegateEmail, diff --git a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts index 6bf77aca7719..333daef6d405 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts +++ b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts @@ -5,6 +5,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isSubmitPolicy} from '@libs/PolicyUtils'; @@ -34,6 +35,7 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const yourSpendPatchData = useYourSpendPatchData(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); @@ -64,6 +66,7 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation amountOwed, ownerBillingGracePeriodEnd, full: true, + yourSpendPatchData, onApproved: startApprovedAnimation, delegateEmail, }); diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 35b8d2d4b0d8..f16608eafe87 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -23,6 +23,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionViolations from '@hooks/useTransactionViolations'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; @@ -108,6 +109,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const {translate, localeCompare} = useLocalize(); const {login: currentUserLogin, accountID, localCurrencyCode} = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'ArrowCollapse', @@ -473,6 +475,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money allTransactionViolationsParam: allTransactionViolations, currentUserAccountID: accountID, currentUserEmail: currentUserLogin ?? '', + yourSpendPatchData, }); } else { if (shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID])) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx index 21ed404c1acf..e0cdfb8ec6a5 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx @@ -6,6 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; @@ -48,6 +49,7 @@ function ApproveActionButton({iouReportID, startApprovedAnimation, onHoldMenuOpe const hasViolations = hasViolationsReportUtils(iouReport?.reportID, transactionViolations, currentUserAccountID, currentUserEmail); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(iouReport?.reportID); + const yourSpendPatchData = useYourSpendPatchData(); const allTransactionValues = Object.values(reportTransactions); const transactions = allTransactionValues; @@ -71,6 +73,7 @@ function ApproveActionButton({iouReportID, startApprovedAnimation, onHoldMenuOpe amountOwed, ownerBillingGracePeriodEnd, full: true, + yourSpendPatchData, onApproved: startApprovedAnimation, delegateEmail, }); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index a3f3e5aeb762..e6b895f3c5d9 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -13,6 +13,7 @@ import usePayChatReportActions from '@hooks/usePayChatReportActions'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; @@ -101,6 +102,7 @@ function PayActionButton({ const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const yourSpendPatchData = useYourSpendPatchData(); const reportTransactionsCollection = useReportTransactionsCollection(iouReportID); const transactions = Object.values(reportTransactionsCollection ?? {}).filter( (t): t is Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), @@ -165,6 +167,7 @@ function PayActionButton({ amountOwed, ownerBillingGracePeriodEnd, full: true, + yourSpendPatchData, onApproved: startApprovedAnimation, delegateEmail, }); @@ -220,6 +223,7 @@ function PayActionButton({ amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData, onPaid: startAnimation, chatReportActions: getChatReportActions(false), }); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx index 65f94b3d8db6..a40c11138d7f 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx @@ -9,6 +9,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {hasDynamicExternalWorkflow, isSubmitPolicy} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils, shouldShowMarkAsDone} from '@libs/ReportUtils'; @@ -76,6 +77,7 @@ function SubmitActionButtonContent({iouReportID, isSubmittingAnimationRunning, s const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const {isOffline} = useNetwork(); + const yourSpendPatchData = useYourSpendPatchData(); const reportTransactionsCollection = useReportTransactionsCollection(iouReportID); const transactions = Object.values(reportTransactionsCollection ?? {}).filter( (t): t is Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), @@ -114,6 +116,7 @@ function SubmitActionButtonContent({iouReportID, isSubmittingAnimationRunning, s expenseReportCurrentNextStepDeprecated: iouReportNextStep, userBillingGracePeriodEnds, amountOwed, + yourSpendPatchData, onSubmitted: startSubmittingAnimation, ownerBillingGracePeriodEnd, delegateEmail, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 2eefa82fc054..01d378af2bb6 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -43,6 +43,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; import {openExternalLink} from '@libs/actions/Link'; @@ -278,6 +279,7 @@ function MoneyRequestView({ const personalDetailsList = usePersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + const yourSpendPatchData = useYourSpendPatchData(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); @@ -664,6 +666,7 @@ function MoneyRequestView({ parentReportNextStep, isOffline, delegateAccountID, + yourSpendPatchData, }); }; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index 267cc71ab68a..4bfec40ff60a 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -11,6 +11,7 @@ import {useReportPaymentContext} from '@hooks/usePaymentContext'; import usePolicy from '@hooks/usePolicy'; import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; import {canIOUBePaid} from '@libs/actions/IOU/ReportWorkflow'; @@ -45,6 +46,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID); + const yourSpendPatchData = useYourSpendPatchData(); const policy = usePolicy(policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); @@ -148,6 +150,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, additionalOnyxData, + yourSpendPatchData, chatReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(chatReport?.reportID)}`], }); }; diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 6bc19227d045..7072ae41331b 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -25,6 +25,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {handleActionButtonPress} from '@libs/actions/Search'; import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; @@ -214,6 +215,7 @@ function ExpenseReportListItemInner({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); const {transactions: reportTransactions, violations: reportViolations} = useTransactionsAndViolationsForReport(reportItem.reportID); + const yourSpendPatchData = useYourSpendPatchData(); const liveReportTransactions = useMemo(() => Object.values(reportTransactions), [reportTransactions]); // Recompute the violations badge from live data at the row, replacing the screen-level @@ -292,6 +294,7 @@ function ExpenseReportListItemInner({ searchData, chatReportActions, delegateEmail, + yourSpendPatchData, }); }, [ currentSearchHash, @@ -330,6 +333,7 @@ function ExpenseReportListItemInner({ nextStep, chatReportActions, delegateEmail, + yourSpendPatchData, ]); const handleSelectionButtonPress = useCallback(() => { diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index 85ae33012e3b..13fe1e6c6d7a 100644 --- a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx +++ b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx @@ -22,6 +22,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -293,6 +294,7 @@ function ReportListItemHeaderInner({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); + const yourSpendPatchData = useYourSpendPatchData(); const handleOnButtonPress = (event?: ModifiedMouseEvent) => { handleActionButtonPress({ @@ -327,6 +329,7 @@ function ReportListItemHeaderInner({ searchData: snapshot?.data, chatReportActions, delegateEmail, + yourSpendPatchData, }); }; return !isLargeScreenWidth ? ( diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index 0a44cb209e81..81127ac385a0 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -17,6 +17,7 @@ import useOnyx from '@hooks/useOnyx'; import {useReportPaymentContext} from '@hooks/usePaymentContext'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/actions/Search'; @@ -195,6 +196,7 @@ function TransactionListItemInner({ const {showConfirmModal} = useConfirmModal(); const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); + const yourSpendPatchData = useYourSpendPatchData(); const handleActionButtonPress = (event?: Parameters[2]) => { handleActionButtonPressUtil({ @@ -230,6 +232,7 @@ function TransactionListItemInner({ searchData: currentSearchResults?.data, chatReportActions, delegateEmail, + yourSpendPatchData, }); }; diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index fd160b951b22..786aa38fc772 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -20,6 +20,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {createWorkspace, generateDefaultWorkspaceName, isCurrencySupportedForDirectReimbursement, isCurrencySupportedForGlobalReimbursement} from '@libs/actions/Policy/Policy'; import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; @@ -119,6 +120,7 @@ function SettlementButton({ const expenseReportPolicy = usePolicy(iouReport?.policyID); const {accountID, email = ''} = useCurrentUserPersonalDetails(); const lastWorkspaceNumber = useLastWorkspaceNumber(); + const yourSpendPatchData = useYourSpendPatchData(); // 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 @@ -525,6 +527,7 @@ function SettlementButton({ amountOwed, ownerBillingGracePeriodEnd, full: false, + yourSpendPatchData, delegateEmail, }); } diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 5fcb93de0ef9..88e0fdb1b24c 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -37,6 +37,7 @@ import usePersonalPolicy from './usePersonalPolicy'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; import useRestrictedActionPolicyID from './useRestrictedActionPolicyID'; import {findSplitPolicyForCustomUnit, getSplitEffectivePolicy} from './useSplitEffectivePolicy'; +import useYourSpendPatchData from './useYourSpendPatchData'; type UseDeleteTransactionsParams = { /** Report object (optional, can be used for context) */ @@ -95,6 +96,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const restrictedActionPolicyID = useRestrictedActionPolicyID(policy); const {isOffline} = useNetwork(); const {isProduction} = useEnvironment(); + const yourSpendPatchData = useYourSpendPatchData(); const getSplitExpenseEditTransactionOnDelete = useCallback( (transactionIDs: string[]): Transaction | undefined => { @@ -320,6 +322,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac transactionReport: report, expenseReport, isOffline, + yourSpendPatchData, }); } @@ -350,6 +353,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac allTransactionViolationsParam: transactionViolations, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', + yourSpendPatchData, }); deletedTransactionIDs.push(transactionID); if (action.childReportID) { @@ -392,6 +396,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac isOffline, isProduction, personalPolicy?.outputCurrency, + yourSpendPatchData, ], ); diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index 55bd3f0bba88..58f456e27e81 100644 --- a/src/hooks/useHoldMenuSubmit.ts +++ b/src/hooks/useHoldMenuSubmit.ts @@ -21,6 +21,7 @@ import useOnyx from './useOnyx'; import usePayChatReportActions from './usePayChatReportActions'; import usePermissions from './usePermissions'; import usePolicy from './usePolicy'; +import useYourSpendPatchData from './useYourSpendPatchData'; type ActionHandledType = DeepValueOf; @@ -52,6 +53,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const currentUserDetails = useCurrentUserPersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); @@ -90,6 +92,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment onApproved: animationCallback, expenseReportPolicy: policy, delegateEmail, + yourSpendPatchData, }); } else if (currentChatReport && paymentType) { payMoneyRequest({ @@ -112,6 +115,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment methodID, onPaid: animationCallback, chatReportActions: getChatReportActions(false), + yourSpendPatchData, }); } onClose(); diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index b65e83654565..9511a0d0cc8b 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -49,6 +49,7 @@ import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; import useStrictPolicyRules from './useStrictPolicyRules'; import useThemeStyles from './useThemeStyles'; import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from './useYourSpendPatchData'; type UseLifecycleActionsParams = { reportID: string | undefined; @@ -106,6 +107,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {accountID, email} = currentUserPersonalDetails; + const yourSpendPatchData = useYourSpendPatchData(); const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); const {isBetaEnabled} = usePermissions(); @@ -193,6 +195,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, amountOwed, ownerBillingGracePeriodEnd, full: true, + yourSpendPatchData, onApproved: () => { if (skipAnimation) { return; @@ -240,6 +243,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, expenseReportCurrentNextStepDeprecated: nextStep, userBillingGracePeriodEnds, amountOwed, + yourSpendPatchData, onSubmitted: () => { if (skipAnimation) { return; @@ -357,7 +361,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); + unapproveExpenseReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail, yourSpendPatchData); }, }, [CONST.REPORT.SECONDARY_ACTIONS.CANCEL_PAYMENT]: { @@ -378,7 +382,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, return; } - cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations); + cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, yourSpendPatchData); }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { @@ -412,7 +416,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, } } - retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail); + retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, delegateEmail, yourSpendPatchData); }, }, [CONST.REPORT.SECONDARY_ACTIONS.REOPEN]: { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 30a35e7bf474..8ecd589c35b8 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -124,6 +124,7 @@ import useSplitEffectivePolicy from './useSplitEffectivePolicy'; import useTheme from './useTheme'; import useThemeStyles from './useThemeStyles'; import useUndeleteTransactions from './useUndeleteTransactions'; +import useYourSpendPatchData from './useYourSpendPatchData'; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -363,6 +364,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {introSelected, betas, isSelfTourViewed, activePolicyID, activePolicy, defaultWorkspaceName, userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, delegateEmail} = usePaymentContext(); const allTransactions = useAllTransactions(); + const yourSpendPatchData = useYourSpendPatchData(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); @@ -852,6 +854,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { full: true, additionalOnyxData: getSearchApproveOnyxData(hash, reportID, currentSearchKey), shouldPlaySuccessSound: false, + yourSpendPatchData, }); if (!wouldNavigateToUpgrade && !wouldNavigateToRestricted) { @@ -889,6 +892,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { betas, delegateEmail, currentSearchKey, + yourSpendPatchData, ]); const {expenseCount, uniqueReportCount} = useMemo(() => { @@ -1240,6 +1244,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paymentItem.paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? (paymentItem.bankAccountID ?? workspaceMethodID ?? reportPolicy?.achAccount?.bankAccountID) : undefined, additionalOnyxData, shouldPlaySuccessSound: false, + yourSpendPatchData, chatReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`], }); paidReportCount += 1; @@ -1284,6 +1289,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { allReportNameValuePairs, currentSearchKey, searchResults?.data, + yourSpendPatchData, ], ); diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index e06c83502dfe..d6ff10d728a0 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -43,6 +43,7 @@ import usePaymentOptions from './usePaymentOptions'; import usePermissions from './usePermissions'; import usePolicy from './usePolicy'; import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; +import useYourSpendPatchData from './useYourSpendPatchData'; type HoldMenuOpenParams = { requestType: ActionHandledType; @@ -87,6 +88,7 @@ function useSelectionModePayment({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const yourSpendPatchData = useYourSpendPatchData(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(moneyRequestReport?.chatReportID)}`); @@ -207,6 +209,7 @@ function useSelectionModePayment({ amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, + yourSpendPatchData, onPaid, chatReportActions: getChatReportActions(false), }); @@ -303,6 +306,7 @@ function useSelectionModePayment({ ownerBillingGracePeriodEnd, delegateEmail, expenseReportPolicy: policy, + yourSpendPatchData, }); }; diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts index 6369c63fd50e..4205babcc134 100644 --- a/src/hooks/useTransactionInlineEdit.ts +++ b/src/hooks/useTransactionInlineEdit.ts @@ -38,6 +38,7 @@ import useOnyx from './useOnyx'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; import usePolicyForTransaction from './usePolicyForTransaction'; import useSelfDMReport from './useSelfDMReport'; +import useYourSpendPatchData from './useYourSpendPatchData'; type UseTransactionInlineEditParams = { transactionID: string; @@ -146,6 +147,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); const {isOffline} = useNetwork(); + const yourSpendPatchData = useYourSpendPatchData(); const permissions = getTransactionEditPermissions({ transaction, @@ -182,6 +184,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed ?? false, hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow ?? false, distanceOriginalPolicy, + yourSpendPatchData, }; }; 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/PaymentUtils.ts b/src/libs/PaymentUtils.ts index 0b7c1374cd82..8809f04e6a53 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'; @@ -55,6 +57,7 @@ type SelectPaymentTypeParams = { amountOwed: OnyxEntry; ownerBillingGracePeriodEnd: OnyxEntry; delegateEmail: string | undefined; + yourSpendPatchData?: YourSpendPatchData; }; type BusinessBankAccountOption = { @@ -252,6 +255,7 @@ const selectPaymentType = (params: SelectPaymentTypeParams) => { amountOwed, ownerBillingGracePeriodEnd, delegateEmail, + yourSpendPatchData, } = params; if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentAccountID)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); @@ -289,6 +293,7 @@ const selectPaymentType = (params: SelectPaymentTypeParams) => { ownerBillingGracePeriodEnd, full: true, delegateEmail, + yourSpendPatchData, }); } return; diff --git a/src/libs/YourSpendPatchData.ts b/src/libs/YourSpendPatchData.ts new file mode 100644 index 000000000000..281c7c0da5ec --- /dev/null +++ b/src/libs/YourSpendPatchData.ts @@ -0,0 +1,18 @@ +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']; + +/** 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 {SnapshotSearch, YourSpendPatchData}; diff --git a/src/libs/YourSpendQueryUtils.ts b/src/libs/YourSpendQueryUtils.ts index b4f2fa561c61..5c6ff0769934 100644 --- a/src/libs/YourSpendQueryUtils.ts +++ b/src/libs/YourSpendQueryUtils.ts @@ -1,8 +1,35 @@ 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'; + +// 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), so subscribers re-render on paid-policy changes 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] = policy; + } + return paidPolicies; +} + function get30DaysAgoDateString(): string { const date = new Date(); date.setUTCDate(date.getUTCDate() - 30); @@ -42,4 +69,4 @@ function buildRecentCardTransactionsQuery(accountID: number, cardID: number): st }); } -export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery, get30DaysAgoDateString}; +export {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, buildRecentCardTransactionsQuery, get30DaysAgoDateString, getPaidGroupPolicyIDs, selectPaidGroupPolicies}; diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index e6247176244c..a0643f21fc7c 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -22,6 +22,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'; @@ -58,6 +59,7 @@ type DeleteMoneyRequestFunctionParams = { currentUserAccountID: number; currentUserEmail: string; transactionThreadReport: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }; /** @@ -693,6 +695,7 @@ function deleteMoneyRequest({ allTransactionViolationsParam, currentUserAccountID, currentUserEmail, + yourSpendPatchData, }: DeleteMoneyRequestFunctionParams) { if (!transactionID) { return; @@ -986,6 +989,7 @@ function deleteMoneyRequest({ transaction, iouReport, currentUserAccountID, + context: yourSpendPatchData, }); // STEP 3: Make the API request diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 742e99d19108..0d02aac9afab 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'; @@ -122,6 +123,7 @@ type PayMoneyRequestFunctionParams = { // TODO: delegateAccountID will be made required in PR 12 when all callers pass the value (https://github.com/Expensify/App/issues/66425) delegateAccountID?: number | undefined; chatReportActions: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }; function mergeAdditionalPayOnyxData< @@ -166,6 +168,7 @@ function getPayMoneyRequestParams({ currentUserLocalCurrency, delegateAccountID, chatReportActions, + yourSpendPatchData, }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; @@ -190,6 +193,7 @@ function getPayMoneyRequestParams({ // TODO: delegateAccountID will be made required in PR 12 when all callers pass the value (https://github.com/Expensify/App/issues/66425) delegateAccountID?: number | undefined; chatReportActions: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }): PayMoneyRequestData { // TODO: https://github.com/Expensify/App/issues/66512 // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -519,6 +523,7 @@ function getPayMoneyRequestParams({ 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(...yourSpendSnapshotUpdates.optimisticData); onyxData.successData?.push(...yourSpendSnapshotUpdates.successData); @@ -550,6 +555,7 @@ function cancelPayment( currentUserAccountIDParam: number, currentUserEmailParam: string, hasViolations: boolean, + yourSpendPatchData?: YourSpendPatchData, ) { if (isEmptyObject(expenseReport)) { return; @@ -748,6 +754,7 @@ function cancelPayment( fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, toStatus: {stateNum, statusNum}, currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, }); API.write( @@ -841,6 +848,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { shouldPlaySuccessSound = true, delegateAccountID, chatReportActions, + yourSpendPatchData, } = params; const policyForBillingRestriction = chatReportPolicy ?? (policy?.id === chatReport.policyID ? policy : undefined); if ( @@ -875,6 +883,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { bankAccountID: paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, delegateAccountID, chatReportActions, + yourSpendPatchData, }); // For now, we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with diff --git a/src/libs/actions/IOU/RejectMoneyRequest.ts b/src/libs/actions/IOU/RejectMoneyRequest.ts index fe66d64fe032..cd14eb7ab583 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'; @@ -84,6 +85,7 @@ type RejectMoneyRequestOptions = { sharedRejectedToReportID?: string; existingRejectedReport?: OnyxEntry; setExistingRejectedReport?: (report: OnyxEntry) => void; + yourSpendPatchData?: YourSpendPatchData; }; function dismissRejectUseExplanation() { @@ -918,6 +920,7 @@ function prepareRejectMoneyRequestData( transaction, iouReport: report, currentUserAccountID: currentUserAccountIDParam, + context: options?.yourSpendPatchData, }); optimisticData.push(...yourSpendSnapshotUpdates.optimisticData); failureData.push(...yourSpendSnapshotUpdates.failureData); @@ -935,7 +938,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; } @@ -1025,6 +1028,7 @@ function rejectExpenseReport( currentUserAccountID: number | undefined, currentUserDisplayName: string | undefined, currentUserAvatarSource: AvatarSource | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { const {reportID} = report; const isRejectToSubmitter = targetAccountID === report.ownerAccountID; @@ -1208,6 +1212,7 @@ function rejectExpenseReport( 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, { diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 512324ba204d..5ef610c89295 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -76,6 +76,7 @@ import { isScanningTransaction, } from '@libs/TransactionUtils'; import {isValidAccountRoute} from '@libs/ValidationUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -114,6 +115,7 @@ type ApproveMoneyRequestFunctionParams = { delegateEmail: string | undefined; additionalOnyxData?: AdditionalPayOnyxData; shouldPlaySuccessSound?: boolean; + yourSpendPatchData?: YourSpendPatchData; }; type SubmitReportFunctionParams = { @@ -133,6 +135,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( @@ -444,6 +447,7 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { expenseReportPolicy, additionalOnyxData, shouldPlaySuccessSound = true, + yourSpendPatchData, } = params; if (!expenseReport) { return; @@ -817,6 +821,7 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, }); onApproved?.(); @@ -1044,6 +1049,7 @@ function retractReport( isASAPSubmitBetaEnabled: boolean, expenseReportCurrentNextStepDeprecated: OnyxEntry, delegateEmail: string | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { if (!expenseReport) { return; @@ -1211,6 +1217,7 @@ function retractReport( fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, }); API.write(WRITE_COMMANDS.RETRACT_REPORT, parameters, { @@ -1229,6 +1236,7 @@ function unapproveExpenseReport( isASAPSubmitBetaEnabled: boolean, expenseReportCurrentNextStepDeprecated: OnyxEntry, delegateEmail: string | undefined, + yourSpendPatchData?: YourSpendPatchData, ) { if (isEmptyObject(expenseReport)) { return; @@ -1386,6 +1394,7 @@ function unapproveExpenseReport( 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, { @@ -1411,6 +1420,7 @@ function submitReport({ submitterLogin, managerEmail, managerAccountID: managerAccountIDFromPopover, + yourSpendPatchData, }: SubmitReportFunctionParams) { if (!expenseReport) { return; @@ -1699,6 +1709,7 @@ function submitReport({ ? {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?.(); diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index eb61f14855b8..83db81c8e968 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'; @@ -108,6 +109,7 @@ type UpdateSplitTransactionsParams = { transactionReport: OnyxEntry; expenseReport: OnyxEntry; isOffline: boolean; + yourSpendPatchData?: YourSpendPatchData; }; type GetReimbursableSplitDiffParams = { @@ -158,6 +160,7 @@ function updateSplitTransactions({ transactionReport, expenseReport: expenseReportFromParams, isOffline, + yourSpendPatchData, }: UpdateSplitTransactionsParams) { const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); // For selfDM-origin splits the caller can't resolve a real `expenseReport` (the draft/source @@ -1854,6 +1857,7 @@ function updateSplitTransactions({ reimbursableDiff, reimbursableCountDiff, currentUserAccountID: currentUserPersonalDetails.accountID, + context: yourSpendPatchData, }); onyxData.optimisticData?.push(...yourSpendSplitUpdates.optimisticData); onyxData.successData?.push(...yourSpendSplitUpdates.successData); diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index d43512085c00..470a8d57afb7 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -82,6 +82,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 = { allTransactionViolationsParam: OnyxCollection; currentUserAccountID: number; currentUserEmail: string; + yourSpendPatchData?: YourSpendPatchData; }; type BuildOnyxDataForTrackExpenseParams = { @@ -2805,6 +2807,7 @@ function deleteTrackExpense({ allTransactionViolationsParam, currentUserAccountID, currentUserEmail, + yourSpendPatchData, }: DeleteTrackExpenseParams) { if (!chatReportID || !transactionID) { return; @@ -2839,6 +2842,7 @@ function deleteTrackExpense({ allTransactionViolationsParam, currentUserAccountID, currentUserEmail, + yourSpendPatchData, }); return urlToNavigateBack; } diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 354f32f59369..7fa9157f64b5 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'; @@ -389,6 +390,7 @@ function updateMoneyRequestReimbursable({ parentReportNextStep, isOffline, delegateAccountID, + yourSpendPatchData, }: { transactionID: string | undefined; transactionThreadReport: OnyxEntry; @@ -403,6 +405,7 @@ function updateMoneyRequestReimbursable({ parentReportNextStep: OnyxEntry; isOffline: boolean; delegateAccountID: number | undefined; + yourSpendPatchData?: YourSpendPatchData; }) { if (!transactionID || !transactionThreadReport?.reportID) { return; @@ -426,6 +429,7 @@ function updateMoneyRequestReimbursable({ iouReportNextStep: parentReportNextStep, isOffline, delegateAccountID, + yourSpendPatchData, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); } @@ -1295,6 +1299,7 @@ type UpdateMoneyRequestAmountAndCurrencyParams = { parentReportNextStep: OnyxEntry; hash?: number; delegateAccountID: number | undefined; + yourSpendPatchData?: YourSpendPatchData; }; /** Updates the amount and currency fields of an expense */ @@ -1320,6 +1325,7 @@ function updateMoneyRequestAmountAndCurrency({ parentReportNextStep, hash, delegateAccountID, + yourSpendPatchData, }: UpdateMoneyRequestAmountAndCurrencyParams) { const transactionChanges = { amount, @@ -1352,6 +1358,7 @@ function updateMoneyRequestAmountAndCurrency({ iouReportNextStep: parentReportNextStep, hash, delegateAccountID, + yourSpendPatchData, }); removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } @@ -1386,6 +1393,7 @@ type GetUpdateMoneyRequestParamsType = { isOffline?: boolean; delegateAccountID: number | undefined; distanceOriginalPolicy?: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }; type UpdateMoneyRequestDataKeys = @@ -1428,6 +1436,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isOffline, delegateAccountID, distanceOriginalPolicy, + yourSpendPatchData, } = params; const optimisticData: Array< OnyxUpdate< @@ -2065,6 +2074,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U updatedTransaction, iouReport, currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, }); optimisticData.push(...yourSpendSnapshotTotalUpdates.optimisticData); successData.push(...yourSpendSnapshotTotalUpdates.successData); @@ -2078,6 +2088,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U updatedTransaction, iouReport, currentUserAccountID: currentUserAccountIDParam, + context: yourSpendPatchData, }); optimisticData.push(...yourSpendSnapshotReimbursableUpdates.optimisticData); successData.push(...yourSpendSnapshotReimbursableUpdates.successData); diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 94438300d592..c9c21fe92df1 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -1,13 +1,13 @@ -// eslint-disable-next-line no-restricted-imports -- Your spend "Awaiting approval"/"Repaid" totals are a billing/paid-only feature (Collect/Control), so paid-group scoping is intentional here. -import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getAmount, getConvertedAmount, getCurrency} from '@libs/TransactionUtils'; -import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDateString} from '@libs/YourSpendQueryUtils'; +import {EMPTY_YOUR_SPEND_PATCH_DATA} from '@libs/YourSpendPatchData'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; +import {buildAwaitingApprovalQuery, buildRepaidLast30DaysQuery, get30DaysAgoDateString, getPaidGroupPolicyIDs} from '@libs/YourSpendQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -25,44 +25,9 @@ type GetYourSpendSnapshotTotalUpdatesParams = { updatedTransaction: OnyxEntry; iouReport: OnyxEntry; currentUserAccountID: number; + context?: YourSpendPatchData; }; -// Mirror only each snapshot's `search` aggregates; dropping the large `data` blob keeps this bounded regardless of how many searches are cached. -type SnapshotSearch = SearchResults['search']; -let allSnapshotSearches: Record = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.SNAPSHOT, - waitForCollectionCallback: true, - callback: (value) => { - const next: Record = {}; - for (const [key, snapshot] of Object.entries(value ?? {})) { - if (snapshot?.search) { - next[key] = snapshot.search; - } - } - allSnapshotSearches = next; - }, -}); - -let allPolicies: OnyxCollection = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (value) => { - allPolicies = value ?? {}; - }, -}); - -function getPaidGroupPolicyIDs(): string[] { - const policyIDs: string[] = []; - for (const policy of Object.values(allPolicies ?? {})) { - if (policy?.id && isPaidGroupPolicy(policy)) { - policyIDs.push(policy.id); - } - } - return policyIDs; -} - function transactionMatchesAwaitingApprovalQuery(iouReport: OnyxEntry, transaction: OnyxEntry, accountID: number, paidGroupPolicyIDs: string[]): boolean { if (!iouReport || !transaction) { return false; @@ -100,13 +65,19 @@ function transactionMatchesRepaidLast30DaysQuery(iouReport: OnyxEntry, t return created > get30DaysAgoDateString(); } -function buildSnapshotTotalUpdatesForHash(snapshotHash: number | undefined, diff: number, currency: string, countDiff = 0): YourSpendSnapshotOnyxData { +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 = allSnapshotSearches[snapshotKey]; + const search = snapshotSearches[snapshotKey]; // Skip when the snapshot isn't loaded; a loaded-but-empty snapshot is a valid zero base. if (!search) { @@ -194,6 +165,7 @@ type GetYourSpendSnapshotReportMoveUpdatesParams = { fromStatus: ReportStatus; toStatus: ReportStatus; currentUserAccountID: number; + context?: YourSpendPatchData; }; function isAwaitingApprovalStatus(status: ReportStatus): boolean { @@ -215,16 +187,16 @@ function reportInRepaidScope(iouReport: OnyxEntry, accountID: number): b return !!iouReport && iouReport.ownerAccountID === accountID; } -function getSnapshotSearchResults(snapshotHash: number | undefined) { +function getSnapshotSearchResults(snapshotSearches: YourSpendPatchData['snapshotSearches'], snapshotHash: number | undefined) { if (!snapshotHash) { return undefined; } const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}` as const; - return allSnapshotSearches[snapshotKey]; + return snapshotSearches[snapshotKey]; } /** Returns a transaction's signed reimbursable amount in the snapshot currency, or null when conversion is unavailable offline. */ -function getReimbursableTransactionAmountInCurrency(transaction: Transaction, iouReport: OnyxEntry, targetCurrency: string): number | null { +function getReimbursableTransactionAmountInCurrency(policies: OnyxCollection, transaction: Transaction, iouReport: OnyxEntry, targetCurrency: string): number | null { const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); const transactionCurrency = getCurrency(transaction); @@ -232,7 +204,7 @@ function getReimbursableTransactionAmountInCurrency(transaction: Transaction, io return getAmount(transaction, isExpenseReportLocal); } // `convertedAmount` is in the policy output currency; only trust it when that matches the target currency. - const policyOutputCurrency = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]?.outputCurrency : undefined; + const policyOutputCurrency = iouReport?.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]?.outputCurrency : undefined; if (transaction.convertedAmount != null && policyOutputCurrency === targetCurrency) { return getConvertedAmount(transaction, isExpenseReportLocal); } @@ -249,6 +221,7 @@ type ReportReimbursableAggregate = { /** 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, @@ -269,7 +242,7 @@ function getReportReimbursableTotal( } } - const amount = getReimbursableTransactionAmountInCurrency(reportTransaction, iouReport, targetCurrency); + const amount = getReimbursableTransactionAmountInCurrency(policies, reportTransaction, iouReport, targetCurrency); if (amount === null) { return null; } @@ -282,14 +255,20 @@ function getReportReimbursableTotal( } /** 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(snapshotHash: number | undefined, transactions: Transaction[], enters: boolean, leaves: boolean): YourSpendSnapshotOnyxData { +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 (!allSnapshotSearches[snapshotKey]) { + if (!snapshotSearches[snapshotKey]) { return {optimisticData: [], successData: [], failureData: []}; } @@ -318,47 +297,49 @@ function getYourSpendSnapshotReportMoveUpdates({ fromStatus, toStatus, currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, }: GetYourSpendSnapshotReportMoveUpdatesParams): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; if (!iouReport) { return result; } - const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + const {paidPolicies, snapshotSearches} = context; + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - const approvalSnapshotSearch = getSnapshotSearchResults(approvalQueryJSON?.hash); + const approvalSnapshotSearch = getSnapshotSearchResults(snapshotSearches, approvalQueryJSON?.hash); const approvalSnapshotCurrency = approvalSnapshotSearch?.currency; // An empty section's snapshot has no currency yet, so fall back to the report's currency. const approvalTargetCurrency = approvalSnapshotCurrency ?? iouReport.currency; if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotSearch && approvalTargetCurrency) { - const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, false, approvalTargetCurrency); + const aggregate = getReportReimbursableTotal(paidPolicies, iouReport, reportTransactions, false, approvalTargetCurrency); if (aggregate !== null) { const enters = isAwaitingApprovalStatus(toStatus); const leaves = isAwaitingApprovalStatus(fromStatus); const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); if (diff !== 0 || countDiff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, diff, approvalTargetCurrency, countDiff)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(approvalQueryJSON?.hash, aggregate.transactions, enters, leaves)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, diff, approvalTargetCurrency, countDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, aggregate.transactions, enters, leaves)); } } } if (reportInRepaidScope(iouReport, currentUserAccountID)) { const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - const paymentSnapshotSearch = getSnapshotSearchResults(paymentQueryJSON?.hash); + const paymentSnapshotSearch = getSnapshotSearchResults(snapshotSearches, paymentQueryJSON?.hash); const paymentTargetCurrency = paymentSnapshotSearch?.currency ?? iouReport.currency; if (paymentSnapshotSearch && paymentTargetCurrency) { - const aggregate = getReportReimbursableTotal(iouReport, reportTransactions, true, paymentTargetCurrency); + const aggregate = getReportReimbursableTotal(paidPolicies, iouReport, reportTransactions, true, paymentTargetCurrency); if (aggregate !== null) { const enters = isRepaidStatus(toStatus); const leaves = isRepaidStatus(fromStatus); const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); if (diff !== 0 || countDiff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, paymentTargetCurrency, countDiff)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(paymentQueryJSON?.hash, aggregate.transactions, enters, leaves)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, diff, paymentTargetCurrency, countDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, aggregate.transactions, enters, leaves)); } } } @@ -371,6 +352,7 @@ type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { transaction: OnyxEntry; iouReport: OnyxEntry; currentUserAccountID: number; + context?: YourSpendPatchData; }; /** @@ -378,6 +360,7 @@ type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { * `enters` adds the transaction's amount/count (and its `data` row); otherwise it subtracts them. */ function getYourSpendSnapshotTransactionMembershipUpdates( + context: YourSpendPatchData, transaction: OnyxEntry, iouReport: OnyxEntry, currentUserAccountID: number, @@ -388,29 +371,30 @@ function getYourSpendSnapshotTransactionMembershipUpdates( return result; } - const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + const {paidPolicies, snapshotSearches} = context; + const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); const sign = enters ? 1 : -1; if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - const approvalSnapshotCurrency = getSnapshotSearchResults(approvalQueryJSON?.hash)?.currency; + const approvalSnapshotCurrency = getSnapshotSearchResults(snapshotSearches, approvalQueryJSON?.hash)?.currency; if (approvalSnapshotCurrency) { - const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, approvalSnapshotCurrency); + const amount = getReimbursableTransactionAmountInCurrency(paidPolicies, transaction, iouReport, approvalSnapshotCurrency); if (amount !== null) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(approvalQueryJSON?.hash, sign * amount, approvalSnapshotCurrency, sign)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(approvalQueryJSON?.hash, [transaction], enters, !enters)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, sign * amount, approvalSnapshotCurrency, sign)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, [transaction], enters, !enters)); } } } if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - const paymentSnapshotCurrency = getSnapshotSearchResults(paymentQueryJSON?.hash)?.currency; + const paymentSnapshotCurrency = getSnapshotSearchResults(snapshotSearches, paymentQueryJSON?.hash)?.currency; if (paymentSnapshotCurrency) { - const amount = getReimbursableTransactionAmountInCurrency(transaction, iouReport, paymentSnapshotCurrency); + const amount = getReimbursableTransactionAmountInCurrency(paidPolicies, transaction, iouReport, paymentSnapshotCurrency); if (amount !== null) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, sign * amount, paymentSnapshotCurrency, sign)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(paymentQueryJSON?.hash, [transaction], enters, !enters)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, sign * amount, paymentSnapshotCurrency, sign)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, [transaction], enters, !enters)); } } } @@ -419,8 +403,13 @@ function getYourSpendSnapshotTransactionMembershipUpdates( } /** Optimistically patches Your spend snapshot aggregates when a single transaction leaves a report (e.g. delete or reject). */ -function getYourSpendSnapshotTransactionRemovalUpdates({transaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { - return getYourSpendSnapshotTransactionMembershipUpdates(transaction, iouReport, currentUserAccountID, false); +function getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport, + currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, +}: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { + return getYourSpendSnapshotTransactionMembershipUpdates(context, transaction, iouReport, currentUserAccountID, false); } type GetYourSpendSnapshotReimbursableUpdatesParams = { @@ -428,6 +417,7 @@ type GetYourSpendSnapshotReimbursableUpdatesParams = { updatedTransaction: OnyxEntry; iouReport: OnyxEntry; currentUserAccountID: number; + context?: YourSpendPatchData; }; /** @@ -439,6 +429,7 @@ function getYourSpendSnapshotReimbursableUpdates({ updatedTransaction, iouReport, currentUserAccountID, + context = EMPTY_YOUR_SPEND_PATCH_DATA, }: GetYourSpendSnapshotReimbursableUpdatesParams): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; if (!transaction || !updatedTransaction || !iouReport) { @@ -453,11 +444,17 @@ function getYourSpendSnapshotReimbursableUpdates({ // 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 getYourSpendSnapshotTransactionMembershipUpdates(membershipTransaction, iouReport, currentUserAccountID, willBeReimbursable); + return getYourSpendSnapshotTransactionMembershipUpdates(context, membershipTransaction, iouReport, currentUserAccountID, willBeReimbursable); } /** Optimistically patches Your spend snapshot aggregates when a transaction amount changes. */ -function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouReport, currentUserAccountID}: GetYourSpendSnapshotTotalUpdatesParams): YourSpendSnapshotOnyxData { +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; @@ -468,18 +465,19 @@ function getYourSpendSnapshotTotalUpdates({transaction, updatedTransaction, iouR return emptyResult; } + const {paidPolicies, snapshotSearches} = context; const currency = getCurrency(updatedTransaction); - const paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + 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(approvalQueryJSON?.hash, diff, currency)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, diff, currency)); } if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(paymentQueryJSON?.hash, diff, currency)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, diff, currency)); } return result; @@ -493,6 +491,7 @@ type GetYourSpendSnapshotSplitUpdatesParams = { // 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). */ @@ -502,20 +501,22 @@ function getYourSpendSnapshotSplitUpdates({ 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 paidGroupPolicyIDs = getPaidGroupPolicyIDs(); + 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(approvalQueryJSON?.hash, reimbursableDiff, currency, reimbursableCountDiff)); + mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, reimbursableDiff, currency, reimbursableCountDiff)); return result; } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 2f29d73f1177..ae5b89fcd3aa 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'; @@ -225,6 +226,7 @@ type HandleActionButtonPressParams = { searchData?: SearchResultDataType; chatReportActions: OnyxEntry; delegateEmail?: string; + yourSpendPatchData?: YourSpendPatchData; }; function handleActionButtonPress({ @@ -261,6 +263,7 @@ function handleActionButtonPress({ searchData, chatReportActions, delegateEmail, + 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. @@ -311,6 +314,7 @@ function handleActionButtonPress({ policy, searchData, chatReportActions, + yourSpendPatchData, }); return; case CONST.SEARCH.ACTION_TYPES.APPROVE: @@ -341,6 +345,7 @@ function handleActionButtonPress({ amountOwed, iouReportCurrentNextStepDeprecated, delegateEmail, + yourSpendPatchData, }); return; case CONST.SEARCH.ACTION_TYPES.SUBMIT: { @@ -514,6 +519,7 @@ type GetPayActionCallbackParams = { policy: OnyxEntry; searchData?: SearchResultDataType; chatReportActions: OnyxEntry; + yourSpendPatchData?: YourSpendPatchData; }; function getPayActionCallback({ @@ -540,6 +546,7 @@ function getPayActionCallback({ policy, searchData, chatReportActions, + yourSpendPatchData, }: GetPayActionCallbackParams) { const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, personalPolicyID, lastPaymentMethod, getReportType(item.reportID)); @@ -587,6 +594,7 @@ function getPayActionCallback({ methodID: lastPolicyPaymentMethod === CONST.IOU.PAYMENT_TYPE.VBBA ? snapshotPolicy?.achAccount?.bankAccountID : undefined, additionalOnyxData: getSearchPayOnyxData(hash, item.reportID, currentSearchKey), chatReportActions, + yourSpendPatchData, }); } @@ -605,6 +613,7 @@ type GetApproveActionCallbackParams = { amountOwed: OnyxEntry; iouReportCurrentNextStepDeprecated?: OnyxEntry; delegateEmail?: string; + yourSpendPatchData?: YourSpendPatchData; }; function getApproveActionCallback({ @@ -622,6 +631,7 @@ function getApproveActionCallback({ amountOwed, iouReportCurrentNextStepDeprecated, delegateEmail, + yourSpendPatchData, }: GetApproveActionCallbackParams) { if (!item.reportID) { return; @@ -647,6 +657,7 @@ function getApproveActionCallback({ delegateEmail, full: true, additionalOnyxData: getSearchApproveOnyxData(hash, item.reportID, currentSearchKey), + yourSpendPatchData, }); } @@ -1178,6 +1189,7 @@ function rejectMoneyRequestInBulk( currentUserLogin: string, betas: OnyxEntry, hash?: number, + yourSpendPatchData?: YourSpendPatchData, ) { const optimisticData: Array> = []; const finallyData: Array> = []; @@ -1196,7 +1208,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, {yourSpendPatchData}, true, undefined); if (data) { optimisticData.push(...data.optimisticData); successData.push(...data.successData); @@ -1233,6 +1245,7 @@ function rejectMoneyRequestsOnSearch( currentUserAccountIDParam: number, currentUserLogin: string, betas: OnyxEntry, + yourSpendPatchData?: YourSpendPatchData, ) { const transactionIDs = Object.keys(selectedTransactions); @@ -1265,7 +1278,7 @@ function rejectMoneyRequestsOnSearch( 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, yourSpendPatchData); } else { // Share a single destination ID across all rejections from the same source report const sharedRejectedToReportID = generateReportID(); @@ -1278,6 +1291,7 @@ function rejectMoneyRequestsOnSearch( sharedRejectedToReportID, existingRejectedReport, setExistingRejectedReport, + yourSpendPatchData, }); } } diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index 6d0761bd14be..9c203fd195dd 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'; @@ -195,6 +196,7 @@ type GetIouParamsInput = { type TransactionInlineEditParams = GetIouParamsInput & { hash: number | undefined; isOffline: boolean; + yourSpendPatchData?: YourSpendPatchData; }; /** @@ -372,6 +374,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 9dd15b162f46..62361651884a 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -36,6 +36,7 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getBase62ReportID from '@libs/getBase62ReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -346,6 +347,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report reportActions: requestParentReportAction ? [requestParentReportAction] : [], policy, }); + const yourSpendPatchData = useYourSpendPatchData(); const isCardTransactionCanBeDeleted = canDeleteCardTransactionByLiabilityType(iouTransaction); const shouldShowDeleteButton = shouldShowTaskDeleteButton || (canDeleteRequest && isCardTransactionCanBeDeleted) || isDemoTransaction(iouTransaction); const shouldShowEditSplitOnDeleteAction = iouTransactionID ? shouldOpenSplitExpenseEditFlowOnDelete([iouTransactionID]) : false; @@ -968,6 +970,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report allTransactionViolationsParam: allTransactionViolations, currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', + yourSpendPatchData, }); } else if (iouTransactionID) { const deleteResult = deleteTransactions([iouTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined, isSingleTransactionView); @@ -1003,6 +1006,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report allTransactionViolations, deleteTransactions, removeTransaction, + yourSpendPatchData, ]); // 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 d07134260b50..2ff8b04d2510 100644 --- a/src/pages/RejectExpenseReportPage.tsx +++ b/src/pages/RejectExpenseReportPage.tsx @@ -15,6 +15,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -79,6 +80,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [lastForwardedActorAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`, {selector: lastForwardedActorAccountIDSelector}); @@ -160,6 +162,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { currentUserPersonalDetails?.accountID, currentUserPersonalDetails?.displayName, currentUserPersonalDetails?.avatar, + yourSpendPatchData, ); Navigation.goBack(); }; diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 00e362de2752..2009bcc218eb 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -18,6 +18,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {search} from '@libs/actions/Search'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -99,6 +100,7 @@ function ReportSubmitToContent({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const yourSpendPatchData = useYourSpendPatchData(); const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); @@ -304,6 +306,7 @@ function ReportSubmitToContent({ ownerBillingGracePeriodEnd, delegateEmail, submitterLogin, + yourSpendPatchData, managerEmail: trimmed, managerAccountID: resolvedManagerAccountID, onSubmitted: () => { @@ -340,6 +343,7 @@ function ReportSubmitToContent({ ownerBillingGracePeriodEnd, delegateEmail, submitterLogin, + yourSpendPatchData, currentSearchQueryJSON, isOffline, currentSearchKey, diff --git a/src/pages/Search/SearchRejectReasonPage.tsx b/src/pages/Search/SearchRejectReasonPage.tsx index 7fb51cb1c7e5..195f8926898d 100644 --- a/src/pages/Search/SearchRejectReasonPage.tsx +++ b/src/pages/Search/SearchRejectReasonPage.tsx @@ -5,6 +5,7 @@ import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionCont import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {clearErrorFields, clearErrors} from '@libs/actions/FormActions'; import {rejectMoneyRequestsOnSearch} from '@libs/actions/Search'; @@ -38,6 +39,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); // 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, + yourSpendPatchData, ); if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS) { clearSelectedTransactions(true); @@ -90,6 +93,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { route.name, showDelegateNoAccessModal, clearSelectedTransactions, + yourSpendPatchData, ], ); diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index 3b4b544fe941..4cab3b9eea00 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -13,6 +13,7 @@ import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {deleteAppReport, deleteReportComment} from '@libs/actions/Report'; @@ -358,6 +359,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro reportActions: reportActionRef.current ? [reportActionRef.current] : [], policy, }); + const yourSpendPatchData = useYourSpendPatchData(); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getOriginalReportID(reportIDRef.current, reportActionRef.current, reportActions)}`); const ancestorsRef = useRef([]); @@ -391,6 +393,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro allTransactionViolationsParam: allTransactionViolations, currentUserAccountID, currentUserEmail: email ?? '', + yourSpendPatchData, }); } else if (originalMessage?.IOUTransactionID) { const deleteResult = deleteTransactions([originalMessage.IOUTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined); @@ -454,6 +457,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro visibleReportActionsData, iouTransaction, iouOriginalTransaction, + yourSpendPatchData, ]); const hideDeleteModal = () => { diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index d2e3b0c35076..bea244fba21e 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -7,6 +7,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -40,6 +41,7 @@ function RejectReasonPage({route}: RejectReasonPageProps) { const policy = usePolicy(reportPolicyID); const {superWideRHPRouteKeys} = useWideRHPState(); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); const [betas] = useOnyx(ONYXKEYS.BETAS); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -49,7 +51,7 @@ 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}); 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 27118ed6ee2e..b6358869fe61 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -27,6 +27,7 @@ import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {getIOUActionForTransactions} from '@libs/actions/IOU/Duplicate'; import {getIOURequestPolicyID} from '@libs/actions/IOU/MoneyRequest'; @@ -122,6 +123,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [expenseReportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(expenseReport?.policyID)}`); const allTransactions = useAllTransactions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`]; const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`]; @@ -381,6 +383,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { transactionReport: draftTransactionReport, expenseReport, isOffline, + yourSpendPatchData, }); }; diff --git a/src/pages/iou/request/step/AmountSubmission.ts b/src/pages/iou/request/step/AmountSubmission.ts index 128ecccd668b..aff647750157 100644 --- a/src/pages/iou/request/step/AmountSubmission.ts +++ b/src/pages/iou/request/step/AmountSubmission.ts @@ -18,6 +18,7 @@ import Permissions from '@libs/Permissions'; import {getPolicyExpenseChat, getTransactionDetails, isMoneyRequestReport, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue, hasReceipt} from '@libs/TransactionUtils'; +import type {YourSpendPatchData} from '@libs/YourSpendPatchData'; import { getMoneyRequestParticipantsFromReport, @@ -171,6 +172,7 @@ type SubmitAmountArgs = { allReportNVPs: OnyxCollection; duplicateTransactions: OnyxCollection; duplicateTransactionViolations: OnyxCollection; + yourSpendPatchData?: YourSpendPatchData; }; /** @@ -248,6 +250,7 @@ function submitAmount({ allReportNVPs, duplicateTransactions, duplicateTransactionViolations, + yourSpendPatchData, }: SubmitAmountArgs): void { const isEditing = action === CONST.IOU.ACTION.EDIT; const isCreateAction = action === CONST.IOU.ACTION.CREATE; @@ -567,6 +570,7 @@ function submitAmount({ isASAPSubmitBetaEnabled, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], delegateAccountID, + yourSpendPatchData, }); navigateBack(); } diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 0d83d46884fb..c3d439819595 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -16,6 +16,7 @@ import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useSkipConfirmationPreInsert from '@hooks/useSkipConfirmationPreInsert'; +import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -69,6 +70,7 @@ function IOURequestStepAmount({ const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const yourSpendPatchData = useYourSpendPatchData(); const delegateAccountID = useDelegateAccountID(); const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const textInput = useRef(null); @@ -216,6 +218,7 @@ function IOURequestStepAmount({ allReportNVPs, duplicateTransactions, duplicateTransactionViolations, + yourSpendPatchData, }); }; diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index 0185623cb9b5..8dc9d6f869a8 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -1,6 +1,7 @@ import {getYourSpendSnapshotReportMoveUpdates} 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'; @@ -8,6 +9,8 @@ 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'; @@ -107,27 +110,35 @@ beforeEach(() => { return Onyx.clear().then(waitForBatchedUpdates); }); -async function seedAwaitingApprovalSnapshot(total: number, currency: string = CONST.CURRENCY.USD) { +/** + * 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); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); - await Onyx.set(snapshotKey, buildSnapshotSearchResults(total, currency)); - await waitForBatchedUpdates(); - return snapshotKey; + const context = buildContext(snapshotKey, buildSnapshotSearchResults(total, currency, count).search); + return {snapshotKey, context}; } -async function seedRepaidSnapshot(total: number, currency: string = CONST.CURRENCY.USD) { +function seedRepaidSnapshot(total: number, currency: string = CONST.CURRENCY.USD) { const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(ACCOUNT_ID)); const snapshotKey = getSnapshotKey(paymentQueryJSON?.hash ?? 0); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, paidPolicy); - await Onyx.set(snapshotKey, buildSnapshotSearchResults(total, currency)); - await waitForBatchedUpdates(); - return snapshotKey; + 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 = await seedAwaitingApprovalSnapshot(-10000); + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-10000); const {optimisticData, failureData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(SUBMITTED_STATUS), @@ -135,6 +146,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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 @@ -162,7 +174,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { }); it('subtracts the report total from awaiting approval when a report is retracted (SUBMITTED -> OPEN)', async () => { - const snapshotKey = await seedAwaitingApprovalSnapshot(-30000); + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-30000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(OPEN_STATUS), @@ -170,6 +182,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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`. @@ -186,7 +199,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { }); it('subtracts the report total from awaiting approval when a report is rejected (SUBMITTED -> OPEN)', async () => { - const snapshotKey = await seedAwaitingApprovalSnapshot(-30000); + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-30000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(OPEN_STATUS), @@ -194,6 +207,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { fromStatus: SUBMITTED_STATUS, toStatus: OPEN_STATUS, currentUserAccountID: ACCOUNT_ID, + context, }); expect(optimisticData).toEqual([ @@ -209,7 +223,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { }); it('adds the report total back to awaiting approval when a report is unapproved (APPROVED -> SUBMITTED)', async () => { - const snapshotKey = await seedAwaitingApprovalSnapshot(-10000); + const {snapshotKey, context} = seedAwaitingApprovalSnapshot(-10000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(SUBMITTED_STATUS), @@ -217,6 +231,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { fromStatus: APPROVED_STATUS, toStatus: SUBMITTED_STATUS, currentUserAccountID: ACCOUNT_ID, + context, }); expect(optimisticData).toEqual([ @@ -232,7 +247,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { }); it('subtracts the report total from repaid when a payment is cancelled (REIMBURSED -> APPROVED)', async () => { - const snapshotKey = await seedRepaidSnapshot(-30000); + const {snapshotKey, context} = seedRepaidSnapshot(-30000); const recentTransaction = buildTransaction({created: getRecentCreatedDate()}); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ @@ -241,6 +256,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { fromStatus: REIMBURSED_STATUS, toStatus: APPROVED_STATUS, currentUserAccountID: ACCOUNT_ID, + context, }); expect(optimisticData).toEqual([ @@ -256,7 +272,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { }); it('does not patch when the report is not owned by the current user', async () => { - await seedAwaitingApprovalSnapshot(10000); + const {context} = seedAwaitingApprovalSnapshot(10000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport({...SUBMITTED_STATUS, ownerAccountID: OTHER_ACCOUNT_ID}), @@ -264,13 +280,14 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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 () => { - await seedAwaitingApprovalSnapshot(10000); + const {context} = seedAwaitingApprovalSnapshot(10000); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(SUBMITTED_STATUS), @@ -278,16 +295,17 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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', async () => { + 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); - await Onyx.set(snapshotKey, buildSnapshotSearchResults(10000, CONST.CURRENCY.USD)); - await waitForBatchedUpdates(); + // 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), @@ -295,13 +313,14 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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 () => { - await seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.EUR); + const {context} = seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.EUR); const {optimisticData} = getYourSpendSnapshotReportMoveUpdates({ iouReport: buildExpenseReport(SUBMITTED_STATUS), @@ -309,13 +328,14 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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', async () => { - const snapshotKey = await seedAwaitingApprovalSnapshot(-10000); + 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({ @@ -324,6 +344,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { fromStatus: OPEN_STATUS, toStatus: SUBMITTED_STATUS, currentUserAccountID: ACCOUNT_ID, + context, }); expect(optimisticData).toEqual([ @@ -338,12 +359,14 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { ]); }); - it('does not patch with convertedAmount when the policy output currency differs from the snapshot currency', async () => { + 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. - await seedAwaitingApprovalSnapshot(10000, CONST.CURRENCY.USD); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {...paidPolicy, outputCurrency: CONST.CURRENCY.EUR}); - await waitForBatchedUpdates(); + 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}), @@ -351,17 +374,14 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { 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)', 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(0, CONST.CURRENCY.USD, 0)); - await waitForBatchedUpdates(); + 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), @@ -369,6 +389,7 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { fromStatus: OPEN_STATUS, toStatus: SUBMITTED_STATUS, currentUserAccountID: ACCOUNT_ID, + context, }); expect(optimisticData).toEqual([ diff --git a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts index 3c40a777838d..0b82cc46a7b1 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -7,6 +7,7 @@ import { } 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'; @@ -56,6 +57,17 @@ function buildSnapshotSearchResults(total: number, currency: string): SearchResu }; } +/** + * 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, @@ -124,6 +136,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { updatedTransaction, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), }); expect(optimisticData).toEqual([ @@ -170,6 +183,7 @@ describe('getYourSpendSnapshotTotalUpdates', () => { updatedTransaction, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, 10000, CONST.CURRENCY.USD), }); expect(optimisticData).toHaveLength(0); @@ -190,6 +204,7 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { transaction, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -30000, CONST.CURRENCY.USD), }); const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; @@ -228,6 +243,7 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { transaction: {...transaction, reimbursable: false}, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, 30000, CONST.CURRENCY.USD), }); expect(optimisticData).toHaveLength(0); @@ -249,6 +265,7 @@ describe('getYourSpendSnapshotReimbursableUpdates', () => { updatedTransaction: {...transaction, reimbursable: false}, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -30000, CONST.CURRENCY.USD), }); const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; @@ -278,6 +295,7 @@ describe('getYourSpendSnapshotReimbursableUpdates', () => { updatedTransaction, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), }); const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`; @@ -304,6 +322,7 @@ describe('getYourSpendSnapshotReimbursableUpdates', () => { updatedTransaction: {...transaction, merchant: 'Renamed'}, iouReport: expenseReport, currentUserAccountID: ACCOUNT_ID, + context: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), }); expect(optimisticData).toHaveLength(0); @@ -334,6 +353,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { isASAPSubmitBetaEnabled: false, iouReportNextStep: undefined, delegateAccountID: undefined, + yourSpendPatchData: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), }); const snapshotOptimisticUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey); @@ -373,6 +393,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { isASAPSubmitBetaEnabled: false, iouReportNextStep: undefined, delegateAccountID: undefined, + 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); @@ -406,6 +427,7 @@ describe('getUpdateMoneyRequestParams — Your spend snapshot totals', () => { isASAPSubmitBetaEnabled: false, iouReportNextStep: undefined, delegateAccountID: undefined, + yourSpendPatchData: buildYourSpendPatchData(snapshotKey, -10000, CONST.CURRENCY.USD), }); const snapshotOptimisticUpdate = onyxData.optimisticData?.find((update) => update.key === snapshotKey); From eea3c275ba1b914cf4a679b9e3c6b685ae9403fd Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 9 Jul 2026 18:21:50 +0200 Subject: [PATCH 25/32] Remove unused SnapshotSearch export from YourSpendPatchData --- Mobile-Expensify | 2 +- src/libs/YourSpendPatchData.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3ded92487c2c..3cc796a31e0e 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3ded92487c2ce944cf17106bbc38c577fe1cfc12 +Subproject commit 3cc796a31e0ecf409c88d858e24c50c17c874e67 diff --git a/src/libs/YourSpendPatchData.ts b/src/libs/YourSpendPatchData.ts index 281c7c0da5ec..d7427d79b5a0 100644 --- a/src/libs/YourSpendPatchData.ts +++ b/src/libs/YourSpendPatchData.ts @@ -15,4 +15,4 @@ type YourSpendPatchData = { const EMPTY_YOUR_SPEND_PATCH_DATA: YourSpendPatchData = {paidPolicies: {}, snapshotSearches: {}}; export {EMPTY_YOUR_SPEND_PATCH_DATA}; -export type {SnapshotSearch, YourSpendPatchData}; +export type {YourSpendPatchData}; From 6f1a6ecd7e2a986d401f5f33aef9f18b082db764 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 15 Jul 2026 15:28:43 +0200 Subject: [PATCH 26/32] Reset Mobile-Expensify submodule pointer to match eorigin/main --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3cc796a31e0e..81378893001c 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3cc796a31e0ecf409c88d858e24c50c17c874e67 +Subproject commit 81378893001cce14bd28ea04dc3a841470f9b764 From 71120b855ffff72ff71fd014f5ff8bb1d79f33ea Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 16 Jul 2026 15:26:59 +0200 Subject: [PATCH 27/32] Document Your spend snapshot update types --- src/libs/actions/IOU/YourSpendSnapshotUpdate.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index c9c21fe92df1..375f334a6a33 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -14,17 +14,33 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-nat 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; }; From f547b32f457c0f1066b333e0721b7218f01cb6b9 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 16 Jul 2026 15:30:25 +0200 Subject: [PATCH 28/32] Add tests for paid-group policy helpers in YourSpendQueryUtils --- .../unit/Search/yourSpendQueryBuildersTest.ts | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/tests/unit/Search/yourSpendQueryBuildersTest.ts b/tests/unit/Search/yourSpendQueryBuildersTest.ts index 2fe44b147669..6e91ec63125d 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 '@libs/YourSpendQueryUtils'; +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,65 @@ 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('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}null`]: null, + }; + + expect(getPaidGroupPolicyIDs(collection)).toEqual(['1']); + }); +}); From 7c5b23f9ff44fe13d0b06731c47e6de797192a30 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 16 Jul 2026 15:47:46 +0200 Subject: [PATCH 29/32] Add tests for getRepaidReportsSignature and DEW Your spend snapshot guard --- tests/actions/IOUTest/ReportWorkflowTest.ts | 119 +++++++++++++++++- .../unit/Search/yourSpendApplicabilityTest.ts | 48 ++++++- 2 files changed, 165 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest/ReportWorkflowTest.ts b/tests/actions/IOUTest/ReportWorkflowTest.ts index 570651ea129e..f0190325ddbe 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/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'); + }); +}); From ee14b12abefe4e1961b80d3e8b2f016f20dda4d0 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 16 Jul 2026 16:56:43 +0200 Subject: [PATCH 30/32] Fix Your spend split diff sign to match the snapshot total convention getReimbursableSplitDiff returned a magnitude-space delta while snapshot totals are signed (spend negative), so splitting reimbursable value into non-reimbursable moved the awaiting-approval total the wrong way. --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 10 ++++++---- .../actions/IOUTest/GetReimbursableSplitDiffTest.ts | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index df9bab9e50d4..4778e697c777 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -123,9 +123,10 @@ type GetReimbursableSplitDiffParams = { }; /** - * Returns the change to the report's reimbursable total from a split, in report-currency magnitude. - * 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. + * 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); @@ -133,7 +134,8 @@ function getReimbursableSplitDiff({splits, originalTransaction, originalChildTra const previousReimbursableTotal = isCreationOfSplits ? creationPreviousTotal : originalChildTransactions.reduce((total, childTransaction) => total + (childTransaction?.reimbursable !== false ? Math.abs(childTransaction?.amount ?? 0) : 0), 0); - return newReimbursableTotal - previousReimbursableTotal; + // 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({ diff --git a/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts b/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts index 0ed46fbefb3b..2c92267f426c 100644 --- a/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts +++ b/tests/actions/IOUTest/GetReimbursableSplitDiffTest.ts @@ -28,8 +28,8 @@ describe('getReimbursableSplitDiff', () => { isCreationOfSplits: true, }); - // Then Your spend should drop by the $40 that became non-reimbursable - expect(diff).toBe(-4000); + // 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', () => { @@ -53,7 +53,7 @@ describe('getReimbursableSplitDiff', () => { isCreationOfSplits: true, }); - expect(diff).toBe(-4000); + expect(diff).toBe(4000); }); it('adds only the reimbursable portion when the original expense was non-reimbursable', () => { @@ -65,7 +65,8 @@ describe('getReimbursableSplitDiff', () => { isCreationOfSplits: true, }); - expect(diff).toBe(6000); + // $60 of new reimbursable spend pushes the (negative) snapshot total further from zero + expect(diff).toBe(-6000); }); }); @@ -80,8 +81,8 @@ describe('getReimbursableSplitDiff', () => { isCreationOfSplits: false, }); - // Then Your spend should drop by the $50 that became non-reimbursable - expect(diff).toBe(-5000); + // 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', () => { From 86ff9396e27ff82deff2f62ca70e56a46e6291a6 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 16 Jul 2026 16:57:18 +0200 Subject: [PATCH 31/32] Aggregate Your spend snapshot updates per bulk action Per-item updates each wrote an absolute total from the same snapshot base, so bulk delete/reject/approve/pay only kept the last item's patch. Batch builders now sum the whole selection (across reports) into one snapshot update attached to a single request. --- src/hooks/useDeleteTransactions.ts | 32 ++- src/hooks/useSearchBulkActions.ts | 55 ++++- src/libs/actions/IOU/DeleteMoneyRequest.ts | 25 +- src/libs/actions/IOU/PayMoneyRequest.ts | 56 ++++- src/libs/actions/IOU/RejectMoneyRequest.ts | 18 +- src/libs/actions/IOU/ReportWorkflow.ts | 53 ++++- .../actions/IOU/YourSpendSnapshotUpdate.ts | 225 +++++++++++++----- src/libs/actions/Search.ts | 37 ++- ...tYourSpendSnapshotReportMoveUpdatesTest.ts | 56 ++++- .../GetYourSpendSnapshotTotalUpdatesTest.ts | 50 ++++ 10 files changed, 482 insertions(+), 125 deletions(-) diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index ecf10688ab34..330d08a85ebd 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -5,6 +5,8 @@ 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'; @@ -332,14 +334,24 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac }); } - 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: yourSpendPatchData, + }); + + 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; @@ -361,8 +373,10 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', policy: iouPolicy, - yourSpendPatchData, + 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); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index d495e42eabb3..173af170cef4 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -9,8 +9,10 @@ import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchFilterKe 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} 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 { @@ -820,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: yourSpendPatchData, + }); + + 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, @@ -852,10 +867,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { additionalOnyxData: getSearchApproveOnyxData(hash, reportID, currentSearchKey), shouldPlaySuccessSound: false, isTrackIntentUser, - yourSpendPatchData, + 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; } } @@ -1154,6 +1171,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; @@ -1238,7 +1257,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { continue; } - payMoneyRequest({ + payCalls.push({ paymentType: paymentItem.paymentType as PaymentMethodType, chatReport, iouReport, @@ -1259,13 +1278,23 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { paymentItem.paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? (paymentItem.bankAccountID ?? workspaceMethodID ?? reportPolicy?.achAccount?.bankAccountID) : undefined, additionalOnyxData, shouldPlaySuccessSound: false, - yourSpendPatchData, chatReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`], isTrackIntentUser, }); paidReportCount += 1; } + let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotReportsMoveUpdates({ + reportItems: payCalls.map(({iouReport}) => getPayYourSpendReportMoveItem(iouReport)).filter((item): item is YourSpendReportMoveItem => !!item), + currentUserAccountID: accountID, + context: yourSpendPatchData, + }); + 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(); diff --git a/src/libs/actions/IOU/DeleteMoneyRequest.ts b/src/libs/actions/IOU/DeleteMoneyRequest.ts index 9fa8cfce408e..48083d6022d3 100644 --- a/src/libs/actions/IOU/DeleteMoneyRequest.ts +++ b/src/libs/actions/IOU/DeleteMoneyRequest.ts @@ -41,6 +41,8 @@ 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'; @@ -77,6 +79,8 @@ type DeleteMoneyRequestFunctionParams = { 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. */ @@ -741,6 +745,7 @@ function deleteMoneyRequest({ currentUserEmail, policy, yourSpendPatchData, + yourSpendSnapshotUpdates, }: DeleteMoneyRequestFunctionParams) { if (!transactionID) { return; @@ -1039,18 +1044,20 @@ function deleteMoneyRequest({ reportActionID: reportAction.reportActionID, }; - const yourSpendSnapshotUpdates = getYourSpendSnapshotTransactionRemovalUpdates({ - transaction, - iouReport, - currentUserAccountID, - context: yourSpendPatchData, - }); + const yourSpendUpdates = + yourSpendSnapshotUpdates ?? + getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport, + currentUserAccountID, + context: yourSpendPatchData, + }); // STEP 3: Make the API request API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, { - optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], - successData: [...successData, ...yourSpendSnapshotUpdates.successData], - failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + optimisticData: [...optimisticData, ...yourSpendUpdates.optimisticData], + successData: [...successData, ...yourSpendUpdates.successData], + failureData: [...failureData, ...yourSpendUpdates.failureData], }); clearPdfByOnyxKey(transactionID); diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 300fb87b5cc7..efbab06226d1 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -48,6 +48,8 @@ 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'; @@ -126,6 +128,8 @@ type PayMoneyRequestFunctionParams = { 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< @@ -172,6 +176,7 @@ function getPayMoneyRequestParams({ chatReportActions, isTrackIntentUser, yourSpendPatchData, + yourSpendSnapshotUpdates, }: { initialChatReport: OnyxTypes.Report; iouReport: OnyxEntry; @@ -198,6 +203,7 @@ function getPayMoneyRequestParams({ 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 @@ -521,17 +527,19 @@ function getPayMoneyRequestParams({ } // Paying a report moves it from "Awaiting approval" to "Repaid" in the Your spend widget; patch both snapshots offline. - const 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(...yourSpendSnapshotUpdates.optimisticData); - onyxData.successData?.push(...yourSpendSnapshotUpdates.successData); - onyxData.failureData?.push(...yourSpendSnapshotUpdates.failureData); + 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: { @@ -830,6 +838,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, @@ -857,6 +878,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { chatReportActions, isTrackIntentUser, yourSpendPatchData, + yourSpendSnapshotUpdates, } = params; const policyForBillingRestriction = chatReportPolicy ?? (policy?.id === chatReport.policyID ? policy : undefined); if ( @@ -893,6 +915,7 @@ function payMoneyRequest(params: PayMoneyRequestFunctionParams) { chatReportActions, isTrackIntentUser, yourSpendPatchData, + yourSpendSnapshotUpdates, }); // For now, we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with @@ -1209,5 +1232,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 b445e1828aaa..d0876d8ec78d 100644 --- a/src/libs/actions/IOU/RejectMoneyRequest.ts +++ b/src/libs/actions/IOU/RejectMoneyRequest.ts @@ -46,6 +46,8 @@ 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'; @@ -86,6 +88,8 @@ type RejectMoneyRequestOptions = { 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() { @@ -916,12 +920,14 @@ function prepareRejectMoneyRequestData( expenseCreatedReportActionID, }; - const yourSpendSnapshotUpdates = getYourSpendSnapshotTransactionRemovalUpdates({ - transaction, - iouReport: report, - currentUserAccountID: currentUserAccountIDParam, - context: options?.yourSpendPatchData, - }); + const yourSpendSnapshotUpdates = + options?.yourSpendSnapshotUpdates ?? + getYourSpendSnapshotTransactionRemovalUpdates({ + transaction, + iouReport: report, + currentUserAccountID: currentUserAccountIDParam, + context: options?.yourSpendPatchData, + }); optimisticData.push(...yourSpendSnapshotUpdates.optimisticData); failureData.push(...yourSpendSnapshotUpdates.failureData); diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 67157b724833..fb50b46f7144 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -89,6 +89,7 @@ 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'; @@ -115,6 +116,8 @@ type ApproveMoneyRequestFunctionParams = { 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 = { @@ -432,6 +435,26 @@ function getReportOriginalCreationTimestamp(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, @@ -453,6 +476,7 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { shouldPlaySuccessSound = true, isTrackIntentUser, yourSpendPatchData, + yourSpendSnapshotUpdates, } = params; if (!expenseReport) { return; @@ -814,16 +838,18 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { // 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, - fromStatus: {stateNum: expenseReport.stateNum, statusNum: expenseReport.statusNum}, - toStatus: {stateNum: predictedNextState, statusNum: predictedNextStatus}, - currentUserAccountID: currentUserAccountIDParam, - context: yourSpendPatchData, - }); + 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) { @@ -834,9 +860,9 @@ function approveMoneyRequest(params: ApproveMoneyRequestFunctionParams) { parameters, mergeAdditionalPayOnyxData( { - optimisticData: [...optimisticData, ...yourSpendSnapshotUpdates.optimisticData], - successData: [...successData, ...yourSpendSnapshotUpdates.successData], - failureData: [...failureData, ...yourSpendSnapshotUpdates.failureData], + optimisticData: [...optimisticData, ...yourSpendUpdates.optimisticData], + successData: [...successData, ...yourSpendUpdates.successData], + failureData: [...failureData, ...yourSpendUpdates.failureData], }, additionalOnyxData, ), @@ -1978,6 +2004,7 @@ export { canIOUBePaid, canSubmitReport, clearPendingExpenseAction, + getApproveYourSpendReportMoveItem, getBadgeFromIOUReport, getIOUReportActionWithBadge, getReportOriginalCreationTimestamp, diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 375f334a6a33..93d845afb3ca 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -306,64 +306,130 @@ function buildSnapshotDataUpdatesForHash( }; } -/** 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, +/** 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, -}: GetYourSpendSnapshotReportMoveUpdatesParams): YourSpendSnapshotOnyxData { +}: GetYourSpendSnapshotReportsMoveUpdatesParams): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; - if (!iouReport) { + 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); - const approvalSnapshotCurrency = approvalSnapshotSearch?.currency; - // An empty section's snapshot has no currency yet, so fall back to the report's currency. - const approvalTargetCurrency = approvalSnapshotCurrency ?? iouReport.currency; - - if (reportInAwaitingApprovalScope(iouReport, currentUserAccountID, paidGroupPolicyIDs) && approvalSnapshotSearch && approvalTargetCurrency) { - const aggregate = getReportReimbursableTotal(paidPolicies, iouReport, reportTransactions, false, approvalTargetCurrency); - if (aggregate !== null) { + // 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 diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); - const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); - if (diff !== 0 || countDiff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, diff, approvalTargetCurrency, countDiff)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, aggregate.transactions, enters, leaves)); + 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); } } - if (reportInRepaidScope(iouReport, currentUserAccountID)) { - const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - const paymentSnapshotSearch = getSnapshotSearchResults(snapshotSearches, paymentQueryJSON?.hash); - const paymentTargetCurrency = paymentSnapshotSearch?.currency ?? iouReport.currency; - if (paymentSnapshotSearch && paymentTargetCurrency) { + 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) { - const enters = isRepaidStatus(toStatus); - const leaves = isRepaidStatus(fromStatus); - const diff = (enters ? aggregate.total : 0) - (leaves ? aggregate.total : 0); - const countDiff = (enters ? aggregate.count : 0) - (leaves ? aggregate.count : 0); - if (diff !== 0 || countDiff !== 0) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, diff, paymentTargetCurrency, countDiff)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, aggregate.transactions, enters, leaves)); - } + 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; @@ -371,19 +437,26 @@ type GetYourSpendSnapshotTransactionRemovalUpdatesParams = { context?: YourSpendPatchData; }; +type GetYourSpendSnapshotTransactionsRemovalUpdatesParams = { + transactionItems: YourSpendTransactionItem[]; + currentUserAccountID: number; + context?: YourSpendPatchData; +}; + /** - * Optimistically patches Your spend snapshot aggregates when a single transaction enters or leaves the reimbursable-only sections. - * `enters` adds the transaction's amount/count (and its `data` row); otherwise it subtracts them. + * 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 getYourSpendSnapshotTransactionMembershipUpdates( +function getYourSpendSnapshotTransactionsMembershipUpdates( context: YourSpendPatchData, - transaction: OnyxEntry, - iouReport: OnyxEntry, + transactionItems: YourSpendTransactionItem[], currentUserAccountID: number, enters: boolean, ): YourSpendSnapshotOnyxData { const result: YourSpendSnapshotOnyxData = {optimisticData: [], successData: [], failureData: []}; - if (!transaction || !iouReport) { + if (transactionItems.length === 0) { return result; } @@ -391,33 +464,68 @@ function getYourSpendSnapshotTransactionMembershipUpdates( const paidGroupPolicyIDs = getPaidGroupPolicyIDs(paidPolicies); const sign = enters ? 1 : -1; - if (transactionMatchesAwaitingApprovalQuery(iouReport, transaction, currentUserAccountID, paidGroupPolicyIDs)) { - const approvalQueryJSON = buildSearchQueryJSON(buildAwaitingApprovalQuery(currentUserAccountID, paidGroupPolicyIDs)); - const approvalSnapshotCurrency = getSnapshotSearchResults(snapshotSearches, approvalQueryJSON?.hash)?.currency; - if (approvalSnapshotCurrency) { + 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) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, sign * amount, approvalSnapshotCurrency, sign)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, approvalQueryJSON?.hash, [transaction], enters, !enters)); + 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)); } } - if (transactionMatchesRepaidLast30DaysQuery(iouReport, transaction, currentUserAccountID)) { - const paymentQueryJSON = buildSearchQueryJSON(buildRepaidLast30DaysQuery(currentUserAccountID)); - const paymentSnapshotCurrency = getSnapshotSearchResults(snapshotSearches, paymentQueryJSON?.hash)?.currency; - if (paymentSnapshotCurrency) { + 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) { - mergeYourSpendSnapshotOnyxData(result, buildSnapshotTotalUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, sign * amount, paymentSnapshotCurrency, sign)); - mergeYourSpendSnapshotOnyxData(result, buildSnapshotDataUpdatesForHash(snapshotSearches, paymentQueryJSON?.hash, [transaction], enters, !enters)); + 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, @@ -425,7 +533,7 @@ function getYourSpendSnapshotTransactionRemovalUpdates({ currentUserAccountID, context = EMPTY_YOUR_SPEND_PATCH_DATA, }: GetYourSpendSnapshotTransactionRemovalUpdatesParams): YourSpendSnapshotOnyxData { - return getYourSpendSnapshotTransactionMembershipUpdates(context, transaction, iouReport, currentUserAccountID, false); + return getYourSpendSnapshotTransactionsMembershipUpdates(context, [{transaction, iouReport}], currentUserAccountID, false); } type GetYourSpendSnapshotReimbursableUpdatesParams = { @@ -460,7 +568,7 @@ function getYourSpendSnapshotReimbursableUpdates({ // 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 getYourSpendSnapshotTransactionMembershipUpdates(context, membershipTransaction, iouReport, currentUserAccountID, willBeReimbursable); + return getYourSpendSnapshotTransactionsMembershipUpdates(context, [{transaction: membershipTransaction, iouReport}], currentUserAccountID, willBeReimbursable); } /** Optimistically patches Your spend snapshot aggregates when a transaction amount changes. */ @@ -502,7 +610,7 @@ function getYourSpendSnapshotTotalUpdates({ type GetYourSpendSnapshotSplitUpdatesParams = { iouReport: OnyxEntry; originalTransaction: OnyxEntry; - // Signed change to the report's reimbursable total, in the report currency. + // 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; @@ -539,8 +647,11 @@ function getYourSpendSnapshotSplitUpdates({ 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 7455281a427f..e5099c00629f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -92,11 +92,13 @@ import Onyx from 'react-native-onyx'; import type {AdditionalPayOnyxData} from './IOU/PayMoneyRequest'; import type {RejectMoneyRequestData} from './IOU/RejectMoneyRequest'; +import type {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 {getYourSpendSnapshotTransactionsRemovalUpdates} from './IOU/YourSpendSnapshotUpdate'; import {isCurrencySupportedForGlobalReimbursement} from './Policy/Policy'; import {setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; @@ -1202,11 +1204,11 @@ function rejectMoneyRequestInBulk( currentUserLogin: string, betas: OnyxEntry, hash?: number, - yourSpendPatchData?: YourSpendPatchData, + 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}; @@ -1221,7 +1223,7 @@ function rejectMoneyRequestInBulk( } > = {}; for (const transactionID of transactionIDs) { - const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, {yourSpendPatchData}, true, undefined); + const data = prepareRejectMoneyRequestData(transactionID, reportID, comment, policy, currentUserAccountIDParam, currentUserLogin, betas, undefined, true, undefined); if (data) { optimisticData.push(...data.optimisticData); successData.push(...data.successData); @@ -1233,6 +1235,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, { @@ -1280,6 +1287,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; @@ -1290,8 +1312,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, yourSpendPatchData); + 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(); @@ -1304,8 +1329,10 @@ function rejectMoneyRequestsOnSearch( sharedRejectedToReportID, existingRejectedReport, setExistingRejectedReport, - yourSpendPatchData, + 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/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts index 7f0d53ced15a..eb03f2d48082 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotReportMoveUpdatesTest.ts @@ -1,4 +1,4 @@ -import {getYourSpendSnapshotReportMoveUpdates} from '@libs/actions/IOU/YourSpendSnapshotUpdate'; +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'; @@ -404,3 +404,57 @@ describe('getYourSpendSnapshotReportMoveUpdates', () => { ]); }); }); + +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 index b982e7218d22..b2b2d3632100 100644 --- a/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts +++ b/tests/actions/IOUTest/GetYourSpendSnapshotTotalUpdatesTest.ts @@ -3,6 +3,7 @@ import { getYourSpendSnapshotReimbursableUpdates, getYourSpendSnapshotTotalUpdates, getYourSpendSnapshotTransactionRemovalUpdates, + getYourSpendSnapshotTransactionsRemovalUpdates, transactionMatchesAwaitingApprovalQuery, } from '@libs/actions/IOU/YourSpendSnapshotUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; @@ -250,6 +251,55 @@ describe('getYourSpendSnapshotTransactionRemovalUpdates', () => { }); }); +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])); From 231d92382637b45df0ce3f6126f8cdc245a4647c Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Fri, 17 Jul 2026 13:21:25 +0200 Subject: [PATCH 32/32] Narrow paid-group policy selector to Your spend fields Scope the useOnyx selector to only the id, type, and outputCurrency fields the snapshot builders read, so subscribers re-render only on relevant paid-policy changes. --- .../MoneyReportHeaderSecondaryActions.tsx | 8 +-- .../PayPrimaryAction.tsx | 6 +- .../SubmitPrimaryAction.tsx | 6 +- .../useConfirmApproval.ts | 6 +- .../MoneyRequestHeaderSecondaryActions.tsx | 6 +- .../ApproveActionButton.tsx | 6 +- .../PayActionButton.tsx | 8 +-- .../SubmitActionButton.tsx | 6 +- .../ReportActionItem/MoneyRequestView.tsx | 6 +- .../ListItem/ActionCell/PayActionCell.tsx | 6 +- .../ListItem/ExpenseReportListItem.tsx | 8 +-- .../ListItem/ReportListItemHeader.tsx | 6 +- .../ListItem/TransactionListItem/index.tsx | 6 +- src/components/SettlementButton/index.tsx | 6 +- src/components/YourSpendPatchDataProvider.tsx | 33 +++++++++ src/hooks/useDeleteTransactions.ts | 10 +-- src/hooks/useHoldMenuSubmit.ts | 8 +-- src/hooks/useLifecycleActions.tsx | 14 ++-- src/hooks/useSearchBulkActions.ts | 68 ++++++++++++++++--- src/hooks/useSelectionModePayment.ts | 8 +-- src/hooks/useTransactionInlineEdit.ts | 6 +- .../Navigation/AppNavigator/AuthScreens.tsx | 2 + src/libs/YourSpendPatchData.ts | 7 +- src/libs/YourSpendQueryUtils.ts | 13 ++-- src/libs/actions/IOU/ReportWorkflow.ts | 16 +++++ .../actions/IOU/YourSpendSnapshotUpdate.ts | 13 ++-- src/libs/actions/Search.ts | 32 +++++++-- src/pages/DynamicReportDetailsPage.tsx | 8 +-- src/pages/RejectExpenseReportPage.tsx | 6 +- src/pages/ReportSubmitToContent.tsx | 8 +-- src/pages/Search/SearchRejectReasonPage.tsx | 8 +-- .../PopoverReportActionContextMenu.tsx | 8 +-- src/pages/iou/RejectReasonPage.tsx | 8 ++- src/pages/iou/SplitExpensePage.tsx | 6 +- .../iou/request/step/IOURequestStepAmount.tsx | 6 +- .../unit/Search/yourSpendQueryBuildersTest.ts | 14 +++- 36 files changed, 265 insertions(+), 127 deletions(-) create mode 100644 src/components/YourSpendPatchDataProvider.tsx diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 36286b3cf11c..8e7958fdb4e9 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'; @@ -36,7 +37,6 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {search} from '@libs/actions/Search'; @@ -168,7 +168,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); @@ -240,7 +240,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onPaid: () => { startAnimation(); }, @@ -453,7 +453,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo ownerBillingGracePeriodEnd, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), ownerLogin: submitterLogin, }); }; diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index e030327b1bdb..6c50dff60b0c 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'; @@ -16,7 +17,6 @@ import usePayChatReportActions from '@hooks/usePayChatReportActions'; import usePolicy from '@hooks/usePolicy'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {search} from '@libs/actions/Search'; @@ -121,7 +121,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const {openHoldMenu} = useMoneyReportHeaderModals(); @@ -181,7 +181,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onPaid: startAnimation, chatReportActions: getChatReportActions(false), isTrackIntentUser, diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 464c61393e5c..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'; @@ -14,7 +15,6 @@ import usePermissions from '@hooks/usePermissions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -112,7 +112,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const handleSubmit = () => { if (!moneyRequestReport || shouldBlockSubmit) { @@ -140,7 +140,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { expenseReportCurrentNextStepDeprecated: nextStep, userBillingGracePeriodEnds, amountOwed, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onSubmitted: startSubmittingAnimation, ownerBillingGracePeriodEnd, delegateEmail, diff --git a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts index 5ffc653af7ad..5c3b31da1515 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts +++ b/src/components/MoneyReportHeaderPrimaryAction/useConfirmApproval.ts @@ -1,11 +1,11 @@ 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'; import usePermissions from '@hooks/usePermissions'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isSubmitPolicy} from '@libs/PolicyUtils'; @@ -39,7 +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 yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, accountID, email ?? ''); @@ -71,7 +71,7 @@ function useConfirmApproval(reportID: string | undefined, startApprovedAnimation ownerBillingGracePeriodEnd, ownerLogin, full: true, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onApproved: startApprovedAnimation, delegateEmail, isTrackIntentUser, diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 085388ee9be0..46e9b2b9cb92 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -25,7 +25,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionViolations from '@hooks/useTransactionViolations'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; @@ -92,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 */ @@ -115,7 +115,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const {login: currentUserLogin, accountID, localCurrencyCode} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const personalDetails = usePersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'ArrowCollapse', @@ -491,7 +491,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money currentUserAccountID: accountID, currentUserEmail: currentUserLogin ?? '', policy: iouPolicy, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); } else { if (shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID])) { diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx index 9f470fc1d368..c04f2601f40f 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx @@ -1,5 +1,6 @@ import Button from '@components/Button'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -7,7 +8,6 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportTransactionViolations from '@hooks/useReportTransactionViolations'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; @@ -48,7 +48,7 @@ function ApproveActionButton() { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(iouReport?.reportID); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const allTransactionValues = Object.values(reportTransactions); const transactions = allTransactionValues; @@ -78,7 +78,7 @@ function ApproveActionButton() { ownerBillingGracePeriodEnd, ownerLogin, full: true, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onApproved: startApprovedAnimation, delegateEmail, isTrackIntentUser, diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 0cdbe14fc269..2b9ed0f2064b 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'; @@ -14,7 +15,6 @@ import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useReportTransactionViolations from '@hooks/useReportTransactionViolations'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; @@ -76,7 +76,7 @@ function PayActionButton() { const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const reportTransactionsCollection = useReportTransactionsCollection(iouReportID); const transactions = Object.values(reportTransactionsCollection ?? {}).filter( (t): t is Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), @@ -116,7 +116,7 @@ function PayActionButton() { ownerBillingGracePeriodEnd, ownerLogin, full: true, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onApproved: startApprovedAnimation, delegateEmail, isTrackIntentUser, @@ -174,7 +174,7 @@ function PayActionButton() { amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - yourSpendPatchData, + 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 6b5f93da3da4..0450b238522f 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'; @@ -10,7 +11,6 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useReportTransactionViolations from '@hooks/useReportTransactionViolations'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {hasDynamicExternalWorkflow, isSubmitPolicy} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils, shouldShowMarkAsDone} from '@libs/ReportUtils'; @@ -73,7 +73,7 @@ function SubmitActionButtonContent() { const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const {isOffline} = useNetwork(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const reportTransactionsCollection = useReportTransactionsCollection(iouReportID); const transactions = Object.values(reportTransactionsCollection ?? {}).filter( (t): t is Transaction => !!t && (isOffline || t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), @@ -122,7 +122,7 @@ function SubmitActionButtonContent() { expenseReportCurrentNextStepDeprecated: iouReportNextStep, userBillingGracePeriodEnds, amountOwed, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onSubmitted: startSubmittingAnimation, ownerBillingGracePeriodEnd, delegateEmail, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 8f5e33ce0b56..35f4733441b3 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -13,6 +13,7 @@ import Text from '@components/Text'; import UserPills from '@components/UserPills'; import ViolationMessages from '@components/ViolationMessages'; import {useWideRHPState} from '@components/WideRHPContextProvider'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useActiveRoute from '@hooks/useActiveRoute'; import useAttendees from '@hooks/useAttendees'; @@ -43,7 +44,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolations from '@hooks/useTransactionViolations'; import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; import {openExternalLink} from '@libs/actions/Link'; @@ -281,7 +281,7 @@ function MoneyRequestView({ const personalDetailsList = usePersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); @@ -701,7 +701,7 @@ function MoneyRequestView({ isOffline, delegateAccountID, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); }; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index 5fc19b54ce78..caf6e31bbbd5 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -2,6 +2,7 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; import SettlementButton from '@components/SettlementButton'; import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useNetwork from '@hooks/useNetwork'; @@ -11,7 +12,6 @@ import {useReportPaymentContext} from '@hooks/usePaymentContext'; import usePolicy from '@hooks/usePolicy'; import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; import {canIOUBePaid} from '@libs/actions/IOU/ReportWorkflow'; @@ -46,7 +46,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const policy = usePolicy(policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); @@ -153,7 +153,7 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, additionalOnyxData, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), chatReportActions: allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(chatReport?.reportID)}`], isTrackIntentUser, }); diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index 3f88b480bd4f..632b44c35455 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -11,6 +11,7 @@ import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import BaseListItem from '@components/SelectionList/ListItem/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import {useYourSpendPatchDataGetter} from '@components/YourSpendPatchDataProvider'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -26,7 +27,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {handleActionButtonPress} from '@libs/actions/Search'; import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils'; @@ -219,7 +219,7 @@ function ExpenseReportListItemInner({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); const {transactions: reportTransactions, violations: reportViolations} = useTransactionsAndViolationsForReport(reportItem.reportID); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const liveReportTransactions = useMemo(() => Object.values(reportTransactions), [reportTransactions]); // Recompute the violations badge from live data at the row, replacing the screen-level @@ -299,7 +299,7 @@ function ExpenseReportListItemInner({ chatReportActions, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); }, [ currentSearchHash, @@ -339,7 +339,7 @@ function ExpenseReportListItemInner({ chatReportActions, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData, ]); const handleSelectionButtonPress = useCallback(() => { diff --git a/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx b/src/components/Search/SearchList/ListItem/ReportListItemHeader.tsx index 223f18cf108c..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'; @@ -22,7 +23,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -296,7 +296,7 @@ function ReportListItemHeaderInner({ const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const handleOnButtonPress = (event?: ModifiedMouseEvent) => { handleActionButtonPress({ @@ -332,7 +332,7 @@ function ReportListItemHeaderInner({ chatReportActions, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + 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 3f345b828627..f80837a0e103 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'; @@ -17,7 +18,6 @@ import useOnyx from '@hooks/useOnyx'; import {useReportPaymentContext} from '@hooks/usePaymentContext'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/actions/Search'; @@ -198,7 +198,7 @@ function TransactionListItemInner({ const {showConfirmModal} = useConfirmModal(); const openReportSubmitToPopover = useOpenReportSubmitToPopover(); const {shouldDisableSearchSubmitPress, consumeIgnoreNextSearchSubmitPress} = useSearchSubmitPopoverGuard(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const handleActionButtonPress = (event?: Parameters[2]) => { handleActionButtonPressUtil({ @@ -235,7 +235,7 @@ function TransactionListItemInner({ chatReportActions, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); }; diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 500044ae0aa8..ceaee4bf6e7b 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'; @@ -20,7 +21,6 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {createWorkspace, generateDefaultWorkspaceName, isCurrencySupportedForDirectReimbursement, isCurrencySupportedForGlobalReimbursement} from '@libs/actions/Policy/Policy'; import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; @@ -121,7 +121,7 @@ function SettlementButton({ const expenseReportPolicy = usePolicy(iouReport?.policyID); const {accountID, email = ''} = useCurrentUserPersonalDetails(); const lastWorkspaceNumber = useLastWorkspaceNumber(); - const yourSpendPatchData = useYourSpendPatchData(); + 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 @@ -531,7 +531,7 @@ function SettlementButton({ ownerBillingGracePeriodEnd, ownerLogin, full: false, - yourSpendPatchData, + 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 330d08a85ebd..124bc1aeb13b 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -1,4 +1,5 @@ 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'; @@ -41,7 +42,6 @@ import usePersonalPolicy from './usePersonalPolicy'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; import useRestrictedActionPolicyID from './useRestrictedActionPolicyID'; import {findSplitPolicyForCustomUnit, getSplitEffectivePolicy} from './useSplitEffectivePolicy'; -import useYourSpendPatchData from './useYourSpendPatchData'; type UseDeleteTransactionsParams = { /** Report object (optional, can be used for context) */ @@ -102,7 +102,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const restrictedActionPolicyID = useRestrictedActionPolicyID(policy); const {isOffline} = useNetwork(); const {isProduction} = useEnvironment(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const getSplitExpenseEditTransactionOnDelete = useCallback( (transactionIDs: string[]): Transaction | undefined => { @@ -330,7 +330,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac isOffline, delegateAccountID, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); } @@ -348,7 +348,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotTransactionsRemovalUpdates({ transactionItems: deletionEntries.map(({transaction, iouReport}) => ({transaction, iouReport})), currentUserAccountID: currentUserPersonalDetails.accountID, - context: yourSpendPatchData, + context: getYourSpendPatchData(), }); for (const {transactionID, action, iouReport} of deletionEntries) { @@ -420,7 +420,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac personalPolicy?.outputCurrency, delegateAccountID, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData, ], ); diff --git a/src/hooks/useHoldMenuSubmit.ts b/src/hooks/useHoldMenuSubmit.ts index a848a4fad2e8..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'; @@ -23,7 +24,6 @@ import useOnyx from './useOnyx'; import usePayChatReportActions from './usePayChatReportActions'; import usePermissions from './usePermissions'; import usePolicy from './usePolicy'; -import useYourSpendPatchData from './useYourSpendPatchData'; type ActionHandledType = DeepValueOf; @@ -57,7 +57,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const currentUserDetails = useCurrentUserPersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); @@ -98,7 +98,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment expenseReportPolicy: policy, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); } else if (currentChatReport && paymentType) { payMoneyRequest({ @@ -122,7 +122,7 @@ function useHoldMenuSubmit({moneyRequestReport, chatReport, requestType, payment onPaid: animationCallback, chatReportActions: getChatReportActions(false), isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); } onClose(); diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index f26ea3a38375..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'; @@ -49,7 +50,6 @@ import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; import useStrictPolicyRules from './useStrictPolicyRules'; import useThemeStyles from './useThemeStyles'; import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from './useYourSpendPatchData'; type UseLifecycleActionsParams = { reportID: string | undefined; @@ -107,7 +107,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {accountID, email} = currentUserPersonalDetails; - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const {areStrictPolicyRulesEnabled} = useStrictPolicyRules(); const {isBetaEnabled} = usePermissions(); @@ -196,7 +196,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, ownerBillingGracePeriodEnd, ownerLogin: submitterLogin, full: true, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onApproved: () => { if (skipAnimation) { return; @@ -245,7 +245,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, expenseReportCurrentNextStepDeprecated: nextStep, userBillingGracePeriodEnds, amountOwed, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onSubmitted: () => { if (skipAnimation) { return; @@ -374,7 +374,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, nextStep, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData(), ); }, }, @@ -396,7 +396,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, return; } - cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, isTrackIntentUser, yourSpendPatchData); + cancelPayment(moneyRequestReport, chatReport, policy, isASAPSubmitBetaEnabled, accountID, email ?? '', hasViolations, isTrackIntentUser, getYourSpendPatchData()); }, }, [CONST.REPORT.SECONDARY_ACTIONS.RETRACT]: { @@ -441,7 +441,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, nextStep, delegateEmail, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData(), ); }, }, diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 173af170cef4..11d5589e80b3 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -6,11 +6,12 @@ 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 {getPayYourSpendReportMoveItem, payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; -import {approveMoneyRequest, getApproveYourSpendReportMoveItem} from '@libs/actions/IOU/ReportWorkflow'; +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'; @@ -129,7 +130,6 @@ import useSplitEffectivePolicy from './useSplitEffectivePolicy'; import useTheme from './useTheme'; import useThemeStyles from './useThemeStyles'; import useUndeleteTransactions from './useUndeleteTransactions'; -import useYourSpendPatchData from './useYourSpendPatchData'; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -369,7 +369,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {introSelected, betas, isSelfTourViewed, activePolicyID, activePolicy, defaultWorkspaceName, userBillingGracePeriodEnds, amountOwed, ownerBillingGracePeriodEnd, delegateEmail} = usePaymentContext(); const allTransactions = useAllTransactions(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [allNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP); const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); @@ -841,7 +841,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { .map(({expenseReport, reportPolicy}) => getApproveYourSpendReportMoveItem(expenseReport, reportPolicy)) .filter((item): item is YourSpendReportMoveItem => !!item), currentUserAccountID: accountID, - context: yourSpendPatchData, + context: getYourSpendPatchData(), }); for (const {reportID, expenseReport, reportPolicy, wouldNavigateToUpgrade, wouldNavigateToRestricted} of approvalEntries) { @@ -908,7 +908,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { delegateEmail, currentSearchKey, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData, personalDetails, ]); @@ -1287,7 +1287,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { let pendingYourSpendSnapshotUpdates: YourSpendSnapshotOnyxData | undefined = getYourSpendSnapshotReportsMoveUpdates({ reportItems: payCalls.map(({iouReport}) => getPayYourSpendReportMoveItem(iouReport)).filter((item): item is YourSpendReportMoveItem => !!item), currentUserAccountID: accountID, - context: yourSpendPatchData, + context: getYourSpendPatchData(), }); for (const payCallParams of payCalls) { payMoneyRequest({...payCallParams, yourSpendSnapshotUpdates: pendingYourSpendSnapshotUpdates}); @@ -1335,7 +1335,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentSearchKey, searchResults?.data, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData, ], ); @@ -1897,6 +1897,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( @@ -1907,6 +1912,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentSearchKey, managerEmail, managerAccountID, + submitYourSpendSnapshotUpdates, ); clearSelectedTransactions(); }, @@ -1915,11 +1921,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(); }, @@ -2275,6 +2322,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 ee8812a6925b..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'; @@ -45,7 +46,6 @@ import usePaymentOptions from './usePaymentOptions'; import usePermissions from './usePermissions'; import usePolicy from './usePolicy'; import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; -import useYourSpendPatchData from './useYourSpendPatchData'; type HoldMenuOpenParams = { requestType: ActionHandledType; @@ -90,7 +90,7 @@ function useSelectionModePayment({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); @@ -214,7 +214,7 @@ function useSelectionModePayment({ amountOwed, ownerBillingGracePeriodEnd, methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), onPaid, chatReportActions: getChatReportActions(false), isTrackIntentUser, @@ -312,7 +312,7 @@ function useSelectionModePayment({ delegateEmail, expenseReportPolicy: policy, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), ownerLogin, }); }; diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts index 2c76cbbbef05..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 { @@ -40,7 +41,6 @@ import usePersonalPolicy from './usePersonalPolicy'; import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; import usePolicyForTransaction from './usePolicyForTransaction'; import useSelfDMReport from './useSelfDMReport'; -import useYourSpendPatchData from './useYourSpendPatchData'; type UseTransactionInlineEditParams = { transactionID: string; @@ -153,7 +153,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); const {isOffline} = useNetwork(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const personalPolicy = usePersonalPolicy(); const permissions = getTransactionEditPermissions({ @@ -195,7 +195,7 @@ function useTransactionInlineEdit({transactionID, hash, linkedReportAction}: Use distanceOriginalPolicy, delegateAccountID, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }; }; 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/YourSpendPatchData.ts b/src/libs/YourSpendPatchData.ts index d7427d79b5a0..b8861767011a 100644 --- a/src/libs/YourSpendPatchData.ts +++ b/src/libs/YourSpendPatchData.ts @@ -5,14 +5,17 @@ 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; + paidPolicies: OnyxCollection; snapshotSearches: Record; }; const EMPTY_YOUR_SPEND_PATCH_DATA: YourSpendPatchData = {paidPolicies: {}, snapshotSearches: {}}; export {EMPTY_YOUR_SPEND_PATCH_DATA}; -export type {YourSpendPatchData}; +export type {YourSpendPatchData, YourSpendPolicy}; diff --git a/src/libs/YourSpendQueryUtils.ts b/src/libs/YourSpendQueryUtils.ts index ca72dc20e8e9..20ffabb9edb0 100644 --- a/src/libs/YourSpendQueryUtils.ts +++ b/src/libs/YourSpendQueryUtils.ts @@ -4,12 +4,14 @@ 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[] { +function getPaidGroupPolicyIDs(paidPolicies: OnyxCollection): string[] { return Object.values(paidPolicies ?? {}) .map((policy) => policy?.id) .filter((id): id is string => !!id); @@ -17,15 +19,16 @@ function getPaidGroupPolicyIDs(paidPolicies: OnyxCollection): string[] { /** * `useOnyx` selector that narrows the full policy collection to only paid-group workspaces (the only ones "Your spend" - * cares about), so subscribers re-render on paid-policy changes rather than on any policy change. + * 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 = {}; +function selectPaidGroupPolicies(policies: OnyxCollection): OnyxCollection { + const paidPolicies: OnyxCollection = {}; for (const [key, policy] of Object.entries(policies ?? {})) { if (!policy?.id || !isPaidGroupPolicy(policy)) { continue; } - paidPolicies[key] = policy; + paidPolicies[key] = {id: policy.id, type: policy.type, outputCurrency: policy.outputCurrency}; } return paidPolicies; } diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index fb50b46f7144..f1ace664d946 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -435,6 +435,21 @@ 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 @@ -2008,6 +2023,7 @@ export { getBadgeFromIOUReport, getIOUReportActionWithBadge, getReportOriginalCreationTimestamp, + getSubmitYourSpendReportMoveItem, reopenReport, retractReport, submitReport, diff --git a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts index 93d845afb3ca..aae5960f793d 100644 --- a/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts +++ b/src/libs/actions/IOU/YourSpendSnapshotUpdate.ts @@ -2,12 +2,12 @@ import {isExpenseReport, isInvoiceReport as isInvoiceReportReportUtils} from '@l import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getAmount, getConvertedAmount, getCurrency} from '@libs/TransactionUtils'; import {EMPTY_YOUR_SPEND_PATCH_DATA} from '@libs/YourSpendPatchData'; -import type {YourSpendPatchData} 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 {Policy, Report, Transaction} from '@src/types/onyx'; +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'; @@ -212,7 +212,12 @@ function getSnapshotSearchResults(snapshotSearches: YourSpendPatchData['snapshot } /** 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 { +function getReimbursableTransactionAmountInCurrency( + policies: OnyxCollection, + transaction: Transaction, + iouReport: OnyxEntry, + targetCurrency: string, +): number | null { const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); const transactionCurrency = getCurrency(transaction); @@ -237,7 +242,7 @@ type ReportReimbursableAggregate = { /** Sums reimbursable transactions (and counts them) in the snapshot currency, optionally restricted to the last 30 days. */ function getReportReimbursableTotal( - policies: OnyxCollection, + policies: OnyxCollection, iouReport: OnyxEntry, reportTransactions: Transaction[], onlyWithinLast30Days: boolean, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index e5099c00629f..2b6e9e6b895f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -92,13 +92,13 @@ import Onyx from 'react-native-onyx'; import type {AdditionalPayOnyxData} from './IOU/PayMoneyRequest'; import type {RejectMoneyRequestData} from './IOU/RejectMoneyRequest'; -import type {YourSpendSnapshotOnyxData} from './IOU/YourSpendSnapshotUpdate'; +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 {getYourSpendSnapshotTransactionsRemovalUpdates} from './IOU/YourSpendSnapshotUpdate'; +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'; @@ -368,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: { @@ -1015,15 +1029,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> = [ @@ -1032,6 +1048,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 @@ -1045,7 +1062,7 @@ function submitMoneyRequestOnSearch( }); } - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE_COLLECTION, key: ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, @@ -1063,6 +1080,7 @@ function submitMoneyRequestOnSearch( ]), ), }, + ...(yourSpendSnapshotUpdates?.failureData ?? []), ]; const trimmedManagerEmail = managerEmail?.trim(); diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 48f9cca46ff2..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'; @@ -36,7 +37,6 @@ import useReportAttributes, {useDerivedReportNameByReportID} from '@hooks/useRep import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getBase62ReportID from '@libs/getBase62ReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -350,7 +350,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report reportActions: requestParentReportAction ? [requestParentReportAction] : [], policy, }); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const isCardTransactionCanBeDeleted = canDeleteCardTransactionByLiabilityType(iouTransaction); const shouldShowDeleteButton = shouldShowTaskDeleteButton || (canDeleteRequest && isCardTransactionCanBeDeleted) || isDemoTransaction(iouTransaction); const shouldShowEditSplitOnDeleteAction = iouTransactionID ? shouldOpenSplitExpenseEditFlowOnDelete([iouTransactionID]) : false; @@ -974,7 +974,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report currentUserAccountID: currentUserPersonalDetails.accountID, currentUserEmail: currentUserPersonalDetails.email ?? '', policy: iouPolicy, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); } else if (iouTransactionID) { const deleteResult = deleteTransactions([iouTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined, isSingleTransactionView); @@ -1012,7 +1012,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report deleteTransactions, removeTransaction, iouPolicy, - yourSpendPatchData, + 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 2c0958da3d43..34949da6d8fd 100644 --- a/src/pages/RejectExpenseReportPage.tsx +++ b/src/pages/RejectExpenseReportPage.tsx @@ -9,13 +9,13 @@ 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'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -81,7 +81,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [lastForwardedActorAccountID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`, {selector: lastForwardedActorAccountIDSelector}); @@ -169,7 +169,7 @@ function RejectExpenseReportPage({route}: RejectExpenseReportPageProps) { currentUserPersonalDetails?.displayName, currentUserPersonalDetails?.avatar, isTrackIntentUser, - yourSpendPatchData, + getYourSpendPatchData(), ); Navigation.goBack(); }; diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 45a0a3e50053..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'; @@ -18,7 +19,6 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {search} from '@libs/actions/Search'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -102,7 +102,7 @@ function ReportSubmitToContent({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); @@ -308,7 +308,7 @@ function ReportSubmitToContent({ ownerBillingGracePeriodEnd, delegateEmail, submitterLogin, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), managerEmail: trimmed, managerAccountID: resolvedManagerAccountID, isTrackIntentUser, @@ -346,7 +346,7 @@ function ReportSubmitToContent({ ownerBillingGracePeriodEnd, delegateEmail, submitterLogin, - yourSpendPatchData, + getYourSpendPatchData, currentSearchQueryJSON, isOffline, currentSearchKey, diff --git a/src/pages/Search/SearchRejectReasonPage.tsx b/src/pages/Search/SearchRejectReasonPage.tsx index 195f8926898d..eb7a504d649c 100644 --- a/src/pages/Search/SearchRejectReasonPage.tsx +++ b/src/pages/Search/SearchRejectReasonPage.tsx @@ -1,11 +1,11 @@ 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'; import useOnyx from '@hooks/useOnyx'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {clearErrorFields, clearErrors} from '@libs/actions/FormActions'; import {rejectMoneyRequestsOnSearch} from '@libs/actions/Search'; @@ -39,7 +39,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + 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) { @@ -69,7 +69,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { currentUserAccountID, currentUserLogin ?? '', betas, - yourSpendPatchData, + getYourSpendPatchData(), ); if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS) { clearSelectedTransactions(true); @@ -93,7 +93,7 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { route.name, showDelegateNoAccessModal, clearSelectedTransactions, - yourSpendPatchData, + getYourSpendPatchData, ], ); diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index 594a1df737f2..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'; @@ -13,7 +14,6 @@ import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {deleteAppReport, deleteReportComment} from '@libs/actions/Report'; @@ -360,7 +360,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro reportActions: reportActionRef.current ? [reportActionRef.current] : [], policy, }); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getOriginalReportID(reportIDRef.current, reportActionRef.current, reportActions)}`); const ancestorsRef = useRef([]); @@ -395,7 +395,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro currentUserAccountID, currentUserEmail: email ?? '', policy: iouPolicy, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); } else if (originalMessage?.IOUTransactionID) { const deleteResult = deleteTransactions([originalMessage.IOUTransactionID], duplicateTransactions, duplicateTransactionViolations, undefined); @@ -460,7 +460,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro iouTransaction, iouOriginalTransaction, iouPolicy, - yourSpendPatchData, + getYourSpendPatchData, ]); const hideDeleteModal = () => { diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index bea244fba21e..1db6aa61998b 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -2,12 +2,12 @@ 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'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -41,7 +41,7 @@ function RejectReasonPage({route}: RejectReasonPageProps) { const policy = usePolicy(reportPolicyID); const {superWideRHPRouteKeys} = useWideRHPState(); const {accountID: currentUserAccountID, login: currentUserLogin} = useCurrentUserPersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [betas] = useOnyx(ONYXKEYS.BETAS); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -51,7 +51,9 @@ function RejectReasonPage({route}: RejectReasonPageProps) { return; } - const urlToNavigateBack = rejectMoneyRequest(transactionID, reportID, values.comment, policy, currentUserAccountID, currentUserLogin ?? '', betas, {yourSpendPatchData}); + 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 e59cb68823d5..78b4553c9979 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'; @@ -28,7 +29,6 @@ import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import {getIOUActionForTransactions} from '@libs/actions/IOU/Duplicate'; import {getIOURequestPolicyID} from '@libs/actions/IOU/MoneyRequest'; @@ -127,7 +127,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [expenseReportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(expenseReport?.policyID)}`); const allTransactions = useAllTransactions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`]; const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`]; @@ -390,7 +390,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { isOffline, delegateAccountID, isTrackIntentUser, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), }); }; diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index b443e558c7a0..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'; @@ -13,7 +14,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useSkipConfirmationPreInsert from '@hooks/useSkipConfirmationPreInsert'; -import useYourSpendPatchData from '@hooks/useYourSpendPatchData'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getIsP2PForAmount, submitAmount} from '@libs/IOUAmountSubmission'; @@ -75,7 +75,7 @@ function IOURequestStepAmount({ const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const yourSpendPatchData = useYourSpendPatchData(); + const getYourSpendPatchData = useYourSpendPatchDataGetter(); const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const textInput = useRef(null); const amountFormRef = useRef(null); @@ -238,7 +238,7 @@ function IOURequestStepAmount({ paymentMethod, isTrackIntentUser, policyTags, - yourSpendPatchData, + yourSpendPatchData: getYourSpendPatchData(), reportPolicyTags, ...submitData, }); diff --git a/tests/unit/Search/yourSpendQueryBuildersTest.ts b/tests/unit/Search/yourSpendQueryBuildersTest.ts index 6e91ec63125d..2fceba7804ad 100644 --- a/tests/unit/Search/yourSpendQueryBuildersTest.ts +++ b/tests/unit/Search/yourSpendQueryBuildersTest.ts @@ -247,12 +247,20 @@ describe('selectPaidGroupPolicies', () => { const result = selectPaidGroupPolicies(buildPolicyCollection([team, corporate, personal, submit])); expect( - Object.values(result) + 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 = { @@ -262,7 +270,7 @@ describe('selectPaidGroupPolicies', () => { const result = selectPaidGroupPolicies(collection); - expect(Object.values(result).map((policy) => policy?.id)).toEqual(['1']); + expect(Object.values(result ?? {}).map((policy) => policy?.id)).toEqual(['1']); }); }); @@ -285,7 +293,7 @@ describe('getPaidGroupPolicyIDs', () => { 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}null`]: null, + [`${ONYXKEYS.COLLECTION.POLICY}missing`]: undefined, }; expect(getPaidGroupPolicyIDs(collection)).toEqual(['1']);