[Internal QA] Fix Account and Wallet RBR still showing for personal cards past the 90-day grace period - #96364
Conversation
…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 Report✅ Changes either increased or maintained existing code coverage, great job!
|
…al-card-rbr-followup
|
@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] |
There was a problem hiding this comment.
💡 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".
|
@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.
|
@codex review |
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
|
🚧 joekaufmanexpensify has triggered a test Expensify/App build. You can view the workflow run here. |
This comment has been minimized.
This comment has been minimized.
|
@codex review |
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Acknowledged as a deliberate trade-off, and called out in the PR description.
card.errors isn't separable per-entry here: the server re-stamps the connection error into card.errors with a fresh microsecond key on every OpenApp (confirmed from the reporter's real account data), so neither the key nor its age distinguishes it from a client-written failure like unassignCard. Matching on the message text would be worse.
The failure isn't invisible though — the card's own row in the Wallet list keeps its red dot (that path is ungated), so only the account-level indicators are held back. Errors written to a separate errorFields entry are unaffected, and as of cebad6d that now includes a failed manual sync.
|
Let me know if you need any more information from me to investigate further! |
|
@joekaufmanexpensify , This PR is working for me locally for personal cards. Just following up on the two points below:
|
This isn't why. There is a separate error on their subscription page. Also, each of these four cards in the wallet page has a broken connection error.
Confirmed, I just checked again. Each of these four personal cards has been broken since 2024 at the latest. |
|
@wildan-m Could you please take a look at these comments #96364 (comment) and #96364 (comment) ? |
|
checking.. @joekaufmanexpensify Is it possible that I'm logging in using your test account which contains that error data? Alternatively, please let me know a more precise method to perform e2e testing aside from the aforementioned script injection |
… runs Personal cards store lastScrape as ISO 8601 (2024-01-01T00:00:00Z), which the strict yyyy-MM-dd HH:mm:ss parse rejected -> NaN -> isBrokenConnectionPastDismissThreshold returned false, so the grace gate never fired and the Account/Wallet RBR persisted past 90 days (company cards use the DB format, so their Home task cleared correctly). Fall back to new Date() for ISO. Adds regression tests for ISO (with/without Z) and an unparseable value.
|
@jayeshmangwani could you try approving again to see if this routes to someone else internally now? |
|
@joekaufmanexpensify, approving doesn't trigger the review. I think the issue is that I’m currently OOO, so I’m not in the GitHub C+ auto-assignment team. Because of that, my approval isn’t triggering the internal engineer review. |
|
I see. At this point, I think it's fine if the review waits until Monday when Maria is back, given her context. Going to assign her since she reviewed the other PR 👍 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0b71530aee
ℹ️ 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 errorFieldsForRBR = | ||
| isPastDismissThreshold && card.errorFields ? Object.fromEntries(Object.entries(card.errorFields).filter(([field]) => field !== 'lastScrape')) : card.errorFields; |
There was a problem hiding this comment.
Preserve fresh manual-sync failures past the threshold
When a past-threshold personal card's Update card request fails, syncCard() writes a fresh generic failure to errorFields.lastScrape (src/libs/actions/Card.ts lines 374–387), but this filter removes every lastScrape field error solely because the previous successful scrape is old. Consequently, after leaving the card details page, the Wallet row has no RBR for that newly failed action. Fresh evidence beyond the earlier field-error report is that lastScrape is also the failure field for a user-initiated sync, rather than exclusively the stale server connection error, so the filter needs to distinguish those cases.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@wildan-m @jayeshmangwani what do you both think of this? Is it possible to land in this scenario where a newly failed user-initiated sync won't show the RBR?
There was a problem hiding this comment.
Confirmed, and fixed in cebad6d. errorFields.lastScrape is only ever written when a user-initiated action fails (syncCard and updatePersonalCardConnection both write it in their failureData) — the stale server connection error lives in card.errors, which is gated separately — so filtering it suppressed the wrong thing. Dropped the filter, so a freshly failed sync keeps its RBR while the dismissed connection stays quiet; both cases now have tests.
|
@wildan-m could you also address the review comments regarding code comment style? |
errorFields.lastScrape is only ever written when a user-initiated action fails:
syncCard ('Update card') and updatePersonalCardConnection ('Fix card') both write
it in their failureData. It is never the stale server connection error, which lives
in card.errors and is gated separately. Filtering it therefore suppressed the wrong
thing: a sync the user had just triggered could fail and leave no RBR once they
navigated away from the card, while the stale error it was meant to hide was never
in that field. Drop the filter and cover both cases with tests.
|
@MariaHCD bot feedbacks sorted |
|
🚧 MariaHCD has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
Nice, thanks everyone! |
|
🚀 Deployed to staging by https://github.com/MariaHCD in version: 9.4.56-0 🚀
|
Help site docs — changes required ✅I reviewed this PR and yes, a help site update is warranted. This PR changes how broken card-connection indicators behave once a card/feed has gone 90 days or more without a successful sync — the proactive reminders (the Time Sensitive task on Home, and the RBR red dots on the Wallet/workspace rows) stop showing, while the error stays on the card/feed so it remains fixable. Two published articles document those exact indicators as the way to detect a broken connection, but don't mention that they stop appearing after ~90 days:
I opened a draft docs PR that adds a short clarifying note plus an FAQ entry to each article (additive only — no existing labels/steps changed): ➡️ #98930 Two notes:
@wildan-m, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR |



