Skip to content

Fix Account and Wallet RBR still showing for personal cards past the 90-day grace period#96364

Open
wildan-m wants to merge 4 commits into
Expensify:mainfrom
wildan-m:wildan/91451-personal-card-rbr-followup
Open

Fix Account and Wallet RBR still showing for personal cards past the 90-day grace period#96364
wildan-m wants to merge 4 commits into
Expensify:mainfrom
wildan-m:wildan/91451-personal-card-rbr-followup

Conversation

@wildan-m

@wildan-m wildan-m commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Follow-up to #93523. That PR added the 90-day grace period and correctly removed the Home task, but for personal cards the Account indicator and the Wallet row red dot stayed on — which the issue asks us to remove:

Personal cards: Remove the indicator on Account and red dot on Wallet row in the account left hand bar. But keep the error on the card itself on the Wallet page so the card can still be fixed.

Reported on production by @joekaufmanexpensify: #91451 (comment)

Why it happens. Those two dots never ask "is this past 90 days?" — they only ask "does this card have an error?", via getShouldShowRBR() (which returns early on hasFeedErrors) and hasPaymentMethodError() (true for any personal card with errors). Since the issue tells us to keep that error so the card stays fixable, the dots never turn off. The Home task disappearing is the giveaway: the 90-day check works, these two paths just skip it.

The fix. Make both paths respect the same 90-day check. card.errors is untouched, so the Wallet page still shows the error with a working Fix card button — only the Account / Wallet dots stop. Cards inside the grace period, and cards with unrelated errors, are unaffected (both covered by new tests).

Fixed Issues

$ #91451
PROPOSAL: #91451 (comment)

Tests

This is shared logic (no platform-specific code), so it behaves the same on web, mWeb, iOS and Android.

Important — what actually reproduces this. It only shows up on a personal card (one added on Account > Wallet, i.e. fundID is absent or '0') whose broken connection also carries an error on the card itself. That error is what lights the Account / Wallet red dots, and we deliberately keep it so the card stays fixable. A company card from a workspace will not reproduce it — it goes through the feed path, which the existing grace check already covers.

Rather than waiting 90 days for real data, paste this in the browser console on a dev build to put your existing personal card into exactly that state:

(async () => {
  // ── config ──────────────────────────────────────────────────────────────
  const DAYS_AGO = 100;   // >= 90 → past the grace period. Set to 1 for the "recently broken" baseline.
  const RESET    = false; // true → restore the card to healthy
  // ────────────────────────────────────────────────────────────────────────
  try {
    if (typeof Onyx === 'undefined' || typeof Onyx.merge !== 'function') {
      console.error('[card-sim] window.Onyx unavailable — use a non-production (dev/adhoc) build.');
      return;
    }
    const cards = (await Onyx.get('cardList')) ?? {};
    // CardUtils.isPersonalCard(): no fundID, or fundID === '0'
    const personal = Object.entries(cards).filter(([, c]) => !c?.fundID || c.fundID === '0');
    console.log(`[card-sim] ${Object.keys(cards).length} card(s), ${personal.length} personal`);
    if (!personal.length) {
      console.warn('[card-sim] no personal card — add one via Account > Wallet > Add personal card first.');
      return;
    }
    if (RESET) {
      for (const [id] of personal) {
        await Onyx.merge('cardList', {[id]: {lastScrapeResult: 200, lastScrape: '', errors: null}});
        console.log(`[card-sim] reset ${id} to healthy`);
      }
      return;
    }
    const p = (n) => String(n).padStart(2, '0');
    const d = new Date(Date.now() - DAYS_AGO * 864e5);
    const scrape = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
    for (const [id] of personal) {
      await Onyx.merge('cardList', {[id]: {
        lastScrapeResult: 403,                                         // a broken status (not in BROKEN_CONNECTION_IGNORED_STATUSES)
        lastScrape: scrape,                                            // last *successful* refresh = how long it has been broken
        errors: {brokenConnection: 'Your card connection is broken.'}, // the error kept on the card — this is what lights the RBR
      }});
      console.log(`[card-sim] personal card ${id} -> broken(403), lastScrape ${scrape} (${DAYS_AGO}d ago)`);
    }
    console.log(DAYS_AGO >= 90
      ? '[card-sim] PAST grace → expect Home task, Account dot and Wallet row dot all GONE; card still listed with its error + "Fix card".'
      : '[card-sim] WITHIN grace → expect Home task, Account dot and Wallet row dot all SHOWING.');
  } catch (e) { console.error('[card-sim] failed:', e); }
})();
  1. On a dev build, make sure the account has a personal card (Account > Wallet > Add personal card). Run the script with DAYS_AGO = 1.
  2. Baseline (recently broken) — unchanged behaviour: confirm all three surface — the time-sensitive task on Home, a red dot on the Account button, and a red dot on the Wallet row in the account left hand bar.
  3. Run the script again with DAYS_AGO = 100.
  4. Past the grace period (this fix): confirm the Home task, the Account red dot, and the Wallet row red dot are all gone.
  5. Confirm the card itself is untouched on the Wallet page: still listed with its own red dot, and opening it still shows "Your card connection is broken." with a working Fix card button (the error is kept so it stays fixable).
  6. Re-run with RESET = true to put the card back to healthy.

