Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8840,6 +8840,7 @@ const CONST = {
SUBMIT: 'submit',
APPROVE: 'approve',
PAY: 'pay',
REJECTED: 'rejected',
EXPORT: 'export',
STATEMENTS: 'statements',
UNAPPROVED_CASH: 'unapprovedCash',
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ const ONYXKEYS = {
/** Whether the "For You" section has ever shown an actionable to-do (keeps the section visible once seen) */
NVP_HAS_SEEN_FOR_YOU_TODO: 'nvp_hasSeenForYouTodo',

/** Whether the user has ever had a rejected expense (keeps the "Rejected" suggested search visible once seen) */
NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH: 'nvp_hasSeenRejectedSuggestedSearch',

/** The insight the user last selected in the Home page insights card (defaults to Spend over time) */
NVP_HOME_SELECTED_INSIGHT: 'nvp_homeSelectedInsight',

Expand Down Expand Up @@ -1889,6 +1892,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING]: Record<string, number>;
[ONYXKEYS.HAS_LOADED_APP]: boolean;
[ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO]: boolean;
[ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH]: boolean;
[ONYXKEYS.NVP_HOME_SELECTED_INSIGHT]:
| typeof CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME
| typeof CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS
Expand Down
30 changes: 30 additions & 0 deletions src/hooks/useHasRejectedExpense.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {hasReportBeenRejectedToSubmitter} from '@libs/ReportUtils';
import {hasTransactionBeenRejected} from '@libs/TransactionUtils';

import ONYXKEYS from '@src/ONYXKEYS';
import type {Report, TransactionViolations} from '@src/types/onyx';

import type {OnyxCollection} from 'react-native-onyx';

// We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live data
// eslint-disable-next-line no-restricted-imports
import {useOnyx} from 'react-native-onyx';

const hasRejectedTransactionSelector = (allTransactionViolations: OnyxCollection<TransactionViolations>): boolean =>
Object.values(allTransactionViolations ?? {}).some((transactionViolations) => hasTransactionBeenRejected(transactionViolations));

// A whole-report rejection doesn't add a violation to the report's transactions, so it has to be checked on the report.
const hasRejectedReportSelector = (allReports: OnyxCollection<Report>): boolean => Object.values(allReports ?? {}).some((report) => hasReportBeenRejectedToSubmitter(report));
Comment on lines +13 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These selectors also match rejections on expenses the current user doesn't own.

Repro: an approver rejects one expense and one report from a submitter. On the approver's account, the server copy of transactionViolations_* still has autoReportedRejectedExpense with rejectedBy set to the approver, and the report matches hasReportBeenRejectedToSubmitter with ownerAccountID set to the submitter.

So hasRejectedExpense is true for the approver, the NVP gets set, and "Rejected" stays in their nav even though none of their own expenses were rejected.

@MelvinBot please investigate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right. Neither selector checks who owns the expense, so an approver or admin who rejects someone else's expense trips the hook and gets the NVP set.

Root cause

Recommended fix: limit both checks to reports the current user owns. The violation doesn't identify the submitter, so the expense path has to go from violation to transaction (reportID) to report (ownerAccountID). isCurrentUserSubmitter already does the owner check. Checking rejectedBy !== currentUserLogin isn't enough, because a second admin who didn't reject the expense would still match.

Sketch

This uses closure selectors, the same pattern as useReportTransactionViolations.ts:15. The last selector still returns a boolean, so the nav only re-renders when the answer changes.

function useHasRejectedExpense(): boolean {
    const {accountID} = useCurrentUserPersonalDetails();

    // 1. IDs of transactions with the rejected-expense violation (usually a short list)
    const [rejectedTransactionIDs = []] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: rejectedTransactionIDsSelector});

    // 2. The reports those transactions live on
    const [rejectedTransactionReportIDs = []] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: reportIDsForTransactionsSelector(rejectedTransactionIDs)});

    // 3. True only if the current user owns a report that was rejected, or that holds a rejected expense
    const [hasRejectedExpense = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {
        selector: hasOwnRejectionSelector(accountID, rejectedTransactionReportIDs),
    });
    return hasRejectedExpense;
}

hasOwnRejectionSelector returns report.ownerAccountID === accountID && (hasReportBeenRejectedToSubmitter(report) || rejectedTransactionReportIDs.includes(report.reportID)).

Test accounts that already have nvp_hasSeenRejectedSuggestedSearch set will keep seeing "Rejected" after the fix. Reset that NVP before you retest.

There's a related gap: the suggested query is type:expense declined:yes with no from:me. An approver who has their own rejected expense may also see others' rejections in the results, depending on how the backend scopes declined:yes. I couldn't verify that from the App repo.


