Skip to content

Multi program expensify card feeds#96070

Draft
heekinho wants to merge 18 commits into
Expensify:mainfrom
heekinho:multi-program-expensify-card-feeds
Draft

Multi program expensify card feeds#96070
heekinho wants to merge 18 commits into
Expensify:mainfrom
heekinho:multi-program-expensify-card-feeds

Conversation

@heekinho

Copy link
Copy Markdown
Contributor

Explanation of Change

A domain (or workspace) can be provisioned with more than one Expensify Card program at the same time — for example both the US and UK/EU (GB) programs. The backend stores them under a single private_expensifyCardSettings NVP keyed by program ({US: {...}, GB: {...}}) and buckets both programs' cards into one cards_{fundID}_Expensify Card Onyx key. Because the Expensify Card UI was keyed entirely by fundID, the two programs collapsed into one: the feed selector showed a single row, the card list mixed both programs, and currency/limit/settings resolved to whichever program came first (US), so a GB feed showed USD.

This PR makes the Expensify Card UI program-aware. The selected program (US/GB) is tracked per policy in a new lastSelectedExpensifyCardProgram Onyx key alongside the existing lastSelectedExpensifyCardFeed (fundID), so the fundID-keyed storage layer is untouched and single-program domains behave exactly as before.

Changes:

  • Foundation — new helpers in CardUtils (getConfiguredExpensifyCardProgramKeys, getProgramKeyForCard, isCardInProgram, filterCardsListByProgram, getExpensifyCardProgramCountrySuffix, getSelectableCardProgramKey, getCardSettingsForSelectedProgram), the lastSelectedExpensifyCardProgram Onyx key, and a useSelectedExpensifyCardProgram hook that resolves the stored program or the feed's first configured program.
  • Separate feed rowsgetAdminExpensifyCardFeedEntries emits one entry per configured program; the selector renders one row per program, with a country suffix on non-US rows (e.g. deptagency.com vs deptagency.com GB).
  • Per-program card list, currency, and settings — the card list filters cards_{fundID} by the selected program's feedCountry, and currency/settings/settlement resolve per program (fixing the "US feed shows a GBP limit" symptom and the settlement/settings pages that showed US values on a GB feed).
  • Card details — surfaces the card's country and resolves currency from the card's own feedCountry.
  • Issuing a card — the issue-card and edit-limit flows use the selected program's currency, and the card is issued into the selected program (feedCountry is now sent on both the physical CreateExpensifyCard and virtual CreateAdminIssuedVirtualCard commands).
  • Linking a feed to a policy — the selected program is passed to linkCardFeedToPolicy (previously defaulted to US, causing "404 Feed not found" when linking a GB feed), threaded through the work-email/verify detour via a new optional feedCountry route param.

The virtual-card issuance and linking rely on companion backend PRs (Web-Expensify + Auth) that route the card to the program named by feedCountry.

Fixed Issues

Part of https://github.com/Expensify/Expensify/issues/652047
PROPOSAL:

Tests

  1. Sign in as an admin of a domain provisioned with both a US and a GB Expensify Card program.
  2. Open Workspace → Expensify Card → open the feed selector; verify two rows appear for the domain: one unsuffixed (US, e.g. deptagency.com) and one suffixed GB (e.g. deptagency.com GB).
  3. Select the US row; verify the card list shows only US cards and the limit/available-spend render in USD.
  4. Select the GB row; verify the card list shows only GB cards and the limit/available-spend render in GBP/EUR.
  5. Open a GB card's details; verify the Country row shows the card's issuing country and the amounts render in the GB currency.
  6. From the GB feed, issue a card to a member; verify the currency shown through the issue flow (limit + confirmation) is the GB currency, and after issuing, the new card appears under the GB feed.
  7. From the GB feed, issue a card to a member who already has a US card on the same domain; verify it succeeds and lands under the GB feed (no "Cardholder not found" error).
  8. Open the Expensify Card feed selector, choose a GB feed listed under "From other workspaces", and link it to the current policy; verify the link succeeds (no "404 Feed not found").
  9. On a domain with only a single program, verify the selector shows one unsuffixed row and everything (cards, currency, issuing) behaves exactly as before.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline.
  2. Open Workspace → Expensify Card and switch between the US and GB feed rows; verify each shows its own cached cards and currency without errors.
  3. Open a card's details while offline; verify the Country and amounts render from cached data.

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • 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

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