You can also read the underlying state directly instead of eyeballing the dots:

JSON.stringify((await Onyx.get('cardFeedErrors'))?.personalCard)
// past grace, on this branch: {"shouldShowRBR":false,"hasFeedErrors":false,"hasWorkspaceErrors":false,"isFeedConnectionBroken":false}
// past grace, on main today:  {"shouldShowRBR":true, "hasFeedErrors":true, "hasWorkspaceErrors":false,"isFeedConnectionBroken":false}   <- the bug

shouldShowRBR is what drives the Account / Wallet red dots; isFeedConnectionBroken drives the Home task. On main the task is correctly gone but shouldShowRBR stays true — which is exactly what was reported.

  • Verify that no errors appear in the JS console

Offline tests

Same as the Tests above. This is a derived value computed from data already in Onyx plus the device date, so it behaves identically offline — the task and indicators stay hidden for a past-grace connection, and the card keeps its error and Fix card action.

QA Steps

Please treat this as a regression check around the card feature. Behaviour is the same on all platforms, so any one platform is fine.

  1. With a personal card whose connection broke recently (under 90 days), confirm it still surfaces as before: the task on Home, the red dot on the Account button, and the red dot on the Wallet row. This is unchanged and must keep working.
  2. With a personal card whose connection has been broken for 90 days or more, confirm the Home task, the Account red dot, and the Wallet row red dot are all gone — while the card is still listed on the Wallet page with its error and a working Fix card button.
  3. Confirm nothing else in the card feature regressed — adding/removing personal cards, company cards, and cards with other (non-connection) errors still show their red dots as before.

If a 90-days-broken connection isn't available as test data, the removal is also covered by automated unit tests; the essential manual checks are step 1 (recently-broken still prompts) and step 3 (no regressions).

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Kapture.2026-07-18.at.10.46.14.mp4

…race period

The 90-day grace correctly removed the home task, but the Account indicator and the
Wallet row red dot stayed lit for personal cards. Both are driven by the card's own
error, which the issue requires us to keep so the card stays fixable:

- getShouldShowRBR short-circuits on hasFeedErrors before the grace check, so a
  past-grace card's error still forced the RBR.
- hasPaymentMethodError counts any personal card with errors, and it feeds both the
  Account indicator (useAccountIndicatorChecks) and the Wallet row (InitialSettingsPage).

Gate both on isBrokenConnectionPastDismissThreshold, leaving card.errors untouched so
the Wallet-page card row keeps its error and Fix card action.

Adds regression tests for a past-grace card that still carries the error (the case the
original fixtures missed), plus guards that within-grace and unrelated errors still show.
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
...libs/actions/OnyxDerived/configs/cardFeedErrors.ts 97.80% <100.00%> (+2.29%) ⬆️
src/libs/actions/PaymentMethods.ts 34.18% <100.00%> (+1.70%) ⬆️
... and 50 files with indirect coverage changes

@wildan-m
wildan-m marked this pull request as ready for review July 18, 2026 03:51
@wildan-m
wildan-m requested review from a team as code owners July 18, 2026 03:51
@melvin-bot
melvin-bot Bot requested a review from jayeshmangwani July 18, 2026 03:51
@melvin-bot

melvin-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

@jayeshmangwani Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from joekaufmanexpensify and removed request for a team July 18, 2026 03:51

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac02fdb2c8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/actions/OnyxDerived/configs/cardFeedErrors.ts Outdated
Comment thread src/libs/actions/PaymentMethods.ts
@wildan-m

Copy link
Copy Markdown
Contributor Author

@jayeshmangwani @joekaufmanexpensify The follow up PR is ready