Reply with @MelvinBot implement this to push the fix to this PR, or @MelvinBot <your feedback> to change the approach.

Comment on lines +13 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MelvinBot please investigate: the report check here shows "Rejected" for whole-report rejections, but declined:yes doesn't seem to return those expenses.

Repro: an approver rejects two expenses individually and one whole report (5778504925457019). The report matches hasReportBeenRejectedToSubmitter, but type:expense declined:yes only returns the two individually rejected expenses (reports 4298380125836590 and 8678739946184850). Nothing from the rejected report comes back. I checked this from the approver's account.

Can you confirm whether the backend's declined:yes match includes expenses in a fully rejected report? If not, a submitter whose whole report was rejected will see "Rejected" in the nav but get "No rejected expenses".


/**
* Whether the user currently has at least one rejected expense, either rejected on its own or as part of a rejected report.
* The selectors return a boolean so consumers only re-render when the answer changes.
*/
function useHasRejectedExpense(): boolean {
const [hasRejectedTransaction = false] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: hasRejectedTransactionSelector});
const [hasRejectedReport = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: hasRejectedReportSelector});

return hasRejectedTransaction || hasRejectedReport;
}

export default useHasRejectedExpense;
19 changes: 19 additions & 0 deletions src/hooks/useSearchTypeMenuSections.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {setHasSeenRejectedSuggestedSearch} from '@libs/actions/Search';
import {createTypeMenuSections, omitInsightsPageMenuItems} from '@libs/SearchUIUtils';

import CONST from '@src/CONST';
Expand All @@ -13,6 +14,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react';

import useCardFeedsForDisplay from './useCardFeedsForDisplay';
import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation';
import useHasRejectedExpense from './useHasRejectedExpense';
import useHasReportAwaitingApproval from './useHasReportAwaitingApproval';
import useMappedPolicies from './useMappedPolicies';
import useNetwork from './useNetwork';
Expand Down Expand Up @@ -76,6 +78,21 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => {
// A report awaiting the current user's approval makes the "Needs approval" suggested search relevant even when they
// are not part of the policy's approval workflow (e.g. an approver chosen manually on a single report).
const hasReportAwaitingApproval = useHasReportAwaitingApproval(isScreenFocused);

// The "Rejected" suggested search appears once the user has a rejected expense, then stays visible for good so it
// doesn't come and go as expenses are rejected and resolved.
const hasRejectedExpense = useHasRejectedExpense();
const [hasSeenRejectedSuggestedSearch = false, hasSeenRejectedSuggestedSearchMetadata] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH);
const isHasSeenRejectedSuggestedSearchLoading = hasSeenRejectedSuggestedSearchMetadata.status === 'loading';
const shouldShowRejectedSuggestion = hasRejectedExpense || hasSeenRejectedSuggestedSearch;

useEffect(() => {
if (isHasSeenRejectedSuggestedSearchLoading || !hasRejectedExpense || hasSeenRejectedSuggestedSearch) {
return;
}
setHasSeenRejectedSuggestedSearch();
}, [isHasSeenRejectedSuggestedSearchLoading, hasRejectedExpense, hasSeenRejectedSuggestedSearch]);

const [pendingReportCreation, setPendingReportCreation] = useState<{policyID: string; policyName?: string; onConfirm: (shouldDismissEmptyReportsConfirmation: boolean) => void} | null>(
null,
);
Expand Down Expand Up @@ -122,6 +139,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => {
isTrackIntentUser: isTrackIntentUser ?? false,
hasReportAwaitingApproval,
policyCategories: allPolicyCategories,
shouldShowRejectedSuggestion,
}),
[
currentUserLoginAndAccountID?.email,
Expand All @@ -137,6 +155,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => {
isTrackIntentUser,
hasReportAwaitingApproval,
allPolicyCategories,
shouldShowRejectedSuggestion,
],
);