heekinho added 11 commits July 14, 2026 10:38
A domain can be provisioned with more than one Expensify Card program (US and GB)
under a single fundID-keyed settings NVP and cards list. Add the helpers and the
selected-program Onyx key needed to distinguish them, with no behavior change yet:

- getConfiguredExpensifyCardProgramKeys, getProgramKeyForCard, isCardInProgram,
  filterCardsListByProgram, getExpensifyCardProgramCountrySuffix,
  getSelectableCardProgramKey, getCardSettingsForSelectedProgram
- LAST_SELECTED_EXPENSIFY_CARD_PROGRAM collection key + type
- updateSelectedExpensifyCardFeed accepts an optional programKey
Resolves the selected program (US/GB) for a policy's feed: the stored value when
present, otherwise the feed's first configured program so a GB-only feed shows its
cards on first load. Pairs with useDefaultFundID to identify which program to display.
getAdminExpensifyCardFeedEntries now emits one entry per configured program (each
tagged with its programKey) instead of one per fundID. The feed selector renders a
row per program, disambiguated by a country suffix on non-US rows (e.g. "deptagency.com"
vs "deptagency.com GB"), and persists the selected program alongside the feed.
A single cards_{fundID}_Expensify Card list holds both programs' cards, so filter it by
the selected program's feedCountry and resolve the currency, settings and header suffix
for that program. useCurrencyForExpensifyCard gains a programKey so it reads the selected
program's currency rather than the collapsed, US-first value.
Add a read-only Country row and resolve the card's currency from its own feedCountry, so
US and GB cards on the same feed show their correct country and currency.
The issue-card flow captured currency without a program, so a GB feed showed USD through
the limit and confirmation steps. Pass the selected program in AssigneeStep, and resolve
currency from the card's own program on the edit-limit and limit-type pages.
…llapse

Make programKey required on useCurrencyForExpensifyCard so a caller can no longer silently
fall back to the collapsed, US-first currency. Fixing the remaining callers surfaced that
the settlement label, Settings page and settlement account/frequency pages all resolved the
program via getCardProgramKey (US-first): they showed US settings on a GB feed, and the
settlement account/frequency edits wrote to the US program even when editing the GB feed.
Use the selected program for both display and the update actions.
Cover the new CardUtils helpers (program key resolution, card-to-program mapping, per-program
card filtering, configured-program detection) and the feed selector splitting a US+GB feed
into one entry per program.
The issue-card flow hardcoded feedCountry as '' or 'US', so selecting the GB feed still
issued into US. Pass the selected program's country to CreateExpensifyCard from both the
extended-access and magic-code paths.

This fixes routing for the physical-card command (CreateExpensifyCard). Virtual cards go
through CreateAdminIssuedVirtualCard, which does not yet carry feedCountry end to end
(App type omits it, Web-Expensify does not forward it, and the Auth command auto-detects
the program) - that routing is a separate backend follow-up.
CREATE_ADMIN_ISSUED_VIRTUAL_CARD omitted feedCountry, so on a domain with more than one program
(e.g. both US and GB) an admin-issued virtual card selected for the GB feed was created on the
default US program. Allow feedCountry on the command params and pass the selected program in the
virtual-card branch of issueExpensifyCard, matching the physical-card branch. The backend uses it
to route the card to the correct program (companion Web-Expensify and Auth PRs).
…licy