Addresses Codex review. The previous change dropped ALL of a stale-broken personal
card's errors/errorFields, so an unrelated actionable error also lost its RBR — a failed
reimbursable/start-date update (another errorFields entry) or a failed remove (card.errors).

Now only errorFields.lastScrape (the broken-connection error the card detail page reads)
is excluded from the RBR once past the grace period; every other error is kept. Reverts
the over-broad hasPaymentMethodError gate, since card.errors on a personal card is an
actionable error, not the connection error.

Adds regression tests for a past-grace card that also carries an unrelated field error
and one that carries a card-level error.
@jayeshmangwani

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c2ad732aa

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const isPastDismissThreshold = isBrokenConnectionPastDismissThreshold(card);
const errorFieldsForRBR =
isPastDismissThreshold && card.errorFields ? Object.fromEntries(Object.entries(card.errorFields).filter(([field]) => field !== 'lastScrape')) : card.errorFields;
const hasCardErrors = !isEmptyObject(card.errors) || !isEmptyObject(errorFieldsForRBR);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Suppress dismissed broken-connection card errors

For past-grace personal cards this only drops errorFields.lastScrape, but card.errors is still treated as RBR-worthy. In the Account indicator path, useAccountIndicatorChecks still calls hasPaymentMethodError(), which returns true for any personal card with card.errors, so a dismissed broken connection stored as a card-level error such as errors.brokenConnection will continue to light the Account dot and Wallet row even though isPastDismissThreshold is true. Please filter the broken-connection card-level error in the same past-grace case, while preserving unrelated card errors.

Useful? React with 👍 / 👎.

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.

Good catch, fixed in e50ca23235c.

You're right that the broken connection is also stored as a card.errors entry (server-set) — that's what keeps lighting the Account button via hasPaymentMethodError and the Wallet row, so dropping only errorFields.lastScrape wasn't enough. This also corrects my earlier note on the PaymentMethods thread: the connection error does land in card.errors, not just errorFields.

Past the grace period we now also skip a broken personal card's card.errors when deciding the RBR, in both the derived value and hasPaymentMethodError, so both surfaces clear. It's gated by isBrokenConnectionPastDismissThreshold at the card level rather than by an errors.brokenConnection key — there's no dedicated key, the error is server-set and uses the generic shape. Any actionable error kept in a separate errorFields entry (e.g. a failed reimbursable/start-date update) still surfaces. The one case this can't separate is a card that is both past-grace broken and carries a fresh card.errors failure (e.g. a failed remove); it's treated as the dismissed connection, but that's a rare/transient state and the error itself stays on the card either way.

Added regression tests for the Account indicator and the derived value.

A broken personal card's connection is also stored as a server-set card.errors
entry, which lit the Account button (via hasPaymentMethodError) and the Wallet row
even past the 90-day grace period. Gate it by isBrokenConnectionPastDismissThreshold
in both the derived value and hasPaymentMethodError, so those surfaces clear while
actionable errors kept in a separate errorFields entry (reimbursable/start-date)
still surface. Adds Account-indicator and derived-value regression tests.
@github-actions

Copy link
Copy Markdown
Contributor

🚧 joekaufmanexpensify has triggered a test Expensify/App build. You can view the workflow run here.

@github-actions

Copy link
Copy Markdown
Contributor

🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
Built from App PR #96364.

Android 🤖 iOS 🍎
⏩ SKIPPED ⏩ ⏩ SKIPPED ⏩
The build for Android was skipped The build for iOS was skipped
Web 🕸️
https://96364.pr-testing.expensify.com
Web

👀 View the workflow run that generated this build 👀

@jayeshmangwani

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e50ca23235

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return true;
// A broken personal-card connection is surfaced as a card error too, so once it is unresolved past the grace
// period we stop leading the user to it and it must no longer light the RBR (any other error still does).
return !CardUtils.isBrokenConnectionPastDismissThreshold(card);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unrelated personal-card errors past threshold

When a personal card is past the broken-connection threshold, this return ignores every root-level card.errors entry on that card, not just the stale connection error. Personal-card actions can create unrelated root-level errors—for example unassignCard failure writes errors into CARD_LIST, and the personal-card details menu can invoke that path—so a card that is also long-broken would no longer light the Account/Wallet indicators for a new actionable failure. Please distinguish the broken-connection error from other card.errors instead of suppressing the whole card.

Useful? React with 👍 / 👎.

@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

Tested, and the changes still aren't working. I still see the red dot indicator on the account button and the wallet row.
image

@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

Let me know if you need any more information from me to investigate further!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants