-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Multi program expensify card feeds #96070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
heekinho
wants to merge
20
commits into
Expensify:main
Choose a base branch
from
heekinho:multi-program-expensify-card-feeds
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
d346680
Add multi-program Expensify Card foundation to CardUtils and Onyx
heekinho 9061602
Add useSelectedExpensifyCardProgram hook
heekinho f403835
Show one feed row per program when a domain has multiple programs
heekinho 42da5d2
Filter cards and currency by selected program on the card list
heekinho 2fb883a
Show country and per-card currency on the card details page
heekinho a2ec545
Use the correct program currency in the issue-card and edit-limit flows
heekinho 1f8511d
Require a program for currency and fix Settings/settlement program co…
heekinho 49b1fd0
Add unit tests for multi-program helpers and feed splitting
heekinho 0a1f6fa
Send the selected program as feedCountry when issuing a card
heekinho c62114f
Issue virtual cards into the selected program
heekinho e722f31
Pass the selected program when linking an Expensify Card feed to a po…
heekinho d656ef3
Add lastSelectedExpensifyCardProgram to Onyx export safe keys
heekinho 7bbc69e
Adjust comments
heekinho e62f03e
Avoid new unsafe type assertions in multi-program CardUtils tests
heekinho cb908ab
Merge branch 'main' into multi-program-expensify-card-feeds
heekinho 116aff1
Merge branch 'main' into multi-program-expensify-card-feeds
heekinho a551f89
Show currency/country qualifier on multi-program feed rows
heekinho 7cf9246
Partition multi-program feeds by per-program linked workspaces
heekinho 3d02eae
Merge branch 'main' into multi-program-expensify-card-feeds
heekinho 7c406c5
Drop country from the multi-program feed label; keep only currency
heekinho File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import type {CardProgramKey} from '@libs/CardUtils'; | ||
| import {getConfiguredExpensifyCardProgramKeys, getSelectableCardProgramKey} from '@libs/CardUtils'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
|
|
||
| import useOnyx from './useOnyx'; | ||
|
|
||
| /** | ||
| * Hook to get the currently selected Expensify Card program (US/GB) for a given policyID and feed (fundID). | ||
| * A single feed holds both programs' cards in one Onyx list, so this pairs with `useDefaultFundID` to identify | ||
| * which program's cards/currency/settings to display. | ||
| * | ||
| * When nothing is stored yet, it falls back to the feed's first configured program (US before GB) so a feed that only | ||
| * has a GB program still shows its cards on first load, while a US (or single-program) feed keeps behaving exactly as before. | ||
| */ | ||
| function useSelectedExpensifyCardProgram(policyID: string | undefined, fundID: number | undefined): CardProgramKey { | ||
| const [lastSelectedExpensifyCardProgram] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_PROGRAM}${policyID}`); | ||
| const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`); | ||
|
|
||
| if (lastSelectedExpensifyCardProgram) { | ||
| return getSelectableCardProgramKey(lastSelectedExpensifyCardProgram); | ||
| } | ||
|
|
||
| return getConfiguredExpensifyCardProgramKeys(cardSettings).at(0) ?? CONST.COUNTRY.US; | ||
| } | ||
|
|
||
| export default useSelectedExpensifyCardProgram; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1403,6 +1403,104 @@ function getCardProgramKey(cardSettings: OnyxEntry<ExpensifyCardSettings>): Card | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| * Returns the configured program keys in display order (US before GB). | ||
| */ | ||
| function getConfiguredExpensifyCardProgramKeys(cardSettings: OnyxEntry<ExpensifyCardSettings>): CardProgramKey[] { | ||
| if (!cardSettings) { | ||
| return []; | ||
| } | ||
|
|
||
| const selectableProgramKeys: CardProgramKey[] = [CONST.COUNTRY.US, CONST.COUNTRY.GB]; | ||
| return selectableProgramKeys.filter((key) => { | ||
| const nested = cardSettings[key]; | ||
| return nested != null && typeof nested === 'object' && !Array.isArray(nested) && nested.paymentBankAccountID != null; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Maps a card to the program it belongs to. Cards on the UK/EU program carry `feedCountry === 'GB'`; every other card | ||
| * (US program, or pre-2024 cards that omit `feedCountry`/use the deprecated `CURRENT` value) is treated as US. | ||
| */ | ||
| function getProgramKeyForCard(card: Card | undefined): CardProgramKey { | ||
| 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. */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fix the name of the key here and in other ocurrences |
||
| function isCardInProgram(card: Card | undefined, programKey: CardProgramKey): boolean { | ||
| return getProgramKeyForCard(card) === programKey; | ||
| } | ||
|
|
||
| /** | ||
| * A single `cards_{fundID}_Expensify Card` Onyx list holds every card for the feed regardless of program (the backend keys | ||
| * Expensify Card settings/cards by fundID, not by country). This keeps only the cards belonging to `programKey`, preserving the | ||
| * `cardList` meta entry. When no program is given, the list is returned unchanged. | ||
| */ | ||
| function filterCardsListByProgram(cardsList: WorkspaceCardsList | undefined, programKey: CardProgramKey | undefined): WorkspaceCardsList | undefined { | ||
| if (!cardsList || !programKey) { | ||
| return cardsList; | ||
| } | ||
|
|
||
| const result: WorkspaceCardsList = {}; | ||
| if (cardsList.cardList) { | ||
| result.cardList = cardsList.cardList; | ||
| } | ||
| forEachAssignedCard(cardsList, (card) => { | ||
| if (!isCardInProgram(card, programKey)) { | ||
| return; | ||
| } | ||
| result[card.cardID.toString()] = card; | ||
| }); | ||
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the settlement currency for an Expensify Card program. Prefers an explicit `currency` (from the card or the | ||
| * program's settings NVP), then falls back to the program itself: the US (and deprecated CURRENT) program is always USD, | ||
| * the GB program is GBP for the UK/Gibraltar/unset country and EUR elsewhere. Defaults to USD when the program is unknown. | ||
| */ | ||
| function getExpensifyCardProgramCurrency(programKey: CardProgramKey | undefined, country: string | undefined, explicitCurrency: string | undefined): string { | ||
| if (explicitCurrency) { | ||
| return explicitCurrency; | ||
| } | ||
|
|
||
| if (programKey === CONST.COUNTRY.US || programKey === CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) { | ||
| return CONST.CURRENCY.USD; | ||
| } | ||
|
|
||
| if (programKey === CONST.COUNTRY.GB) { | ||
| // Only Gibraltar and UK use GBP. If country is not set at all, also assume GBP. | ||
| if (!country || country === CONST.COUNTRY.GB || country === CONST.COUNTRY.GI) { | ||
| return CONST.CURRENCY.GBP; | ||
| } | ||
|
|
||
| // All other countries on this program use EUR | ||
| return CONST.CURRENCY.EUR; | ||
| } | ||
|
|
||
| return CONST.CURRENCY.USD; | ||
| } | ||
|
|
||
| /** | ||
| * Parenthetical qualifier appended to a feed's label so that, when a domain is provisioned with more than one program | ||
| * (e.g. both US and GB), each program's row is distinguishable by its settlement currency (e.g. `expensify.com (GBP)`). | ||
| */ | ||
| function getExpensifyCardProgramLabelSuffix(cardSettings: OnyxEntry<ExpensifyCardSettings>, programKey: CardProgramKey): string { | ||
| const programSettings = getCardSettings(cardSettings, programKey); | ||
| const currency = getExpensifyCardProgramCurrency(programKey, programSettings?.country, programSettings?.currency); | ||
| return `(${currency})`; | ||
| } | ||
|
|
||
| /** | ||
| * Narrows a stored program value (e.g. the `lastSelectedExpensifyCardProgram` Onyx string) to a selectable program key. | ||
| * Only US and GB are user-selectable; anything else (including undefined) falls back to US so single-program feeds are unaffected. | ||
| */ | ||
| function getSelectableCardProgramKey(programKey: string | undefined): CardProgramKey { | ||
| return programKey === CONST.COUNTRY.GB ? CONST.COUNTRY.GB : CONST.COUNTRY.US; | ||
| } | ||
|
|
||
| function getCardSettings(cardSettings: OnyxEntry<ExpensifyCardSettings>, programKey?: CardProgramKey): NestedExpensifyCardSettings | undefined { | ||
| if (!cardSettings) { | ||
| return undefined; | ||
|
|
@@ -1435,6 +1533,15 @@ function getCardSettings(cardSettings: OnyxEntry<ExpensifyCardSettings>, program | |
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the settings for the display page's currently-selected program, falling back to auto-detect when that program | ||
| * is not nested on the NVP. This keeps legacy domains the backend still sends flat (or single-program feeds that default to | ||
| * US) working, without changing `getCardSettings`, whose strict-`programKey` behavior other callers (e.g. currency resolution) rely on. | ||
| */ | ||
| function getCardSettingsForSelectedProgram(cardSettings: OnyxEntry<ExpensifyCardSettings>, programKey: CardProgramKey | undefined): NestedExpensifyCardSettings | undefined { | ||
| return getCardSettings(cardSettings, programKey) ?? getCardSettings(cardSettings); | ||
| } | ||
|
|
||
| /** Backend may nest linkedPolicyIDs under each program block (not only on the settings root). */ | ||
| const NESTED_EXPENSIFY_CARD_PROGRAM_KEYS: readonly CardProgramKey[] = [CONST.COUNTRY.US, CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT, CONST.COUNTRY.GB, CONST.TRAVEL.PROGRAM_TRAVEL_US]; | ||
|
|
||
|
|
@@ -1484,6 +1591,26 @@ function getLinkedPolicyIDsFromExpensifyCardSettings(settings: ExpensifyCardSett | |
| return dedupePolicyIDsCaseInsensitive(ids); | ||
| } | ||
|
|
||
| /** | ||
| * Linked workspace IDs for a single program on the feed. A domain provisioned with more than one program (e.g. US and GB) | ||
| * keeps a separate `linkedPolicyIDs` on each program's nested block, so a workspace linked to only the US program must not | ||
| * appear linked to the GB program. Root-level `linkedPolicyIDs` still apply to the program because legacy/single-program | ||
| * feeds store the links flat on the root rather than nested. | ||
| */ | ||
| function getLinkedPolicyIDsForExpensifyCardProgram(settings: ExpensifyCardSettings | OnyxEntry<ExpensifyCardSettings>, programKey: CardProgramKey): string[] | undefined { | ||
| if (!settings) { | ||
| return undefined; | ||
| } | ||
| const ids: string[] = [ | ||
| ...collectLinkedPolicyIDsFromBase(settings as ExpensifyCardSettingsBase), | ||
| ...collectLinkedPolicyIDsFromBase(getNestedExpensifyCardProgramSettings(settings, programKey)), | ||
| ]; | ||
| if (ids.length === 0) { | ||
| return undefined; | ||
| } | ||
| return dedupePolicyIDsCaseInsensitive(ids); | ||
| } | ||
|
|
||
| /** True if `policyID` is in the linked list (case-insensitive). */ | ||
| function isPolicyIDInLinkedExpensifyCardPolicyList(linkedPolicyIDs: string[] | undefined, policyID: string): boolean { | ||
| return !!linkedPolicyIDs?.some((id) => id.toUpperCase() === policyID.toUpperCase()); | ||
|
|
@@ -1898,30 +2025,7 @@ function getCardOrFeedCurrency(card?: OnyxEntry<Card>, cardSettings?: OnyxEntry< | |
| // If not, attempt to get currency from the card settings. | ||
| const programKey = card?.nameValuePairs?.feedCountry as CardProgramKey | undefined; | ||
| const settings = getCardSettings(cardSettings, programKey); | ||
| if (settings?.currency) { | ||
| return settings.currency; | ||
| } | ||
|
|
||
| // Fall back to the program and country to try to determine the correct currency. | ||
| // US programs are always USD | ||
| if (programKey === CONST.COUNTRY.US || programKey === CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) { | ||
| return CONST.CURRENCY.USD; | ||
| } | ||
|
|
||
| // For UK/EU cards, determine currency by country | ||
| const country = card?.nameValuePairs?.country; | ||
| if (programKey === CONST.COUNTRY.GB) { | ||
| // Only Gibraltar and UK use GBP. If country is not set at all, also assume GBP. | ||
| if (!country || country === CONST.COUNTRY.GB || country === CONST.COUNTRY.GI) { | ||
| return CONST.CURRENCY.GBP; | ||
| } | ||
|
|
||
| // All other countries on this program use EUR | ||
| return CONST.CURRENCY.EUR; | ||
| } | ||
|
|
||
| // Finally if all else fails, default to USD | ||
| return CONST.CURRENCY.USD; | ||
| return getExpensifyCardProgramCurrency(programKey, card?.nameValuePairs?.country, settings?.currency); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -2065,8 +2169,16 @@ export { | |
| hasIssuedExpensifyCard, | ||
| isExpensifyCardFullySetUp, | ||
| getCardSettings, | ||
| getCardSettingsForSelectedProgram, | ||
| getCardProgramKey, | ||
| getConfiguredExpensifyCardProgramKeys, | ||
| getProgramKeyForCard, | ||
| isCardInProgram, | ||
| filterCardsListByProgram, | ||
| getExpensifyCardProgramLabelSuffix, | ||
| getSelectableCardProgramKey, | ||
| getLinkedPolicyIDsFromExpensifyCardSettings, | ||
| getLinkedPolicyIDsForExpensifyCardProgram, | ||
| getPreferredPolicyFromExpensifyCardSettings, | ||
| getDomainNameFromExpensifyCardSettings, | ||
| getDomainByFundID, | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.