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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/pages/home/YourSpendSection/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ function get30DaysAgoDateString(): string {
return `${year}-${month}-${day}`;
}

function buildAwaitingApprovalQuery(accountID: number): string {
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} : {}),
});
}

Expand Down
60 changes: 42 additions & 18 deletions src/pages/home/YourSpendSection/useYourSpendData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import {search} from '@libs/actions/Search';
import {getDisplayableExpensifyCards} from '@libs/CardUtils';
import {arePaymentsEnabled, hasApprovalFlow, isPaidGroupPolicy} from '@libs/PolicyUtils';
import {arePaymentsEnabled, isPaidGroupPolicy} from '@libs/PolicyUtils';
import {buildSearchQueryJSON} from '@libs/SearchQueryUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy} from '@src/types/onyx';
Expand Down Expand Up @@ -38,13 +38,27 @@ type YourSpendCardRow = {
type YourSpendApplicability = {
isApprovalApplicable: boolean;
isPaymentApplicable: boolean;
// IDs of the user's Team/Corporate workspaces. Used to scope the
// "Awaiting approval" query so IOU and personal expenses don't count.
paidGroupPolicyIDs: string[];
};

function getYourSpendApplicability(policies: OnyxCollection<Policy> | undefined): YourSpendApplicability {
const policyList = Object.values(policies ?? {});
const isApprovalApplicable = policyList.some((policy) => hasApprovalFlow(policy));
const isPaymentApplicable = policyList.some((policy) => isPaidGroupPolicy(policy) && arePaymentsEnabled(policy ?? undefined));
return {isApprovalApplicable, isPaymentApplicable};
const paidGroupPolicyIDs: string[] = [];
let isPaymentApplicable = false;
for (const policy of Object.values(policies ?? {})) {
if (policy?.id && isPaidGroupPolicy(policy)) {
paidGroupPolicyIDs.push(policy.id);
if (!isPaymentApplicable && arePaymentsEnabled(policy ?? undefined)) {
isPaymentApplicable = true;
}
}
Comment thread
mountiny marked this conversation as resolved.
}
return {
isApprovalApplicable: paidGroupPolicyIDs.length > 0,
isPaymentApplicable,
paidGroupPolicyIDs,
};
}

type YourSpendRowTotals = {
Expand Down Expand Up @@ -83,19 +97,20 @@ function useYourSpendData(): UseYourSpendDataReturn {
const {isOffline} = useNetwork();
const isFocused = useIsFocused();

const awaitingApprovalQuery = buildAwaitingApprovalQuery(accountID);
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);

const {isApprovalApplicable, isPaymentApplicable, paidGroupPolicyIDs} = getYourSpendApplicability(policies);

const awaitingApprovalQuery = buildAwaitingApprovalQuery(accountID, paidGroupPolicyIDs);
Comment thread
mountiny marked this conversation as resolved.
const repaidLast30DaysQuery = buildRepaidLast30DaysQuery(accountID);

const approvalQueryJSON = buildSearchQueryJSON(awaitingApprovalQuery);
const paymentQueryJSON = buildSearchQueryJSON(repaidLast30DaysQuery);

const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [cardList] = useOnyx(ONYXKEYS.CARD_LIST);
const [approvalSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${approvalQueryJSON?.hash}`);
const [paymentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${paymentQueryJSON?.hash}`);

const {isApprovalApplicable, isPaymentApplicable} = getYourSpendApplicability(policies);

// Memo anchor. The compiler does not auto-cache this call, so without the
// `useMemo` every downstream value derived from `displayableCards` would
// get a new identity each render and defeat the compiler's downstream caches.
Expand Down Expand Up @@ -231,14 +246,23 @@ function useYourSpendData(): UseYourSpendDataReturn {
// and the home re-fetch. Cache the last READY totals and reuse them when the
// snapshot is loaded but its count has been wiped. A genuine `count === 0`
// is still treated as empty.
const [cachedApprovalReady, setCachedApprovalReady] = useState<YourSpendRowTotals | null>(null);
//
// The approval cache is keyed by the approval query hash so that joining/leaving
// a workspace (which changes the policyID filter and therefore the hash) drops
// the previous workspace set's cached total instead of flashing a stale value.
type CachedReady = {hash: number; totals: YourSpendRowTotals};
const [cachedApprovalReady, setCachedApprovalReady] = useState<CachedReady | null>(null);
const [cachedPaymentReady, setCachedPaymentReady] = useState<YourSpendRowTotals | null>(null);

const approvalHash = approvalQueryJSON?.hash;
const cachedApprovalForCurrentHash = cachedApprovalReady && approvalHash !== undefined && cachedApprovalReady.hash === approvalHash ? cachedApprovalReady.totals : null;

if (
approvalRowStateRaw === YOUR_SPEND_ROW_STATE.READY &&
(!cachedApprovalReady || cachedApprovalReady.total !== approvalTotalsRaw.total || cachedApprovalReady.currency !== approvalTotalsRaw.currency)
approvalHash !== undefined &&
(!cachedApprovalForCurrentHash || cachedApprovalForCurrentHash.total !== approvalTotalsRaw.total || cachedApprovalForCurrentHash.currency !== approvalTotalsRaw.currency)
) {
setCachedApprovalReady({total: approvalTotalsRaw.total, currency: approvalTotalsRaw.currency});
setCachedApprovalReady({hash: approvalHash, totals: {total: approvalTotalsRaw.total, currency: approvalTotalsRaw.currency}});
}
if (
paymentRowStateRaw === YOUR_SPEND_ROW_STATE.READY &&
Expand All @@ -253,17 +277,17 @@ function useYourSpendData(): UseYourSpendDataReturn {
const paymentCountIsMissing = paymentCount === undefined || paymentCount === null;

const shouldUseCachedApproval =
approvalRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && approvalCountIsMissing && approvalSearchResults !== undefined && cachedApprovalReady !== null;
approvalRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && approvalCountIsMissing && approvalSearchResults !== undefined && cachedApprovalForCurrentHash !== null;
const shouldUseCachedPayment = paymentRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && paymentCountIsMissing && paymentSearchResults !== undefined && cachedPaymentReady !== null;

const approvalRowState = shouldUseCachedApproval ? YOUR_SPEND_ROW_STATE.READY : approvalRowStateRaw;
const paymentRowState = shouldUseCachedPayment ? YOUR_SPEND_ROW_STATE.READY : paymentRowStateRaw;
const approvalTotals: YourSpendRowTotals = shouldUseCachedApproval && cachedApprovalReady ? cachedApprovalReady : approvalTotalsRaw;
const approvalTotals: YourSpendRowTotals = shouldUseCachedApproval && cachedApprovalForCurrentHash ? cachedApprovalForCurrentHash : approvalTotalsRaw;
const paymentTotals: YourSpendRowTotals = shouldUseCachedPayment && cachedPaymentReady ? cachedPaymentReady : paymentTotalsRaw;

// Stable key that changes whenever approval/payment applicability flips, so
// the search-firing effect re-runs.
const applicabilityKey = `${isApprovalApplicable ? 1 : 0}${isPaymentApplicable ? 1 : 0}`;
// Re-fires the search effect when applicability flips or the user
// joins/leaves a workspace (which changes the policyID filter).
const applicabilityKey = `${isApprovalApplicable ? 1 : 0}${isPaymentApplicable ? 1 : 0}|${paidGroupPolicyIDs.join(',')}`;

const fireSearches = useEffectEvent(() => {
if (isOffline) {
Expand Down
86 changes: 71 additions & 15 deletions tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'
import useNetwork from '@hooks/useNetwork';
import {search} from '@libs/actions/Search';
import {getDisplayableExpensifyCards} from '@libs/CardUtils';
import {hasApprovalFlow} from '@libs/PolicyUtils';
import {isPaidGroupPolicy} from '@libs/PolicyUtils';
import {buildSearchQueryJSON} from '@libs/SearchQueryUtils';
import YOUR_SPEND_ROW_STATE from '@pages/home/YourSpendSection/const';
import {buildAwaitingApprovalQuery, buildRecentCardTransactionsQuery, buildRepaidLast30DaysQuery} from '@pages/home/YourSpendSection/queries';
Expand Down Expand Up @@ -74,7 +74,7 @@ jest.mock('@libs/CardUtils', () => ({

jest.mock('@libs/PolicyUtils', () => ({
...jest.requireActual<Record<string, unknown>>('@libs/PolicyUtils'),
hasApprovalFlow: jest.fn(() => false),
isPaidGroupPolicy: jest.fn(() => false),
}));

// Typed references to mocked modules
Expand All @@ -83,7 +83,7 @@ const mockedUseNetwork = jest.mocked(useNetwork);
const mockedUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails);
const mockedSearch = jest.mocked(search);
const mockedGetDisplayableExpensifyCards = jest.mocked(getDisplayableExpensifyCards);
const mockedHasApprovalFlow = jest.mocked(hasApprovalFlow);
const mockedIsPaidGroupPolicy = jest.mocked(isPaidGroupPolicy);
const mockedBuildAwaitingApprovalQuery = jest.mocked(buildAwaitingApprovalQuery);
const mockedBuildRepaidLast30DaysQuery = jest.mocked(buildRepaidLast30DaysQuery);
const mockedBuildRecentCardTransactionsQuery = jest.mocked(buildRecentCardTransactionsQuery);
Expand Down Expand Up @@ -189,7 +189,7 @@ beforeEach(() => {
mockedUseNetwork.mockReturnValue(networkState(false));
mockedUseCurrentUserPersonalDetails.mockReturnValue({accountID: ACCOUNT_ID, login: `${ACCOUNT_ID}@test.com`} as CurrentUserPersonalDetails);
mockedGetDisplayableExpensifyCards.mockReturnValue([]);
mockedHasApprovalFlow.mockReturnValue(false);
mockedIsPaidGroupPolicy.mockReturnValue(false);

// Default policies: one CORPORATE policy, no approval flow, payments enabled
setupPolicies([makeCorporatePolicy()]);
Expand All @@ -199,42 +199,42 @@ beforeEach(() => {

describe('useYourSpendData — approvalRowState', () => {
it('returns HIDDEN when no policy has an approval flow', () => {
mockedHasApprovalFlow.mockReturnValue(false);
mockedIsPaidGroupPolicy.mockReturnValue(false);
const {result} = renderHook(() => useYourSpendData());
expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN);
});

it('returns LOADING when applicable, online, and snapshot not yet populated', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
// No snapshot entry → undefined
const {result} = renderHook(() => useYourSpendData());
expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.LOADING);
});

it('returns READY when applicable and snapshot has count > 0', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupApprovalSnapshot(makeSearchResultsWithCount(5));
const {result} = renderHook(() => useYourSpendData());
expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY);
});

it('returns HIDDEN_EMPTY when applicable and snapshot has count === 0', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupApprovalSnapshot(makeSearchResultsWithCount(0));
const {result} = renderHook(() => useYourSpendData());
expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY);
});

it('returns HIDDEN_EMPTY when applicable, offline, and no cached snapshot', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
mockedUseNetwork.mockReturnValue(networkState(true));
// No snapshot set
const {result} = renderHook(() => useYourSpendData());
expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY);
});

it('returns READY when applicable, offline, and cached snapshot has count > 0', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
mockedUseNetwork.mockReturnValue(networkState(true));
setupApprovalSnapshot(makeSearchResultsWithCount(3));
const {result} = renderHook(() => useYourSpendData());
Expand All @@ -252,34 +252,39 @@ describe('useYourSpendData — paymentRowState', () => {
});

it('returns LOADING when applicable, online, and payment snapshot not yet populated', () => {
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupPolicies([makeCorporatePolicy({reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES})]);
// No snapshot entry
const {result} = renderHook(() => useYourSpendData());
expect(result.current.paymentRowState).toBe(YOUR_SPEND_ROW_STATE.LOADING);
});

it('returns READY when applicable and payment snapshot has count > 0', () => {
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupPolicies([makeCorporatePolicy({reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES})]);
setupPaymentSnapshot(makeSearchResultsWithCount(2));
const {result} = renderHook(() => useYourSpendData());
expect(result.current.paymentRowState).toBe(YOUR_SPEND_ROW_STATE.READY);
});

it('returns HIDDEN_EMPTY when applicable and payment snapshot has count === 0', () => {
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupPolicies([makeCorporatePolicy({reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES})]);
setupPaymentSnapshot(makeSearchResultsWithCount(0));
const {result} = renderHook(() => useYourSpendData());
expect(result.current.paymentRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY);
});

it('returns HIDDEN_EMPTY when applicable, offline, and no cached payment snapshot', () => {
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupPolicies([makeCorporatePolicy({reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES})]);
mockedUseNetwork.mockReturnValue(networkState(true));
const {result} = renderHook(() => useYourSpendData());
expect(result.current.paymentRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY);
});

it('applies to REIMBURSEMENT_MANUAL (track) as well as REIMBURSEMENT_YES (direct)', () => {
mockedIsPaidGroupPolicy.mockReturnValue(true);
setupPolicies([makeCorporatePolicy({reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL})]);
setupPaymentSnapshot(makeSearchResultsWithCount(1));
const {result} = renderHook(() => useYourSpendData());
Expand Down Expand Up @@ -355,9 +360,28 @@ describe('useYourSpendData — cardRows', () => {
// query builder integration

describe('useYourSpendData — query builder integration', () => {
it('calls buildAwaitingApprovalQuery with the current user accountID', () => {
it('calls buildAwaitingApprovalQuery with the current user accountID and an empty policyIDs list when the user has no paid group policy', () => {
mockedIsPaidGroupPolicy.mockReturnValue(false);
renderHook(() => useYourSpendData());
expect(buildAwaitingApprovalQuery).toHaveBeenCalledWith(ACCOUNT_ID);
expect(buildAwaitingApprovalQuery).toHaveBeenCalledWith(ACCOUNT_ID, []);
});

it('passes the IDs of paid group policies into buildAwaitingApprovalQuery', () => {
const policyA = makeCorporatePolicy({id: 'policy_a'});
const policyB = makeCorporatePolicy({id: 'policy_b'});
setupPolicies([policyA, policyB]);
mockedIsPaidGroupPolicy.mockReturnValue(true);
renderHook(() => useYourSpendData());
expect(buildAwaitingApprovalQuery).toHaveBeenCalledWith(ACCOUNT_ID, expect.arrayContaining(['policy_a', 'policy_b']));
});

it('excludes policies that do not pass isPaidGroupPolicy from the policyIDs list', () => {
const paidGroup = makeCorporatePolicy({id: 'paid_group'});
const otherPolicy = makeCorporatePolicy({id: 'other'});
setupPolicies([paidGroup, otherPolicy]);
mockedIsPaidGroupPolicy.mockImplementation((p) => p?.id === 'paid_group');
renderHook(() => useYourSpendData());
expect(buildAwaitingApprovalQuery).toHaveBeenCalledWith(ACCOUNT_ID, ['paid_group']);
});

it('calls buildRepaidLast30DaysQuery with the current user accountID', () => {
Expand Down Expand Up @@ -386,7 +410,7 @@ describe('useYourSpendData — query builder integration', () => {

describe('useYourSpendData — search dispatch', () => {
it('dispatches search() with shouldCalculateTotals:true and shouldUpdateLastSearchParams:false when focused and online', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
renderHook(() => useYourSpendData());
expect(search).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -397,14 +421,14 @@ describe('useYourSpendData — search dispatch', () => {
});

it('does not dispatch search() when offline', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
mockedUseNetwork.mockReturnValue(networkState(true));
renderHook(() => useYourSpendData());
expect(search).not.toHaveBeenCalled();
});

it('dispatches search() with the approval queryJSON hash', () => {
mockedHasApprovalFlow.mockReturnValue(true);
mockedIsPaidGroupPolicy.mockReturnValue(true);
renderHook(() => useYourSpendData());
const expectedHash = buildSearchQueryJSON(APPROVAL_QUERY)?.hash;
expect(search).toHaveBeenCalledWith(
Expand All @@ -414,3 +438,35 @@ describe('useYourSpendData — search dispatch', () => {
);
});
});

// approval cache scoped by query hash

describe('useYourSpendData — approval cache is keyed by query hash', () => {
// Second valid query string with a different hash, simulating the user's paid-workspace
// set changing (which changes the policyID filter and therefore the approval query hash).
const APPROVAL_QUERY_B = `type:expense status:outstanding from:${ACCOUNT_ID} reimbursable:yes policyID:other_policy`;

function setupApprovalSnapshotForQuery(query: string, results: SearchResults | undefined) {
const hash = buildSearchQueryJSON(query)?.hash;
onyxData[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`] = results;
}

it('does not reuse a previous-hash cached READY total after the approval query hash changes', () => {
mockedIsPaidGroupPolicy.mockReturnValue(true);

// Render with query A: snapshot READY with count > 0 so the cache fills.
mockedBuildAwaitingApprovalQuery.mockReturnValue(APPROVAL_QUERY);
setupApprovalSnapshotForQuery(APPROVAL_QUERY, makeSearchResultsWithCount(3));
const {result, rerender} = renderHook(() => useYourSpendData());
expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY);

// Switch to query B (different hash), with count missing on B's snapshot — the situation
// that would let a stale-cache reuse happen if the cache weren't keyed by hash.
mockedBuildAwaitingApprovalQuery.mockReturnValue(APPROVAL_QUERY_B);
setupApprovalSnapshotForQuery(APPROVAL_QUERY_B, {search: {count: undefined}, data: {}} as unknown as SearchResults);
rerender(undefined);

// Should NOT be READY — the cache for hash A must not apply to hash B.
expect(result.current.approvalRowState).not.toBe(YOUR_SPEND_ROW_STATE.READY);
});
});
Loading
Loading