diff --git a/src/pages/home/YourSpendSection/SpendSummaryRow.tsx b/src/pages/home/YourSpendSection/SpendSummaryRow.tsx index 99ac385ccf34..c928e23f8b0a 100644 --- a/src/pages/home/YourSpendSection/SpendSummaryRow.tsx +++ b/src/pages/home/YourSpendSection/SpendSummaryRow.tsx @@ -61,9 +61,11 @@ type SpendSummaryRowProps = { // Position of this row within the Your spend list. Used to vary the skeleton // title width across stacked rows, mirroring `ForYouSkeleton`. skeletonRowIndex: number; + // Greys the total when a queued offline change may have made it stale. + isStale?: boolean; }; -function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onPress, wrapperStyle, skeletonRowIndex}: SpendSummaryRowProps) { +function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onPress, wrapperStyle, skeletonRowIndex, isStale = false}: SpendSummaryRowProps) { const styles = useThemeStyles(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -108,7 +110,7 @@ function SpendSummaryRow({state, testIDPrefix, description, totals, iconSrc, onP Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: awaitingApprovalQuery}))} wrapperStyle={wrapperStyle} skeletonRowIndex={0} + isStale={isApprovalStale} /> Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: repaidLast30DaysQuery}))} wrapperStyle={wrapperStyle} skeletonRowIndex={1} + isStale={isPaymentStale} /> {visibleCardRows.map((cardRow) => ( diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 483d0d7fd456..f23e3805fc7f 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -4,6 +4,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {search} from '@libs/actions/Search'; +import {WRITE_COMMANDS} from '@libs/API/types'; import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards, isPersonalCard, lastFourNumbersFromCardName} from '@libs/CardUtils'; import {arePaymentsEnabled, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; @@ -12,6 +13,7 @@ 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 {AnyRequest} from '@src/types/onyx/Request'; import type SearchResults from '@src/types/onyx/SearchResults'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -95,6 +97,10 @@ type UseYourSpendDataReturn = { cardRows: YourSpendCardRow[]; awaitingApprovalQuery: string; repaidLast30DaysQuery: string; + // True when offline with a queued change that would move this specific total, + // so its row renders greyed out until the next online refresh. + isApprovalStale: boolean; + isPaymentStale: boolean; }; function getOutstandingReportsSignature(reports: OnyxCollection | undefined, paidGroupPolicyIDs: string[], accountID: number): string { @@ -117,6 +123,95 @@ function getOutstandingReportsSignature(reports: OnyxCollection | undefi return ids.sort().join(','); } +// Offline queue commands that move each "Your spend" total. Read from the action +// queue rather than inferred from a report's status, because a report can pass +// through several states offline (e.g. approve then pay) and only its final status +// would otherwise survive — dropping the earlier action's stale signal. +const YOUR_SPEND_APPROVAL_COMMANDS = new Set([ + WRITE_COMMANDS.SUBMIT_REPORT, + WRITE_COMMANDS.RETRACT_REPORT, + WRITE_COMMANDS.APPROVE_MONEY_REQUEST, + WRITE_COMMANDS.UNAPPROVE_EXPENSE_REPORT, +]); +const YOUR_SPEND_PAYMENT_COMMANDS = new Set([WRITE_COMMANDS.PAY_MONEY_REQUEST, WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET, WRITE_COMMANDS.CANCEL_PAYMENT]); + +type YourSpendPendingBuckets = { + // A queued offline change would move the "Awaiting approval" (status:outstanding) total. + approval: boolean; + // A queued offline change would move the "Repaid last 30 days" (status:paid) total. + payment: boolean; +}; + +// Status of each report the user owns on a paid group workspace, plus the totals an +// amount change (edit / delete / reject; marked by `pendingFields.total`) would move. +// Amount changes keep a report in the same bucket, so the report's current status +// classifies them; state transitions are handled from the queue instead. +type YourSpendReportsProjection = { + // reportID -> statusNum, used to scope queued commands to the user's own paid reports. + statusByID: Record; + amount: YourSpendPendingBuckets; +}; + +function projectYourSpendReports(reports: OnyxCollection | undefined, paidGroupPolicyIDs: string[], accountID: number): YourSpendReportsProjection { + const projection: YourSpendReportsProjection = {statusByID: {}, amount: {approval: false, payment: false}}; + if (!reports || paidGroupPolicyIDs.length === 0) { + return projection; + } + const policyIDSet = new Set(paidGroupPolicyIDs); + for (const report of Object.values(reports)) { + if (report?.ownerAccountID !== accountID || !report?.policyID || !policyIDSet.has(report.policyID) || report.statusNum === undefined) { + continue; + } + projection.statusByID[report.reportID] = report.statusNum; + if (report.pendingFields?.total == null) { + continue; + } + if (report.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { + projection.amount.approval = true; + } else if (report.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED) { + projection.amount.payment = true; + } + } + return projection; +} + +// Which "Your spend" totals a queued offline change would move. The totals come +// from server-computed search snapshots we cannot recompute offline, so instead of +// patching a value we can't trust we detect that a relevant change is pending and +// grey only the affected total until the next online refresh. +// +// State transitions (submit / retract / approve / unapprove / pay / cancel) are read +// from the offline action queue: every queued command persists for the whole offline +// session, so a report that was approved and then paid keeps BOTH signals. Amount +// changes (edit / delete / reject) don't move a report between buckets, so they're +// classified by the report's current status (see projectYourSpendReports). +function getYourSpendPendingBuckets(projection: YourSpendReportsProjection, queuedRequests: AnyRequest[] | undefined): YourSpendPendingBuckets { + const buckets: YourSpendPendingBuckets = {approval: projection.amount.approval, payment: projection.amount.payment}; + for (const request of queuedRequests ?? []) { + const isApprovalCommand = YOUR_SPEND_APPROVAL_COMMANDS.has(request.command); + const isPaymentCommand = YOUR_SPEND_PAYMENT_COMMANDS.has(request.command); + if (!isApprovalCommand && !isPaymentCommand) { + continue; + } + // Report-level commands carry `reportID`; money-request commands (e.g. pay) carry `iouReportID`. + const rawReportID = request.data?.reportID ?? request.data?.iouReportID; + const reportID = typeof rawReportID === 'string' ? rawReportID : undefined; + // Only count commands acting on one of the user's own paid-group reports. + if (!reportID || projection.statusByID[reportID] === undefined) { + continue; + } + if (isApprovalCommand) { + buckets.approval = true; + } else { + buckets.payment = true; + } + if (buckets.approval && buckets.payment) { + break; + } + } + return buckets; +} + function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourSpendRowStateParams): YourSpendRowState { if (!isApplicable) { return YOUR_SPEND_ROW_STATE.HIDDEN; @@ -162,6 +257,21 @@ function useYourSpendData(): UseYourSpendDataReturn { selector: (reports) => getOutstandingReportsSignature(reports, paidGroupPolicyIDs, accountID), }); + // Which totals a queued offline change would move. When offline we can't + // refresh the snapshots, so we grey only the affected total to signal it may + // be stale rather than showing a value we know might be wrong. + const [reportsProjection] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: (reports) => projectYourSpendReports(reports, paidGroupPolicyIDs, accountID), + }); + const [queuedSpendRequests] = useOnyx(ONYXKEYS.PERSISTED_REQUESTS, { + selector: (requests) => + (requests ?? []).filter((request) => !!request?.command && (YOUR_SPEND_APPROVAL_COMMANDS.has(request.command) || YOUR_SPEND_PAYMENT_COMMANDS.has(request.command))), + }); + const pendingSpendBuckets = useMemo( + () => getYourSpendPendingBuckets(reportsProjection ?? {statusByID: {}, amount: {approval: false, payment: false}}, queuedSpendRequests), + [reportsProjection, queuedSpendRequests], + ); + // 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(); @@ -428,8 +538,29 @@ function useYourSpendData(): UseYourSpendDataReturn { cardRows, awaitingApprovalQuery, repaidLast30DaysQuery, + isApprovalStale: isOffline && !!pendingSpendBuckets?.approval, + isPaymentStale: isOffline && !!pendingSpendBuckets?.payment, }; } -export {YOUR_SPEND_CARD_KIND, YOUR_SPEND_ROW_STATE, getOutstandingReportsSignature, getYourSpendApplicability, getYourSpendRowState, useYourSpendData}; -export type {GetYourSpendRowStateParams, UseYourSpendDataReturn, YourSpendApplicability, YourSpendCardKind, YourSpendCardRow, YourSpendRowState, YourSpendRowTotals}; +export { + YOUR_SPEND_CARD_KIND, + YOUR_SPEND_ROW_STATE, + getOutstandingReportsSignature, + getYourSpendApplicability, + getYourSpendPendingBuckets, + getYourSpendRowState, + projectYourSpendReports, + useYourSpendData, +}; +export type { + GetYourSpendRowStateParams, + UseYourSpendDataReturn, + YourSpendApplicability, + YourSpendCardKind, + YourSpendCardRow, + YourSpendPendingBuckets, + YourSpendReportsProjection, + YourSpendRowState, + YourSpendRowTotals, +}; diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index c8d594f1ae9c..c0e6861bba4e 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -6,6 +6,9 @@ * - query builders are called with the current user's accountID * - awaitingApprovalQuery / repaidLast30DaysQuery are exposed on the return value * - search() is dispatched when focused and online; suppressed when offline + * - isApprovalStale / isPaymentStale: true only when offline AND a change would move + * that specific total — state transitions read from the offline queue, amount edits + * from the report's pendingFields.total classified by status */ import {act, renderHook} from '@testing-library/react-native'; @@ -13,6 +16,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useNetwork from '@hooks/useNetwork'; import {search} from '@libs/actions/Search'; +import {WRITE_COMMANDS} from '@libs/API/types'; import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards} from '@libs/CardUtils'; import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; @@ -830,3 +834,151 @@ describe('useYourSpendData — drops the approval cache when no outstanding repo expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY); }); }); + +// isApprovalStale / isPaymentStale — grey only the total a queued change would move + +describe('useYourSpendData — per-row staleness', () => { + const UPDATE = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + + type QueuedRequest = {command: string; data?: Record}; + + function makeReport(overrides: Partial = {}): Report { + return { + reportID: 'r1', + policyID: 'policy_1', + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ...overrides, + } as Report; + } + + function setupReports(reports: Report[]) { + onyxData[ONYXKEYS.COLLECTION.REPORT] = Object.fromEntries(reports.map((r) => [`${ONYXKEYS.COLLECTION.REPORT}${r.reportID}`, r])); + } + + /** Seeds the offline action queue that classifies state-transition staleness. */ + function setupQueue(requests: QueuedRequest[]) { + onyxData[ONYXKEYS.PERSISTED_REQUESTS] = requests; + } + + beforeEach(() => { + mockedIsPaidGroupPolicy.mockReturnValue(true); + mockedUseNetwork.mockReturnValue(networkState(true)); + setupPolicies([makeCorporatePolicy({id: 'policy_1'})]); + }); + + it('greys neither row when online even if a relevant change is queued', () => { + mockedUseNetwork.mockReturnValue(networkState(false)); + setupReports([makeReport()]); + setupQueue([{command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('greys neither row when offline but nothing is queued and no amount change is pending', () => { + setupReports([makeReport()]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('greys only Awaiting approval for a pending total change on a SUBMITTED report (reject/delete/edit)', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, pendingFields: {total: UPDATE}})]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(true); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('greys only Awaiting approval for a queued SubmitReport', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED})]); + setupQueue([{command: WRITE_COMMANDS.SUBMIT_REPORT, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(true); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('greys only Awaiting approval for a queued RetractReport', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.OPEN})]); + setupQueue([{command: WRITE_COMMANDS.RETRACT_REPORT, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(true); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('greys only Awaiting approval for a queued ApproveMoneyRequest', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.APPROVED})]); + setupQueue([{command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(true); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('greys only Repaid for a queued PayMoneyRequest (reportID carried as iouReportID)', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED})]); + setupQueue([{command: WRITE_COMMANDS.PAY_MONEY_REQUEST, data: {iouReportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(true); + }); + + it('greys only Repaid for a queued CancelPayment', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED})]); + setupQueue([{command: WRITE_COMMANDS.CANCEL_PAYMENT, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(true); + }); + + it('keeps Awaiting approval greyed after paying an already-approved report offline (approve then pay)', () => { + // Both actions target the same report; the final status is REIMBURSED but the queued + // ApproveMoneyRequest must keep the approval total greyed too — the original bug. + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED})]); + setupQueue([ + {command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST, data: {reportID: 'r1'}}, + {command: WRITE_COMMANDS.PAY_MONEY_REQUEST, data: {iouReportID: 'r1'}}, + ]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(true); + expect(result.current.isPaymentStale).toBe(true); + }); + + it('does not grey when only an amount change is pending on an OPEN draft (adding an expense)', () => { + setupReports([makeReport({statusNum: CONST.REPORT.STATUS_NUM.OPEN, pendingFields: {total: UPDATE}})]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('does not grey when the only pending field is irrelevant to the totals', () => { + setupReports([makeReport({pendingFields: {createChat: UPDATE}})]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('ignores queued commands for reports the user does not own', () => { + setupReports([makeReport({ownerAccountID: ACCOUNT_ID + 1})]); + setupQueue([{command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('ignores queued commands for reports outside the paid group policies', () => { + setupReports([makeReport({policyID: 'policy_other'})]); + setupQueue([{command: WRITE_COMMANDS.APPROVE_MONEY_REQUEST, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); + + it('ignores queued commands unrelated to the Your spend totals', () => { + setupReports([makeReport()]); + setupQueue([{command: WRITE_COMMANDS.ADD_COMMENT, data: {reportID: 'r1'}}]); + const {result} = renderHook(() => useYourSpendData()); + expect(result.current.isApprovalStale).toBe(false); + expect(result.current.isPaymentStale).toBe(false); + }); +});