Explanation of Change
Follow-up to #93523. That PR added the 90-day grace period and correctly removed the Home task, but both halves of the issue's requirement were still wrong on production. @joekaufmanexpensify reported each one:
Part 1 — personal cards: the dots never turned off. (report) The
Account/Walletdots only ask "does this card have an error?" — viahasPaymentMethodError()and the derivedshouldShowRBR— and we deliberately keep that error so the card stays fixable, so they need the grace check applied explicitly. Two things then stopped that check from ever firing:isBrokenConnectionPastDismissThreshold()parsedcard.lastScrapewith the strict Expensify DB format (yyyy-MM-dd HH:mm:ss) only, so an ISO 8601 value (2024-01-01T00:00:00Z) →NaN→ never past-grace.isCardConnectionBroken(), but the server sets the connection error oncard.errorseven whenlastScrapeResultis one of the ignored statuses (e.g.434). Those cards are "not broken" per that check, so they could never be dismissed — their error kept the dots lit forever. (That account's cards were status434, last successful sync ~700 days ago, plus438ones the existing check did cover.)Fix: key the dismissal on how long the card has gone without a successful sync via a new
isLastScrapePastDismissThreshold()(DB format, then ISO fallback), and gate the personal-card error paths on it. The Home task keeps the narrow broken-connection check, andcard.errorsis left untouched on the card, so it keeps its red dot in theWalletlist and — for a genuine broken status like403— its details page still shows "Your card connection is broken." with a working Fix card button. (For an ignored status like434that prompt never rendered in the first place, since the details page gates it onisCardConnectionBroken; the dot is the surviving signal.)Part 2 — company cards: the error itself disappeared, so the feed became unfixable. (report) #93523 also ANDed the 90-day check into the company path, zeroing
cardFeedErrors[feed].isFeedConnectionBroken. That same flag renders the Company cards page's "Card feed connection is broken — log into your bank" banner, which is the only labelled entry point into the reconnect flow from that page. It also gatesupdateBrokenConnection(), the code that clears the error after a successful reconnect. So past 90 days the feed could no longer be fixed at all, whileSettings > Walletstill told the user to go fix it there.Fix: that flag was doing two jobs — "is this feed broken?" (the banner and the reconnect, which must keep working so the feed stays fixable) and "should we still prompt about it?" (the RBR dots, the Home task, assign-blocking, and per-row error suppression). Only the first should survive the grace period, so they're split:
isFeedConnectionBrokenstays truthful, and a newshouldPromptBrokenConnectiondrives everything in the second group.So past the grace period assigning is no longer blocked and per-row card errors are no longer suppressed. Both are deliberate: the flag is OR'd across the whole feed, so one long-dead card would otherwise disable assigning on every row forever — and a commercial/CSV feed can't be reconnected by a bank login at all (
RefreshCardFeedConnectionPage404s for non-direct feeds), so it would never resolve.getShouldShowBrokenConnectionError→ Company cards bannerisFeedConnectionBrokenuseUpdateFeedBrokenConnection→ reconnect clears the errorcardsWithBrokenFeedConnectiongetShouldShowRBR→ all RBR dotsshouldPromptBrokenConnectionuseBrokenDirectCompanyCardFeedsForAdmin→ Home taskuseAssignCard→ Assign buttonshouldPromptBrokenConnectionWorkspaceCompanyCardsTable→ per-row card errorsshouldPromptBrokenConnectionNet behaviour past 90 days: no Home task and no navigational RBR dots (
Accountbutton,Walletrow,Workspacesrow) — but the card keeps its error on theWalletpage, and a broken direct company feed keeps its "log into your bank" banner (including the red dot that is part of that banner) plus a working reconnect. Within 90 days of the last successful sync nothing changes — note the gate deliberately measures time-since-last-successful-sync rather than time-since-broken, because the server marks434-status cards with the connection error whileisCardConnectionBrokenreports them healthy. New unit tests cover the ignored-status (434) case, both date formats, within/past grace, the company-feed banner-vs-RBR split, and that assigning stays possible on a dismissed feed. (The per-row error suppression is a rendering-level prop, so it's covered by the QA steps rather than a unit test.)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 shows on a personal card (added on
Account > Wallet,fundIDabsent or'0') that carries the server-set connection error incard.errorswhile the 90-day gate can't fire for it. The production case:lastScrapeResult: 434— an "ignored" status thatisCardConnectionBrokentreats as not broken, so the old gate (which required a broken connection) could never dismiss it even with the last successful sync ~700 days ago. An ISO-formatlastScrapefailing the old strict date parse triggers the same outcome.The script below has two modes:
MODE = 'inspect'(default, read-only — safe on any account, including a real one): prints every card already on the account (itslastScrape+ detectedformat, whether it'sbroken,daysBroken, andpastGrace) plus the derivedcardFeedErrors. Use this to see real cards' data and whether the gate fired.MODE = 'simulate'(dev/adhoc accounts only): injects synthetic broken personal cards so you don't have to wait 90 days for real data.MODE = 'reset'removes them.MODE = 'inspect'and read the printed JSON. On an account with long-broken personal cards (any scrape status), confirm the 90+ day ones showpastGrace: trueand the derivedpersonalCard.shouldShowRBRisfalse(the fix) — while each card is still listed on theWalletpage with its red dot.MODE = 'simulate',DAYS_AGO = 1— confirm the Account button dot and the Wallet row dot both show (recently broken, unchanged behaviour).DAYS_AGO = 100and re-run — confirm the Account dot and the Wallet row dot are gone, and the log showspersonalCard.shouldShowRBR: false. This is the production case: status434cards are "not broken" perisCardConnectionBroken, so before this fix they could never be dismissed.Walletpage: still listed with their own red dot. WithSTATUS = 403(a genuine broken status) opening one also still shows "Your card connection is broken." with a working Fix card button — that prompt readserrorFields.lastScrape, so it never appears for the434case.STATUS = 403(a plain broken status — also verifies the Home task appears atDAYS_AGO = 1and disappears at100), and withFORMAT = 'iso'(the ISO date shape behaves the same). Re-run withMODE = 'reset'to remove the simulated cards.Past grace, the derived
personalCardshould read:personalCard.shouldShowRBRdrives theWalletrow dot; theAccountbutton dot is computed separately byhasPaymentMethodError(), which readscard.errorsdirectly — this PR gates both on the same 90-day check.isFeedConnectionBrokendrives the Home task. Before the fix a status-434card fails theisCardConnectionBrokenprecondition, so the 90-day gate never runs and its error keepsshouldShowRBRstuck ontrue— exactly what the production account showed.Part 2 — company cards (the broken feed must stay fixable)
You need a workspace where you're an admin with company cards enabled. Paste this in the console on a dev build to give it a direct (OAuth) feed whose last successful sync is ~5 years ago — the reported production shape. Replace
DOMAINwith the workspace'spolicyAccountID((await Onyx.get('policy_<policyID>'))?.policyAccountID):DAYS_AGO = 1900, openWorkspaces > [workspace] > Company cards. Confirm the "Card feed connection is broken. Please log into your bank…" banner shows and its link opens the reconnect flow — this is the fix; before it, the banner was gone and the feed could not be fixed at all.Workspaceslist, and no time-sensitive task on Home — prompting stays off past 90 days.DAYS_AGO = 1and confirm the baseline is unchanged: the Home task "Fix … company card connection" and the workspace red dot both come back, alongside the banner.{"isFeedConnectionBroken":true,"shouldPromptBrokenConnection":false,"shouldShowRBR":false}; within grace all three aretrue.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 its Fix card action where that prompt applies).
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.
Walletpage with its red dot, and a genuinely broken card (e.g. status403) still offers Fix card on its details page.Company cardspage.Company cardspage still shows the "Card feed connection is broken — log into your bank" banner and reconnecting still clears the error. Also confirm the Assign button still works on that feed. (Commercial/CSV feeds never show this banner — that's pre-existing behaviour, not a regression.)card.errorsaren't separable per-error, so once a personal card hasn't synced in 90+ days none of them light theAccount/Walletdots (othererrorFieldsentries, e.g. a failed reimbursable update, still do).If a 90-days-broken connection isn't available as test data, the behaviour is also covered by automated unit tests; the essential manual checks are steps 1 and 3 (recently-broken still prompts) and step 5 (no regressions).
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Kapture.2026-07-18.at.10.46.14.mp4