Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3690,6 +3690,9 @@ const CONST = {
NAME: 'expensifyCard',
BANK: 'Expensify Card',
ROUTE: 'expensify-card',
CARD_PROGRAM: {
CURRENT: 'CURRENT',
},
FRAUD_TYPES: {
DOMAIN: 'domain',
INDIVIDUAL: 'individual',
Expand Down
23 changes: 19 additions & 4 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,11 +1084,26 @@ function getCardSettings(cardSettings: OnyxEntry<ExpensifyCardSettings>, feedCou
return undefined;
}

if (feedCountry) {
const feedCountryCardSettings = cardSettings[feedCountry as keyof typeof cardSettings];
if (feedCountryCardSettings && typeof feedCountryCardSettings === 'object' && !Array.isArray(feedCountryCardSettings)) {
return feedCountryCardSettings as ExpensifyCardSettingsBase;
const getMergedProgramSettings = (programKey: string): ExpensifyCardSettingsBase | undefined => {
const programSettings = cardSettings[programKey as keyof typeof cardSettings];
if (programSettings && typeof programSettings === 'object' && !Array.isArray(programSettings)) {
// Spread nested first so root-level optimistic writes (e.g. paymentBankAccountID
// from updateSettlementAccount) take precedence over stale nested values.
return {...(programSettings as ExpensifyCardSettingsBase), ...cardSettings} as ExpensifyCardSettingsBase;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve nested program fields over root in merged card settings

Spreading programSettings before cardSettings lets root-level fields overwrite the selected program payload, which can surface the wrong card config once Phase 2 sends multiple nested programs under one key. In this repo, Travel Invoicing actions still write fields like paymentBankAccountID and isEnabled at the root of the same private_expensifyCardSettings_* entry, so after a travel update a US card read can be overwritten by travel/root values even when US nested data is correct; this affects callers like workspace Expensify Card pages that rely on getCardSettings() for settlement/account state.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reverted back to {...root, ...nested} — nested program values are the authoritative source from the backend, this matches the existing getTravelSettings pattern. optimistic writes are temporary and get corrected by the server response, so nested should win to avoid cross-program leakage (e.g. travel paymentBankAccountID at root overwriting the correct US nested value)

}
return undefined;
};

if (feedCountry) {
return getMergedProgramSettings(feedCountry) ?? cardSettings;
}

// Auto-detect: try known card programs in priority order so callers that
// don't pass feedCountry still get the right program sub-object when the
// backend sends nested settings (Phase 2 of fixing shared Onyx key).
const result = getMergedProgramSettings(CONST.COUNTRY.US) ?? getMergedProgramSettings(CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) ?? getMergedProgramSettings(CONST.COUNTRY.GB);
if (result) {
return result;
}

return cardSettings;
Expand Down
6 changes: 6 additions & 0 deletions src/types/onyx/ExpensifyCardSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Nested Expensify Card settings keyed by feed country from backend */
// eslint-disable-next-line @typescript-eslint/naming-convention
US?: ExpensifyCardSettingsBase;
/** Nested settings for pre-2024 US card program from backend */
// eslint-disable-next-line @typescript-eslint/naming-convention
CURRENT?: ExpensifyCardSettingsBase;
/** Nested settings for UK/EU card program from backend */
// eslint-disable-next-line @typescript-eslint/naming-convention
GB?: ExpensifyCardSettingsBase;
/** Nested Travel Invoicing settings from backend */
// eslint-disable-next-line @typescript-eslint/naming-convention
TRAVEL_US?: ExpensifyCardSettingsBase;
Expand Down
68 changes: 54 additions & 14 deletions tests/unit/CardUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3245,6 +3245,8 @@ describe('CardUtils', () => {
} as ExpensifyCardSettings;

const nestedSettings = {
domainName: 'example.com',
preferredPolicy: 'policyID',
paymentBankAccountID: 12345,
limit: 50000,
US: {
Expand All @@ -3268,42 +3270,80 @@ describe('CardUtils', () => {
expect(getCardSettings(null as unknown as undefined)).toBeUndefined();
});

it('should return flat root when feedCountry is not provided', () => {
it('should return flat root when feedCountry is not provided and no nested keys exist', () => {
const result = getCardSettings(flatSettings);
expect(result).toBe(flatSettings);
});

it('should return flat root when feedCountry is undefined', () => {
it('should return flat root when feedCountry is undefined and no nested keys exist', () => {
const result = getCardSettings(flatSettings, undefined);
expect(result).toBe(flatSettings);
});

it('should return nested object when feedCountry matches a nested key', () => {
it('should return merged root + nested when feedCountry matches a nested key', () => {
const result = getCardSettings(nestedSettings, 'US');
expect(result).toEqual({
paymentBankAccountID: 67890,
limit: 30000,
currentBalance: 500,
});
// Root-level values take precedence (may contain optimistic updates)
expect(result?.paymentBankAccountID).toBe(12345);
expect(result?.limit).toBe(50000);
// Nested-only fields still come through
expect(result?.currentBalance).toBe(500);
expect(result?.domainName).toBe('example.com');
});

it('should fall back to flat root when feedCountry key does not exist', () => {
it('should fall back to root when feedCountry key does not exist', () => {
const result = getCardSettings(nestedSettings, 'CA');
expect(result).toBe(nestedSettings);
});

it('should return TRAVEL_US nested settings when feedCountry is TRAVEL_US', () => {
it('should return merged root + TRAVEL_US when feedCountry is TRAVEL_US', () => {
const result = getCardSettings(nestedSettings, 'TRAVEL_US');
expect(result).toEqual({
paymentBankAccountID: 11111,
isEnabled: true,
});
// Root-level paymentBankAccountID takes precedence over nested
expect(result?.paymentBankAccountID).toBe(12345);
// Nested-only field comes through
expect(result?.isEnabled).toBe(true);
expect(result?.domainName).toBe('example.com');
});

it('should not return primitive values as nested settings', () => {
const result = getCardSettings(nestedSettings, 'limit');
expect(result).toBe(nestedSettings);
});

it('should auto-detect US program when no feedCountry is provided', () => {
const result = getCardSettings(nestedSettings);
// Root-level values take precedence over nested (optimistic updates)
expect(result?.paymentBankAccountID).toBe(12345);
expect(result?.limit).toBe(50000);
// Nested-only field comes through
expect(result?.currentBalance).toBe(500);
expect(result?.domainName).toBe('example.com');
});

it('should auto-detect GB program when only GB nested key exists', () => {
const gbOnlySettings = {
domainName: 'uk-example.com',
GB: {
paymentBankAccountID: 99999,
limit: 20000,
},
} as ExpensifyCardSettings;
const result = getCardSettings(gbOnlySettings);
expect(result?.paymentBankAccountID).toBe(99999);
expect(result?.domainName).toBe('uk-example.com');
});

it('should auto-detect CURRENT program for legacy pre-2024 nested format', () => {
const currentSettings = {
domainName: 'legacy.com',
CURRENT: {
paymentBankAccountID: 55555,
limit: 10000,
},
} as ExpensifyCardSettings;
const result = getCardSettings(currentSettings);
expect(result?.paymentBankAccountID).toBe(55555);
expect(result?.domainName).toBe('legacy.com');
});
});
});

Expand Down
Loading