Expand Down
5 changes: 5 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10230,6 +10230,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
title: 'Keine zu bezahlenden Ausgaben',
subtitle: 'Glückwunsch! Du hast die Ziellinie überquert.',
},
emptyRejectedResults: {
title: 'Keine abgelehnten Ausgaben',
subtitle: 'Nichts zu korrigieren. Gute Arbeit!',
},
emptyExportResults: {
title: 'Keine Spesen zum Exportieren',
subtitle: 'Zeit, es ruhig angehen zu lassen – gute Arbeit.',
Expand Down Expand Up @@ -10488,6 +10492,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
submit: 'Entwürfe',
approve: 'Genehmigung erforderlich',
pay: 'Zahlungsbereit',
rejected: 'Abgelehnt',
accounting: 'Buchhaltung',
export: 'Export ausstehend',
unapprovedCash: 'Kassenabgrenzungen',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10378,6 +10378,7 @@ ${reportName}`,
submit: 'Πρόχειρα',
approve: 'Χρειάζεται έγκριση',
pay: 'Έτοιμο για πληρωμή',
rejected: 'Απορρίφθηκε',
accounting: 'Λογιστική',
export: 'Αναμονή για εξαγωγή',
unapprovedCash: 'Δεδουλευμένα μετρητών',
Expand Down Expand Up @@ -10435,6 +10436,10 @@ ${reportName}`,
title: 'Δεν υπάρχουν έξοδα προς πληρωμή',
subtitle: 'Συγχαρητήρια! Περάσατε τη γραμμή τερματισμού.',
},
emptyRejectedResults: {
title: 'Δεν υπάρχουν απορριφθέντα έξοδα',
subtitle: 'Τίποτα για διόρθωση. Μπράβο!',
},
emptyExportResults: {
title: 'Δεν υπάρχουν έξοδα για εξαγωγή',
subtitle: 'Ώρα να χαλαρώσετε, μπράβο σας.',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10403,6 +10403,7 @@ const translations = {
submit: 'Drafts',
approve: 'Needs approval',
pay: 'Ready to pay',
rejected: 'Rejected',
accounting: 'Accounting',
export: 'Awaiting export',
unapprovedCash: 'Cash accruals',
Expand Down Expand Up @@ -10460,6 +10461,10 @@ const translations = {
title: 'No expenses to pay',
subtitle: 'Congrats! You crossed the finish line.',
},
emptyRejectedResults: {
title: 'No rejected expenses',
subtitle: 'Nothing to fix. Nice work!',
},
emptyExportResults: {
title: 'No expenses to export',
subtitle: 'Time to take it easy, nice work.',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10006,6 +10006,7 @@ ${reportName}`,
submit: 'Borradores',
approve: 'Necesita aprobación',
pay: 'Listo para pagar',
rejected: 'Rechazado',
accounting: 'Contabilidad',
export: 'Pendiente de exportación',
unapprovedCash: 'Acumulaciones de efectivo',
Expand Down Expand Up @@ -10059,6 +10060,10 @@ ${reportName}`,
title: 'No hay gastos para pagar',
subtitle: '¡Felicidades! Has cruzado la línea de meta.',
},
emptyRejectedResults: {
title: 'No hay gastos rechazados',
subtitle: 'Nada que corregir. ¡Buen trabajo!',
},
emptyExportResults: {
title: 'No hay gastos para exportar',
subtitle: 'Es hora de relajarse, buen trabajo.',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10256,6 +10256,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
title: 'Aucune dépense à payer',
subtitle: 'Félicitations ! Vous avez franchi la ligne d’arrivée.',
},
emptyRejectedResults: {
title: 'Aucune dépense rejetée',
subtitle: 'Rien à corriger. Beau travail !',
},
emptyExportResults: {
title: 'Aucune dépense à exporter',
subtitle: 'Il est temps de se détendre, beau travail.',
Expand Down Expand Up @@ -10514,6 +10518,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
submit: 'Brouillons',
approve: 'Nécessite une approbation',
pay: 'Prêt à payer',
rejected: 'Rejeté',
accounting: 'Comptabilité',
export: 'En attente d’exportation',
unapprovedCash: 'Provisions de trésorerie',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10178,6 +10178,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
title: 'Nessuna spesa da pagare',
subtitle: 'Congratulazioni! Hai tagliato il traguardo.',
},
emptyRejectedResults: {
title: 'Nessuna spesa rifiutata',
subtitle: 'Niente da correggere. Ottimo lavoro!',
},
emptyExportResults: {
title: 'Nessuna spesa da esportare',
subtitle: 'È il momento di rilassarsi, bel lavoro.',
Expand Down Expand Up @@ -10436,6 +10440,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
submit: 'Bozze',
approve: 'Richiede approvazione',
pay: 'Pronto per pagare',
rejected: 'Rifiutato',
accounting: 'Contabilità',
export: 'In attesa di esportazione',
unapprovedCash: 'Ratei e risconti in contanti',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10035,6 +10035,10 @@ ${reportName}`,
title: '支払う経費はありません',
subtitle: 'おめでとうございます!ゴールに到達しました。',
},
emptyRejectedResults: {
title: '却下された経費はありません',
subtitle: '修正が必要なものはありません。お疲れさまでした!',
},
emptyExportResults: {
title: 'エクスポートする経費はありません',
subtitle: 'ひと休みする時間です、お疲れさまでした。',
Expand Down Expand Up @@ -10283,6 +10287,7 @@ ${reportName}`,
submit: '下書き',
approve: '承認が必要',
pay: '支払いの準備完了',
rejected: '却下済み',
accounting: '会計',
export: 'エクスポート待ち',
unapprovedCash: '現金発生主義',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10149,6 +10149,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
title: 'Geen uitgaven om te betalen',
subtitle: 'Gefeliciteerd! Je hebt de finish gehaald.',
},
emptyRejectedResults: {
title: 'Geen afgekeurde uitgaven',
subtitle: 'Niets om te corrigeren. Goed gedaan!',
},
emptyExportResults: {
title: 'Geen uitgaven om te exporteren',
subtitle: 'Tijd om rustig aan te doen, goed gedaan.',
Expand Down Expand Up @@ -10407,6 +10411,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
submit: 'Concepten',
approve: 'Moet worden goedgekeurd',
pay: 'Klaar om te betalen',
rejected: 'Afgekeurd',
accounting: 'Boekhouding',
export: 'Wachten op export',
unapprovedCash: 'Kasverplichtingen',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10159,6 +10159,10 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
title: 'Brak wydatków do opłacenia',
subtitle: 'Gratulacje! Przekroczyłeś linię mety.',
},
emptyRejectedResults: {
title: 'Brak odrzuconych wydatków',
subtitle: 'Nie ma nic do poprawienia. Dobra robota!',
},
emptyExportResults: {
title: 'Brak wydatków do wyeksportowania',
subtitle: 'Czas trochę odetchnąć, dobra robota.',
Expand Down Expand Up @@ -10423,6 +10427,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
submit: 'Szkice',
approve: 'Wymaga zatwierdzenia',
pay: 'Gotowe do zapłaty',
rejected: 'Odrzucone',
accounting: 'Księgowość',
export: 'Oczekuje na eksport',
unapprovedCash: 'Rozliczenia kasowe',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10146,6 +10146,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
title: 'Nenhuma despesa para pagar',
subtitle: 'Parabéns! Você cruzou a linha de chegada.',
},
emptyRejectedResults: {
title: 'Nenhuma despesa rejeitada',
subtitle: 'Nada para corrigir. Bom trabalho!',
},
emptyExportResults: {
title: 'Nenhuma despesa para exportar',
subtitle: 'Hora de relaxar, bom trabalho.',
Expand Down Expand Up @@ -10404,6 +10408,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
submit: 'Rascunhos',
approve: 'Precisa de aprovação',
pay: 'Pronto para pagar',
rejected: 'Rejeitado',
accounting: 'Contabilidade',
export: 'Aguardando exportação',
unapprovedCash: 'Provisões de caixa',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9768,6 +9768,10 @@ ${reportName}`,
title: '暂无可支付的报销',
subtitle: '恭喜!你已冲过终点线。',
},
emptyRejectedResults: {
title: '暂无被拒绝的报销',
subtitle: '没有需要修正的内容。干得好!',
},
emptyExportResults: {
title: '没有可导出的报销费用',
subtitle: '该放松一下了,干得不错。',
Expand Down Expand Up @@ -10014,6 +10018,7 @@ ${reportName}`,
submit: '草稿',
approve: '待审批',
pay: '准备付款',
rejected: '已拒绝',
accounting: '会计',
export: '等待导出',
unapprovedCash: '现金应计',
Expand Down
1 change: 1 addition & 0 deletions src/libs/ExportOnyxState/masking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ const safeOnyxKeys = new Set<string>([
ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST,
ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH,
ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO,
ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH,
ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING,
ONYXKEYS.NVP_HOME_SELECTED_INSIGHT,
ONYXKEYS.NVP_LAST_ANDROID_LOGIN,
Expand Down
23 changes: 23 additions & 0 deletions src/libs/SearchSuggestionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const SEARCH_TYPE_MENU_ICON_NAMES = [
'Document',
'Pencil',
'ThumbsUp',
'ThumbsDown',
'CheckCircle',
] as const satisfies readonly ExpensifyIconName[];

Expand Down Expand Up @@ -258,6 +259,28 @@ function getSuggestedSearches(
return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID;
},
},
[CONST.SEARCH.SEARCH_KEYS.REJECTED]: {
key: CONST.SEARCH.SEARCH_KEYS.REJECTED,
translationPath: 'search.tabs.rejected',
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
icon: 'ThumbsDown',
searchQuery: buildQueryStringFromFilterFormValues({
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
declined: CONST.SEARCH.BOOLEAN.YES,
}),
get searchQueryJSON() {
return buildSearchQueryJSON(this.searchQuery);
},
get hash() {
return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID;
},
get similarSearchHash() {
return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID;
},
get recentSearchHash() {
return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID;
},
},
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: {
key: CONST.SEARCH.SEARCH_KEYS.EXPORT,
translationPath: 'search.tabs.export',
Expand Down
Loading
Loading