Linking an Expensify Card feed to a policy always sent feedCountry=US (linkCardFeedToPolicy's
default), so linking the GB feed of a multi-program domain failed with '404 Feed not found'. Pass
the selected program from the feed selector, and thread it through the work-email/verify detour via
a new optional feedCountry route param so the program survives email validation before linking.
heekinho added 2 commits July 14, 2026 11:30
ExportOnyxStateTest requires every ONYXKEYS collection key to be classified into an export bucket.
The new LAST_SELECTED_EXPENSIFY_CARD_PROGRAM key holds a non-sensitive selected-program value, so
list it under safeOnyxKeys next to LAST_SELECTED_EXPENSIFY_CARD_FEED.
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Coverage Δ
src/ONYXKEYS.ts 100.00% <ø> (ø)
src/libs/API/types.ts 100.00% <ø> (ø)
src/libs/ExpensifyCardFeedSelectorUtils.ts 92.30% <100.00%> (ø)
src/libs/ExportOnyxState/common.ts 80.35% <ø> (ø)
...ce/expensifyCard/DynamicExpensifyCardLimitPage.tsx 0.00% <0.00%> (ø)
...xpensifyCard/DynamicExpensifyCardLimitTypePage.tsx 0.00% <0.00%> (ø)
src/ROUTES.ts 21.02% <0.00%> (ø)
...sifyCard/DynamicWorkspaceSettlementAccountPage.tsx 0.00% <0.00%> (ø)
...kspace/expensifyCard/WorkspaceCardSettingsPage.tsx 0.00% <0.00%> (ø)
...ifyCard/WorkspaceExpensifyCardAddWorkEmailPage.tsx 0.00% <0.00%> (ø)
... and 14 more
... and 21 files with indirect coverage changes

heekinho added 5 commits July 14, 2026 11:46
The multi-program helper tests added several 'as unknown as' casts, pushing CardUtilsTest over the
eslint-seatbelt baseline for no-unsafe-type-assertion. Build the Card and ExpensifyCardSettings
mocks with createMock<T>() and set the WorkspaceCardsList cardList meta entry after construction, so
the tests add no net unsafe assertions.
When a domain is provisioned with more than one Expensify Card program
(e.g. both US and GB), each program's selector row now shows a
parenthetical qualifier so the rows are distinguishable:
- US shows (USD) — country omitted since USD is eligible in one country
- GB shows (COUNTRY - CURRENCY), e.g. (GB - GBP)
A single-program domain shows just the domain, and the workspace card
list header shows just the domain.

Currency is derived per program via the new getExpensifyCardProgramCurrency
helper (extracted from getCardOrFeedCurrency), so US rows show (USD) even
when the settings NVP carries no explicit currency.
A domain provisioned with both US and GB programs shares one settings NVP,
and isFeedPrimaryForPolicy read the merged linkedPolicyIDs across the root
and every program block. So linking a workspace to only the US program made
both the US and GB rows appear as available (primary) for that workspace.

Add getLinkedPolicyIDsForExpensifyCardProgram, which resolves the linked
workspaces for a single program (root-level links still apply, for
legacy/single-program feeds). isFeedPrimaryForPolicy now checks the entry's
own program, so an unlinked program stays under "From other workspaces".

@danieldoglas danieldoglas left a comment

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.

overall lgtm, we need more test steps here for all changes that were created.

e.g. - test the workemail/verify-work-email paths,

Comment thread src/libs/CardUtils.ts

/**
* A single settings NVP can hold more than one provisioned Expensify Card program (e.g. a domain with both a US and a GB feed).
* The user-selectable programs are US and GB; CURRENT is deprecated and TRAVEL_US is backend-managed, so neither is offered as its own row.

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.

Suggested change
* The user-selectable programs are US and GB; CURRENT is deprecated and TRAVEL_US is backend-managed, so neither is offered as its own row.
* The user-selectable programs are US and GB.

Comment thread src/libs/CardUtils.ts
return card?.nameValuePairs?.feedCountry === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : CONST.COUNTRY.US;
}

/** Whether a card belongs to the given program, so a single `cards_{fundID}_Expensify Card` list can be split per program. */

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.

fix the name of the key here and in other ocurrences

Comment on lines +131 to +133
// Surface the card's issuing country so cards on different programs (e.g. US vs GB) of the same feed are distinguishable.
const cardCountryCode = card?.nameValuePairs?.country;
const cardCountryName = cardCountryCode ? (CONST.ALL_COUNTRIES as Record<string, string>)[cardCountryCode] : undefined;

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.

Is this really needed? We're filtering by feeds already, so we shouldn't see the same card for different feeds in the same page.

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