Skip to content

[HOLD] Show balance and remaining limit for Plaid company card feeds - #94096

Open
ishpaul777 wants to merge 13 commits into
Expensify:mainfrom
ishpaul777:ishpaul777/93198-company-card-balance-frontend
Open

[HOLD] Show balance and remaining limit for Plaid company card feeds#94096
ishpaul777 wants to merge 13 commits into
Expensify:mainfrom
ishpaul777:ishpaul777/93198-company-card-balance-frontend

Conversation

@ishpaul777

@ishpaul777 ishpaul777 commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Displays a balance block (Current balance + Remaining limit) above the company cards table for Plaid-connected (direct) feeds, mirroring the pattern on the Expensify Card page.

  • Adds currentBalance, remainingLimit, and balanceTimestamp (all in cents / datetime) to the company card feed type (CustomCardFeedData). These are populated by the backend from Plaid (see backend PR below).
  • New WorkspaceCompanyCardsBalanceLabels renders the two stats above the table; each stat (WorkspaceCompanyCardsBalanceLabel) has an info tooltip showing the last-updated timestamp reported by the bank.
  • Display rules, per the issue:
    • Non-Plaid feed -> block hidden (gated on isDirectFeed).
    • Plaid feed but no balance data -> block hidden.
    • Partial data (e.g. no remaining limit) -> that field shows "Not available".
    • Full data -> both values shown.
  • No cash back item for company cards (confirmed in the issue; Plaid does not provide it).

Depends on backend PR: https://github.com/Expensify/Web-Expensify/pull/53882 — that PR fetches the balance from Plaid and stores it on the feed (in cents). This PR is display-only and is safe to merge after it.

Note: amounts are displayed in the policy's output currency (fallback USD), consistent with the rest of the company cards page.

Fixed Issues

$ #93198
PROPOSAL: N/A (internal)

Tests

  1. As a workspace admin, open Workspace > Company cards and select a Plaid-connected feed.
  2. Verify a balance block appears above the table showing Current balance and Remaining limit.
  3. Tap the ⓘ next to each — verify a tooltip shows the description and the last-updated timestamp.
  4. Select a non-Plaid feed (commercial/CSV) — verify the balance block is hidden.
  5. For a feed where the bank reports no limit, verify Remaining limit shows "Not available".
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline and open the company cards page for a Plaid feed.
  2. Verify the previously loaded balance block still renders from cached Onyx data.

QA Steps

  1. Same as Tests above, on a staging account with a Plaid-connected company card feed.
  • 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 any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • 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 any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • 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.
  • I verified that similar component doesn't exist in the codebase
  • I verified that all props are defined accurately and each prop has a /** comment above it */
  • I verified that each file is named correctly
  • I verified that each component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
  • I verified that the only data being stored in component state is data necessary for rendering and nothing else
  • In component if we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
  • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
  • I verified that component internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
  • I verified that all JSX used for rendering exists in the render method
  • I verified that each component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions

Screenshots/Videos

Screenshot 2026-07-07 at 9 20 45 PM Screenshot 2026-07-07 at 9 21 13 PM Screenshot 2026-07-07 at 9 21 51 PM Screenshot 2026-07-07 at 9 22 38 PM

Display a balance block (current balance + remaining limit) above the
company cards table for Plaid-connected feeds, mirroring the Expensify
Card page. Each stat has an info tooltip with the last-updated timestamp
reported by the bank. The block is hidden for non-Plaid feeds and when no
balance data is available, and shows "Not available" for a field the bank
did not report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ishpaul777
ishpaul777 requested review from a team as code owners June 19, 2026 21:38
@melvin-bot

melvin-bot Bot commented Jun 19, 2026

Copy link
Copy Markdown

Hey, I noticed you changed src/languages/en.ts in a PR from a fork. For security reasons, translations are not generated automatically for PRs from forks.

If you want to automatically generate translations for other locales, an Expensify employee will have to:

  1. Look at the code and make sure there are no malicious changes.
  2. Run the Generate static translations GitHub workflow. If you have write access and the K2 extension, you can simply click: [this button]

Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running:

npx ts-node ./scripts/generateTranslations.ts --help

Typically, you'd want to translate only what you changed by running npx ts-node ./scripts/generateTranslations.ts --compare-ref main

@melvin-bot
melvin-bot Bot requested review from truph01 and removed request for a team June 19, 2026 21:38
@melvin-bot

melvin-bot Bot commented Jun 19, 2026

Copy link
Copy Markdown

@truph01 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 trjExpensify and removed request for a team June 19, 2026 21:38
@ishpaul777
ishpaul777 marked this pull request as draft June 19, 2026 21:39
@ishpaul777

Copy link
Copy Markdown
Contributor Author

draft WIP for now will open soon

ishpaul777 and others added 2 commits June 20, 2026 03:10
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/pages/workspace/companyCards/WorkspaceCompanyCardsBalanceLabel.tsx Outdated
<Text style={styles.shortTermsHeadline}>{displayValue}</Text>
<Popover
onClose={() => setVisible(false)}
isVisible={isVisible}

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.

❌ UI-3 (docs)

innerContainerStyle={!shouldUseNarrowLayout ? {maxWidth: variables.modalContentMaxWidth} : undefined} passes an inline style object literal to a *Style prop. {maxWidth: variables.modalContentMaxWidth} is a fully static value that belongs in the shared style system; the inline literal creates a new object every render and bypasses the styles helpers.

Add a named style (e.g. styles.popoverMaxWidth returning {maxWidth: variables.modalContentMaxWidth}) in the style sheet and reference it: innerContainerStyle={!shouldUseNarrowLayout ? styles.popoverMaxWidth : undefined}.


Reviewed at: a0a6c8a | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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.

Done — replaced the inline {maxWidth: variables.modalContentMaxWidth} with a named styles.cardLabelTooltipContainer in the shared component.

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Coverage Δ
src/styles/index.ts 60.30% <ø> (ø)
...onents/Tables/WorkspaceCompanyCardsTable/index.tsx 0.00% <0.00%> (ø)
...companyCards/WorkspaceCompanyCardsBalanceLabel.tsx 0.00% <0.00%> (ø)
...ompanyCards/WorkspaceCompanyCardsBalanceLabels.tsx 0.00% <0.00%> (ø)
...orkspace/expensifyCard/WorkspaceCardsListLabel.tsx 0.00% <0.00%> (ø)
src/components/WorkspaceCardLabel.tsx 0.00% <0.00%> (ø)
... and 25 files with indirect coverage changes

@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: 3151435db5

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}, [isVisible, windowWidth]);

const displayValue = value === undefined ? translate('workspace.companyCards.balance.notAvailable') : convertToDisplayString(value, currency);
const formattedLastUpdated = lastUpdated ? format(new Date(lastUpdated.replace(' ', 'T')), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING) : undefined;

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 Use UTC-aware formatting for balance timestamps

When the backend sends balanceTimestamp in the same DB format used elsewhere (yyyy-MM-dd HH:mm:ss, serialized as UTC without a Z), this parses it as a local device time because the replacement does not add a timezone. In non-UTC timezones the tooltip will show a shifted “last updated” time compared with existing company-card timestamps that go through getLocalDateFromDatetime, so admins can see incorrect balance freshness.

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.

Fixed — switched to getLocalDateFromDatetime, which treats the backend datetime as UTC (it appends Z) and converts to the user's selected timezone, so the tooltip no longer shows a shifted last-updated time.

@trjExpensify
trjExpensify requested review from joekaufmanexpensify and removed request for trjExpensify June 22, 2026 21:42
@trjExpensify

Copy link
Copy Markdown
Contributor

Switching @joekaufmanexpensify on for this one.

Move the duplicated header, value, anchor-positioning and info tooltip
out of WorkspaceCardsListLabel into a reusable WorkspaceCardLabel, and
replace the inline popover max-width with a named style.
Render the company card balance stats via WorkspaceCardLabel and format
the last-updated time with getLocalDateFromDatetime so it honors the
user's locale and timezone.
Shrink labels to content width and align with feed header using ph5/mt2, with gap24 spacing between them.
@ishpaul777

Copy link
Copy Markdown
Contributor Author

@MelvinBot fix conflicts

@shawnborton

Copy link
Copy Markdown
Contributor

Let me know if you have videos/screenshots for us to review!

@abzokhattab

Copy link
Copy Markdown
Contributor

looks like we have conflicts .. lets also cover the screenshots

…mpany-card-balance-frontend

# Conflicts:
#	src/components/Tables/WorkspaceCompanyCardsTable/index.tsx
@ishpaul777

Copy link
Copy Markdown
Contributor Author

resolved conflicts and screenshots in PR description.@shawnborton @abzokhattab

@joekaufmanexpensify you might want to test this with a real connection in adhoc build

@shawnborton

Copy link
Copy Markdown
Contributor

I think we need more space below the balances and above the search input. From the mocks:
CleanShot 2026-07-07 at 11 56 10@2x

@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

@joekaufmanexpensify you might want to test this with a real connection in adhoc build

Yep, I will 👍

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

This comment has been minimized.

@ishpaul777

Copy link
Copy Markdown
Contributor Author

I think we need more space below the balances and above the search input. From the mocks:

@shawnborton increased margin bottom to 24px

Screenshot 2026-07-07 at 10 04 53 PM

@shawnborton

Copy link
Copy Markdown
Contributor

that looks better, thanks!

@abzokhattab

abzokhattab commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

reviewing today from my side as well

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 8, 2026

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 #94096.

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

👀 View the workflow run that generated this build 👀

@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

