Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3eb951b
fix: stop the Account/Wallet RBR for personal cards past the 90-day g…
wildan-m Jul 17, 2026
ac02fdb
Merge remote-tracking branch 'upstream/main' into wildan/91451-person…
wildan-m Jul 17, 2026
4c2ad73
fix: suppress only the broken-connection error past the grace period
wildan-m Jul 18, 2026
e50ca23
fix: also drop the broken-connection card error past the grace period
wildan-m Jul 20, 2026
17be8bf
fix: parse ISO-format lastScrape so the 90-day broken-connection gate…
wildan-m Jul 24, 2026
96e4a24
test: reword to satisfy cspell (parseable -> valid)
wildan-m Jul 24, 2026
13f06f8
Merge remote-tracking branch 'upstream/main' into wildan/91451-person…
wildan-m Jul 24, 2026
d266031
Merge remote-tracking branch 'upstream/main' into wildan/91451-person…
wildan-m Jul 29, 2026
adc1fe8
fix: dismiss a personal card's error by last-sync age, not the broken…
wildan-m Jul 29, 2026
6d3e92c
Merge remote-tracking branch 'upstream/main' into wildan/91451-person…
wildan-m Aug 3, 2026
aed1471
fix: keep a >90-day broken company feed fixable, stop only the prompting
wildan-m Aug 3, 2026
1ec3050
Merge remote-tracking branch 'upstream/main' into wildan/91451-person…
wildan-m Aug 3, 2026
0b71530
fix: don't let a dismissed broken feed block assigning or hide card e…
wildan-m Aug 4, 2026
00e6ac1
Merge remote-tracking branch 'upstream/main' into wildan/91451-person…
wildan-m Aug 18, 2026
9ed47a9
style: drop em dashes from added comments (CONSISTENCY-16)
wildan-m Aug 18, 2026
cebad6d
fix: keep failed manual syncs actionable past the grace period
wildan-m Aug 18, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ function WorkspaceCompanyCardsTable({

const {cardFeedErrors} = useCardFeedErrors();
const illustrations = useMemoizedLazyIllustrations(['LaptopAssignCard', 'BrokenMagnifyingGlass']);
const isFeedConnectionBroken = feedName ? cardFeedErrors[feedName]?.isFeedConnectionBroken : false;
// Per-row errors are hidden while we surface the connection error for the whole feed instead. Keyed on the prompting flag
// so that past the grace period an actionable card error (e.g. a failed unassignment) becomes visible and dismissible
// again rather than being suppressed forever.
const isFeedConnectionBroken = feedName ? cardFeedErrors[feedName]?.shouldPromptBrokenConnection : false;

const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY);
const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES);
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useAssignCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ function useAssignCard({feedName, policyID, setShouldShowOfflineModal}: UseAssig

const {cardFeedErrors} = useCardFeedErrors();
const feedErrors = feedName ? cardFeedErrors[feedName] : undefined;
const isSelectedFeedConnectionBroken = !!feedErrors?.isFeedConnectionBroken || !!feedErrors?.hasFeedErrors;
// Keyed on the prompting flag rather than `isFeedConnectionBroken`: once a broken connection is past the grace period we
// stop blocking assignment. Otherwise a single long-dead card would disable assigning on the whole feed forever, and a
// commercial/CSV feed cannot be reconnected at all, so there would be no way out.
const isSelectedFeedConnectionBroken = !!feedErrors?.shouldPromptBrokenConnection || !!feedErrors?.hasFeedErrors;

const isAllowedToIssueCompanyCard = useIsAllowedToIssueCompanyCard({policyID});
const isAssigningCardDisabled = !currentFeedData || !!currentFeedData?.pending || isSelectedFeedConnectionBroken || !isAllowedToIssueCompanyCard;
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useCardFeedErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import useOnyx from './useOnyx';
const DEFAULT_CARD_FEED_ERROR_STATE: CardFeedErrorState = {
shouldShowRBR: false,
isFeedConnectionBroken: false,
shouldPromptBrokenConnection: false,
hasFeedErrors: false,
hasWorkspaceErrors: false,
};
Expand Down
40 changes: 29 additions & 11 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1450,30 +1450,47 @@ function getCardConnectionStatusDisplay({
}

/**
* Check whether a broken card connection has been unresolved long enough that we should stop
* actively prompting the user (remove the time-sensitive task and the RBR). The error itself is
* kept, so this is only used to gate the proactive surfacing, not the underlying broken state.
* Check whether a card's last successful sync is at least the dismiss threshold (90 days) old.
*
* `lastScrape` is the last successful update timestamp (a separate `lastImportAttempt` tracks
* attempts), so for a broken connection its age equals how long the connection has been failing.
* attempts), so its age equals how long the card has gone without a working sync. A card can carry
* a server-set connection error even when `lastScrapeResult` is one of the ignored statuses (e.g.
* 434), so this deliberately does NOT require `isCardConnectionBroken`. Use it to decide whether
* a card's errors should still surface account-level indicators.
*
* @param card the card to check
* @returns true if the connection is broken and has been unresolved for at least the grace period
* @returns true if the last successful sync is at least the grace period old
*/
function isBrokenConnectionPastDismissThreshold(card: Card): boolean {
if (!isCardConnectionBroken(card) || !card.lastScrape) {
function isLastScrapePastDismissThreshold(card: Card): boolean {
if (!card.lastScrape) {
return false;
}
// `card.lastScrape` uses the Expensify DB datetime format (e.g. "2024-11-27 11:00:53"). Parse it explicitly with the
// matching format instead of relying on `new Date()`, whose handling of this non-ISO string is not portable across JS
// engines — an invalid parse would make the difference NaN, so the comparison would always be false and never dismiss.
const lastScrapeDate = parse(card.lastScrape, 'yyyy-MM-dd HH:mm:ss', new Date());
// `card.lastScrape` is usually the Expensify DB datetime format ("2024-11-27 11:00:53"), but a personal card's value can
// arrive as ISO 8601 ("2024-11-27T11:00:53Z"). Try the DB format explicitly first (its `new Date()` handling isn't
// portable across JS engines), then fall back to `new Date()`, which parses ISO 8601 reliably. Without the fallback an
// ISO value fails the DB parse, the difference is NaN, and the connection is never dismissed (the RBR stays forever).
let lastScrapeDate = parse(card.lastScrape, 'yyyy-MM-dd HH:mm:ss', new Date());
if (Number.isNaN(lastScrapeDate.getTime())) {
lastScrapeDate = new Date(card.lastScrape);
}
if (Number.isNaN(lastScrapeDate.getTime())) {
return false;
}
return DateUtils.getDifferenceInDaysFromNow(lastScrapeDate) >= CONST.COMPANY_CARDS.BROKEN_CONNECTION_DISMISS_AFTER_DAYS;
}

/**
* Check whether a broken card connection has been unresolved long enough that we should stop
* actively prompting the user (remove the time-sensitive task and the RBR). The error itself is
* kept, so this is only used to gate the proactive surfacing, not the underlying broken state.
*
* @param card the card to check
* @returns true if the connection is broken and has been unresolved for at least the grace period
*/
function isBrokenConnectionPastDismissThreshold(card: Card): boolean {
return isCardConnectionBroken(card) && isLastScrapePastDismissThreshold(card);
}

/**
* Checks if an Expensify Card was issued for a given workspace.
*/
Expand Down Expand Up @@ -2178,6 +2195,7 @@ export {
doesCardConnectionNeedReauthentication,
getCardConnectionStatusDisplay,
isBrokenConnectionPastDismissThreshold,
isLastScrapePastDismissThreshold,
isSmartLimitEnabled,
lastFourNumbersFromCardName,
isMatchingCard,
Expand Down
48 changes: 38 additions & 10 deletions src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import {getCombinedCardFeedsFromAllFeeds, getWorkspaceCardFeedsStatus} from '@libs/CardFeedUtils';
import {filterAllInactiveCards, forEachAssignedCard, getCardFeedWithDomainID, isBrokenConnectionPastDismissThreshold, isCardConnectionBroken, isPersonalCard} from '@libs/CardUtils';
import {
filterAllInactiveCards,
forEachAssignedCard,
getCardFeedWithDomainID,
isBrokenConnectionPastDismissThreshold,
isCardConnectionBroken,
isLastScrapePastDismissThreshold,
isPersonalCard,
} from '@libs/CardUtils';

import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig';

Expand All @@ -17,6 +25,7 @@ const DEFAULT_CARD_FEED_ERROR_STATE: CardFeedErrorState = {
hasFeedErrors: false,
hasWorkspaceErrors: false,
isFeedConnectionBroken: false,
shouldPromptBrokenConnection: false,
};

function getShouldShowRBR(state: Partial<CardFeedErrorState>): boolean {
Expand All @@ -27,7 +36,9 @@ function getShouldShowRBR(state: Partial<CardFeedErrorState>): boolean {
return true;
}

return !!state.isFeedConnectionBroken;
// Deliberately keyed on the prompting flag, not `isFeedConnectionBroken`: past the grace period we stop showing
// the RBR while the connection stays broken so it can still be fixed.
return !!state.shouldPromptBrokenConnection;
}

export default createOnyxDerivedValueConfig({
Expand All @@ -53,7 +64,15 @@ export default createOnyxDerivedValueConfig({
const personalCardsWithBrokenConnection: Record<string, Card> = {};

function addErrorsForPersonalCard(card: Card) {
const hasCardErrors = !isEmptyObject(card.errors) || !isEmptyObject(card.errorFields);
// Once the card has gone without a successful sync past the grace period we stop leading the user to it: the
// time-sensitive task and the RBR are removed. The connection error is a server-set `card.errors` entry, which
// is what lights the Account button via `hasPaymentMethodError`, so past the threshold it must not light the
// RBR. This is keyed on the last successful sync rather than `isCardConnectionBroken`, because the server sets
// the connection error even for scrape statuses that check ignores (e.g. 434). `errorFields` entries are left
// alone: they are written by a user-initiated action that failed (a manual sync, a reimbursable/start-date
// update), so they stay actionable no matter how old the connection is. The error itself stays on the card.
const isPastDismissThreshold = isLastScrapePastDismissThreshold(card);
const hasCardErrors = (!isPastDismissThreshold && !isEmptyObject(card.errors)) || !isEmptyObject(card.errorFields);
const cardErrors = {
...(hasCardErrors
? {
Expand All @@ -66,21 +85,23 @@ export default createOnyxDerivedValueConfig({
: {}),
} as Record<string, CardErrors>;

// Stop surfacing the broken connection (task + RBR) once it has been unresolved past the
// grace period; the underlying error on the card is kept so the user can still fix it.
const isFeedConnectionBroken = isCardConnectionBroken(card) && !isBrokenConnectionPastDismissThreshold(card);
const isFeedConnectionBroken = isCardConnectionBroken(card) && !isPastDismissThreshold;
// Track personal cards with broken feed connection
if (isFeedConnectionBroken) {
personalCardsWithBrokenConnection[card.cardID] = card;
}
const newFeedState: Omit<CardFeedErrorState, 'shouldShowRBR'> = {
isFeedConnectionBroken,
// A personal card is fixed from its own details page, which reads the card directly, so there is no
// separate capability signal to preserve here. Prompting follows the same grace period.
shouldPromptBrokenConnection: isFeedConnectionBroken,
hasFeedErrors: !isEmptyObject(cardErrors),
hasWorkspaceErrors: false,
};
const shouldShowRBR = getShouldShowRBR(newFeedState);

personalCardStates.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken;
personalCardStates.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection;
personalCardStates.hasFeedErrors ||= newFeedState.hasFeedErrors;
personalCardStates.shouldShowRBR ||= shouldShowRBR;
}
Expand Down Expand Up @@ -123,12 +144,15 @@ export default createOnyxDerivedValueConfig({
: {}),
} as Record<string, CardErrors>;

// Stop surfacing the broken connection (task + RBR) once it has been unresolved past the
// grace period; the underlying error on the card is kept so the user can still fix it.
const isFeedConnectionBroken = isCardConnectionBroken(card) && !isBrokenConnectionPastDismissThreshold(card);
// Keep the broken state itself truthful: the Company cards page renders its "log into your bank" fix from
// this flag, and the reconnect needs it to clear the error afterwards. Only stop *prompting* (the RBR and
// the time-sensitive task) once the connection has been unresolved past the grace period.
const isFeedConnectionBroken = isCardConnectionBroken(card);
const shouldPromptBrokenConnection = isFeedConnectionBroken && !isBrokenConnectionPastDismissThreshold(card);

const newFeedState: Omit<CardFeedErrorState, 'shouldShowRBR'> = {
isFeedConnectionBroken: isFeedConnectionBroken || previousFeedErrors.isFeedConnectionBroken,
shouldPromptBrokenConnection: shouldPromptBrokenConnection || previousFeedErrors.shouldPromptBrokenConnection,
hasFeedErrors: hasFeedErrors || previousFeedErrors.hasFeedErrors,
hasWorkspaceErrors: hasWorkspaceErrors || previousFeedErrors.hasWorkspaceErrors,
};
Expand All @@ -143,7 +167,9 @@ export default createOnyxDerivedValueConfig({
workspaceErrors,
};

// Track cards with broken feed connection
// Track cards with broken feed connection. This stays truthful past the grace period so that reconnecting
// still clears the error (see useUpdateFeedBrokenConnection); consumers that prompt the user filter on the
// grace period themselves.
if (isFeedConnectionBroken) {
cardsWithBrokenFeedConnection[card.cardID] = card;
}
Expand All @@ -152,10 +178,12 @@ export default createOnyxDerivedValueConfig({
const cardTypeState = isExpensifyCard ? expensifyCardFeedStates : companyCardFeedsState;

allFeedsState.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken;
allFeedsState.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection;
allFeedsState.hasFeedErrors ||= newFeedState.hasFeedErrors;
allFeedsState.hasWorkspaceErrors ||= newFeedState.hasWorkspaceErrors;

cardTypeState.isFeedConnectionBroken ||= newFeedState.isFeedConnectionBroken;
cardTypeState.shouldPromptBrokenConnection ||= newFeedState.shouldPromptBrokenConnection;
cardTypeState.hasFeedErrors ||= newFeedState.hasFeedErrors;
cardTypeState.hasWorkspaceErrors ||= newFeedState.hasWorkspaceErrors;

Expand Down
6 changes: 5 additions & 1 deletion src/libs/actions/PaymentMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,11 @@ function hasPaymentMethodError(
const policyList = Object.values(policies ?? {}).filter(Boolean);
const hasRelevantCardError = cardsWithErrors.some((card) => {
if (CardUtils.isPersonalCard(card)) {
return true;
// A personal card's connection error is surfaced as a card error too, so once the card has gone without a
// successful sync past the grace period we stop leading the user to it and it must no longer light the RBR.
// This is deliberately keyed on the last successful sync rather than `isCardConnectionBroken`: the server
// sets the connection error even for scrape statuses that check treats as ignored (e.g. 434).
return !CardUtils.isLastScrapePastDismissThreshold(card);
}
const workspaceAccountID = Number(card?.fundID);
const policy = policyList.find((p) => p?.policyAccountID === workspaceAccountID);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import useCardFeedErrors from '@hooks/useCardFeedErrors';

import {getCardFeedWithDomainID, isDirectFeed} from '@libs/CardUtils';
import {getCardFeedWithDomainID, isBrokenConnectionPastDismissThreshold, isDirectFeed} from '@libs/CardUtils';

import type {Policy} from '@src/types/onyx';

Expand Down Expand Up @@ -45,6 +45,12 @@ function useBrokenDirectCompanyCardFeedsForAdmin(adminPolicies: Policy[] | undef
continue;
}

// Stop offering the task once the connection has been unresolved past the grace period. The card stays in
// `cardsWithBrokenFeedConnection` so the Company cards page can still fix it. We just stop prompting here.
if (isBrokenConnectionPastDismissThreshold(card)) {
continue;
}

const cardFundID = Number(card.fundID);
const matchingPolicy = adminPolicies.find((policy) => policy.policyAccountID === cardFundID);

Expand Down
10 changes: 9 additions & 1 deletion src/types/onyx/DerivedValues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,17 @@ type CardFeedErrorState = {
hasWorkspaceErrors: boolean;

/**
* Whether some feed connection is broken.
* Whether some feed connection is broken. This stays true for as long as the connection is broken, so the
* Company cards page keeps offering the "log into your bank" fix and the reconnect can complete.
*/
isFeedConnectionBroken: boolean;

/**
* Whether we should still actively prompt the user about the broken connection (the RBR dots and the
* time-sensitive home task). Unlike `isFeedConnectionBroken` this turns false once the connection has been
* unresolved past the grace period, so we stop nagging without taking away the ability to fix it.
*/
shouldPromptBrokenConnection: boolean;
};

/**
Expand Down
Loading
Loading