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 3 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 3 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%) ⬆️
... and 30 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".

// The broken connection is reported as an error on the card itself, so past the grace period it must not
// count towards the RBR either — otherwise keeping the error (so the card stays fixable) would keep the
// Account/Wallet red dot lit forever.
const hasCardErrors = (!isEmptyObject(card.errors) || !isEmptyObject(card.errorFields)) && !isPastDismissThreshold;

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 Don't suppress unrelated personal-card field errors

When a personal card is past the broken-connection dismiss threshold, this drops every card.errors/card.errorFields entry, not just the stale connection prompt. For example, a failed personal-card reimbursable or start-date update writes card.errorFields in src/libs/actions/Card.ts, and if the card also still has the old broken lastScrapeResult/lastScrape, personalCard.shouldShowRBR remains false, so Settings > Wallet no longer gets a red dot for that new actionable field error.

Useful? React with 👍 / 👎.

Comment thread src/libs/actions/PaymentMethods.ts Outdated
// Once a personal card's broken connection has been unresolved past the grace period we stop leading the user
// to it, so it must not light the Account/Wallet RBR. The error itself is kept on the card so it can still be
// fixed from the Wallet page.
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 Don't hide fresh card errors on stale broken cards

This returns false for any personal card with errors once its broken connection is past the dismiss threshold, even if those errors came from a different action. A failed remove/unassign restores the card with a generic card.errors while preserving the stale broken-connection fields, so the Account/Wallet indicators stop lighting for that new failure; the grace-period check should only suppress the broken-connection error itself.

Useful? React with 👍 / 👎.

@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.
@wildan-m

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 4c2ad732aa3: past the grace period we now suppress only the broken-connection error (errorFields.lastScrape), so any other error on the card (a failed reimbursable/start-date update, or a failed remove in card.errors) keeps its RBR; reverted the over-broad hasPaymentMethodError gate and added regression tests for both cases.

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.

1 participant