I connected my own Amex card to test this PR. I don't see the balance block. I investigated with Claude, and it seems we're not running the command that shows the balance block when the feed is initially added. It said that showing/updating the balance is tied to the ScrapeCards command, which doesn't run when a feed is added or card is initially assigned.

I know we initially discussed doing that in the GitHub issue, but I wonder now if that's actually the design we want. With this current design, my understanding is we wouldn't show the balance block until around 24 hours after the first card is assigned. If no cards are assigned initially, we wouldn't show the balance block at all. Neither seems ideal to me as the balance for the feed is independent of card assignment, we can get it from Plaid at any time.

Would it be challenging to decouple the balance update from ScrapeCards? Then, we could run it when the feed is initially added and thereafter 1-4 times daily. Thoughts?

image

@abzokhattab

Copy link
Copy Markdown
Contributor

backend is down for few hours couldnt test this, will take a look tomorrow https://expensify.slack.com/archives/C01GTK53T8Q/p1783539221593109

@abzokhattab

Copy link
Copy Markdown
Contributor

looks like we have conflicts @ishpaul777

…mpany-card-balance-frontend

# Conflicts:
#	src/components/Tables/WorkspaceCompanyCardsTable/index.tsx
@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

I retested this and verified that we were able to eventually pull the balance and limit.

One interesting note. My Amex credit card doesn't have a set credit limit. Despite that, it seems like Amex returned $0, even though the remaining limit isn't actually $0, which is confusing. Is there any additional signal we get to know that there's no set remaining limit? I think it would be a lot clearer to display NA in this scenario, if so. If not, I think it's fine as a known limitation rather than hard-coding it or something like that, but figured I'd ask.

We did correctly pull the remaining limit for my other credit card, which has a set limit.

image image

@ishpaul777

Copy link
Copy Markdown
Contributor Author

When Amex is a "no preset limit" card - there's no fixed ceiling, so the bank doesn't send us a limit at all. In that case Amex reports the leftover amount as 0, and we were taking that literally and showing $0, which is misleading.

The signal we can rely on is exactly that: if the bank doesn't give us a limit for the card, there's no meaningful "remaining limit" to show. So I've updated the backend to show "Not available" in that situation instead of $0. Cards that do have a real limit (like your other credit card) are unaffected and still show the correct remaining amount - and a card that's genuinely maxed out will still correctly show $0. This is a backend change [PR here] it should not block this PR

@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

Nice, thanks. That sounds like a great fix. What do you think about this comment @ishpaul777?

@ishpaul777

ishpaul777 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Nice, thanks. That sounds like a great fix. What do you think about #94096 (comment) @ishpaul777?

Sorry for missing this comment earlier - I wasn't sure about the exact code path at the time, but I've dug into it now.

You're right on all counts, and I agree we should decouple this. Here's the approach I'm landing on:

Today the balance only refreshes inside the card scrape, and only for feeds that have an assigned card being scraped. So with no cards assigned the block never shows, and even with cards it only appears after the first scrape (up to ~24h later). That's not the behavior we want.
The good news is the function that fetches and stores the balance is already standalone (it just needs the feed, not any card), so we can decouple it cleanly:

  • Refresh the balance the moment a Plaid feed is connected, so it shows up right away regardless of card assignment.
  • Set up a self-repeating job per feed that refreshes it a few times a day (independent of scraping), so it stays fresh. This mirrors how we already keep the Expensify Card domain limit up to date.

One catch worth calling out: this "create the job on connect" logic only covers feeds connected after it ships. Feeds that are already connected today (like yours) won't have a job yet, so we'll need a one-time backfill to create the recurring job for every existing feed. I am not quite sure on how we do it in general i'll bring this slack for engineering help.

@joekaufmanexpensify

@joekaufmanexpensify

Copy link
Copy Markdown
Contributor

Sounds great. Thanks for bringing this up in Slack!

@abzokhattab

Copy link
Copy Markdown
Contributor

no data in my case as well the balance block never renders since no scrape has run yet. +1 on decoupling the balance refresh from ScrapeCards so it populates right after the feed connects, regardless of card assignment ... should i wait for the refresh to abel to test?

image

@abzokhattab

Copy link
Copy Markdown
Contributor

@ishpaul777 gentle reminder on the above

@ishpaul777

Copy link
Copy Markdown
Contributor Author

Thanks! its on my radar working on PR here https://github.com/Expensify/Web-Expensify/pull/54603

@abzokhattab

Copy link
Copy Markdown
Contributor

gentle reminder on this one @ishpaul777

@ishpaul777

Copy link
Copy Markdown
Contributor Author

gentle reminder on this one @ishpaul777

Backend PR was stalled, i pinged reviewer again putting this on HOLD for now

@ishpaul777 ishpaul777 changed the title Show balance and remaining limit for Plaid company card feeds [HOLD] Show balance and remaining limit for Plaid company card feeds Aug 3, 2026
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.

6 participants