From b6a1ccd050aaf1afd5d923a638a328e32813bf43 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Thu, 5 Mar 2026 20:09:39 +0530 Subject: [PATCH 1/6] Auto-detect nested card program in getCardSettings --- src/libs/CardUtils.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 9456622a03033..7b7bfcff51739 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1084,11 +1084,24 @@ function getCardSettings(cardSettings: OnyxEntry, 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 tryNested = (key: string): ExpensifyCardSettingsBase | undefined => { + const nested = cardSettings[key as keyof typeof cardSettings]; + if (nested && typeof nested === 'object' && !Array.isArray(nested)) { + return {...cardSettings, ...(nested as ExpensifyCardSettingsBase)} as ExpensifyCardSettingsBase; } + return undefined; + }; + + if (feedCountry) { + return tryNested(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 = tryNested(CONST.COUNTRY.US) ?? tryNested('CURRENT') ?? tryNested('GB'); + if (result) { + return result; } return cardSettings; From 0d9a1fe77edff25291ef95b5ddd8ce335cf5e16c Mon Sep 17 00:00:00 2001 From: allgandaf Date: Thu, 5 Mar 2026 20:11:04 +0530 Subject: [PATCH 2/6] Update getCardSettings tests for auto-detection and merge behavior --- tests/unit/CardUtilsTest.ts | 61 ++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 26c0436397be0..0231b0a934ede 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -3245,6 +3245,8 @@ describe('CardUtils', () => { } as ExpensifyCardSettings; const nestedSettings = { + domainName: 'example.com', + preferredPolicy: 'policyID', paymentBankAccountID: 12345, limit: 50000, US: { @@ -3268,42 +3270,73 @@ 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, - }); + expect(result?.paymentBankAccountID).toBe(67890); + expect(result?.limit).toBe(30000); + 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, - }); + expect(result?.paymentBankAccountID).toBe(11111); + 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); + expect(result?.paymentBankAccountID).toBe(67890); + expect(result?.limit).toBe(30000); + 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'); + }); }); }); From 90e7daf2fb5b56b971792bf99a84d044c61cc2c2 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Thu, 5 Mar 2026 20:34:19 +0530 Subject: [PATCH 3/6] Replace magic strings with constants, rename tryNested, update types --- src/CONST/index.ts | 3 +++ src/libs/CardUtils.ts | 13 +++++++------ src/types/onyx/ExpensifyCardSettings.ts | 6 ++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 60a350410ce31..71d4f165a8870 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3690,6 +3690,9 @@ const CONST = { NAME: 'expensifyCard', BANK: 'Expensify Card', ROUTE: 'expensify-card', + CARD_PROGRAM: { + CURRENT: 'CURRENT', + }, FRAUD_TYPES: { DOMAIN: 'domain', INDIVIDUAL: 'individual', diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 7b7bfcff51739..c4923139a70fc 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1084,22 +1084,23 @@ function getCardSettings(cardSettings: OnyxEntry, feedCou return undefined; } - const tryNested = (key: string): ExpensifyCardSettingsBase | undefined => { - const nested = cardSettings[key as keyof typeof cardSettings]; - if (nested && typeof nested === 'object' && !Array.isArray(nested)) { - return {...cardSettings, ...(nested as ExpensifyCardSettingsBase)} as ExpensifyCardSettingsBase; + const getMergedProgramSettings = (programKey: string): ExpensifyCardSettingsBase | undefined => { + const programSettings = cardSettings[programKey as keyof typeof cardSettings]; + if (programSettings && typeof programSettings === 'object' && !Array.isArray(programSettings)) { + return {...cardSettings, ...(programSettings as ExpensifyCardSettingsBase)} as ExpensifyCardSettingsBase; } return undefined; }; if (feedCountry) { - return tryNested(feedCountry) ?? cardSettings; + 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 = tryNested(CONST.COUNTRY.US) ?? tryNested('CURRENT') ?? tryNested('GB'); + const result = + getMergedProgramSettings(CONST.COUNTRY.US) ?? getMergedProgramSettings(CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) ?? getMergedProgramSettings(CONST.COUNTRY.GB); if (result) { return result; } diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts index 62c39a29926d3..db4f9fae25e93 100644 --- a/src/types/onyx/ExpensifyCardSettings.ts +++ b/src/types/onyx/ExpensifyCardSettings.ts @@ -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; From a40d674b45e65cde3bf075af754e5f4c0172a8c6 Mon Sep 17 00:00:00 2001 From: allgandaf Date: Thu, 5 Mar 2026 20:44:37 +0530 Subject: [PATCH 4/6] fix prettier --- src/libs/CardUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index c4923139a70fc..460d58f70bcb4 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1099,8 +1099,7 @@ function getCardSettings(cardSettings: OnyxEntry, feedCou // 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); + const result = getMergedProgramSettings(CONST.COUNTRY.US) ?? getMergedProgramSettings(CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) ?? getMergedProgramSettings(CONST.COUNTRY.GB); if (result) { return result; } From c36db661fa9a3836f5f3de4870dd6725fa5d9bdc Mon Sep 17 00:00:00 2001 From: allgandaf Date: Fri, 6 Mar 2026 01:35:58 +0530 Subject: [PATCH 5/6] Fix merge order: root-level optimistic writes take precedence over nested --- src/libs/CardUtils.ts | 4 +++- tests/unit/CardUtilsTest.ts | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 460d58f70bcb4..953584321476c 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1087,7 +1087,9 @@ function getCardSettings(cardSettings: OnyxEntry, feedCou const getMergedProgramSettings = (programKey: string): ExpensifyCardSettingsBase | undefined => { const programSettings = cardSettings[programKey as keyof typeof cardSettings]; if (programSettings && typeof programSettings === 'object' && !Array.isArray(programSettings)) { - return {...cardSettings, ...(programSettings as ExpensifyCardSettingsBase)} as ExpensifyCardSettingsBase; + // 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; } return undefined; }; diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 0231b0a934ede..04c2d2cf89ad5 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -3282,8 +3282,10 @@ describe('CardUtils', () => { it('should return merged root + nested when feedCountry matches a nested key', () => { const result = getCardSettings(nestedSettings, 'US'); - expect(result?.paymentBankAccountID).toBe(67890); - expect(result?.limit).toBe(30000); + // 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'); }); @@ -3295,7 +3297,9 @@ describe('CardUtils', () => { it('should return merged root + TRAVEL_US when feedCountry is TRAVEL_US', () => { const result = getCardSettings(nestedSettings, 'TRAVEL_US'); - expect(result?.paymentBankAccountID).toBe(11111); + // 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'); }); @@ -3307,8 +3311,11 @@ describe('CardUtils', () => { it('should auto-detect US program when no feedCountry is provided', () => { const result = getCardSettings(nestedSettings); - expect(result?.paymentBankAccountID).toBe(67890); - expect(result?.limit).toBe(30000); + // 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'); }); From ce895fb885901d8f65cea7ff0de45bdcf6b31f5c Mon Sep 17 00:00:00 2001 From: allgandaf Date: Fri, 6 Mar 2026 03:29:23 +0530 Subject: [PATCH 6/6] Revert merge order: nested program values are authoritative over root --- src/libs/CardUtils.ts | 6 +++--- tests/unit/CardUtilsTest.ts | 16 +++++----------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 953584321476c..8199f8da27f69 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1087,9 +1087,9 @@ function getCardSettings(cardSettings: OnyxEntry, feedCou 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; + // Nested program values take precedence — they are the authoritative source for + // program-specific fields once the backend sends the full nested format (Phase 2). + return {...cardSettings, ...(programSettings as ExpensifyCardSettingsBase)} as ExpensifyCardSettingsBase; } return undefined; }; diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 04c2d2cf89ad5..7a8865856ee58 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -3282,10 +3282,8 @@ describe('CardUtils', () => { it('should return merged root + nested when feedCountry matches a nested key', () => { const result = getCardSettings(nestedSettings, 'US'); - // 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?.paymentBankAccountID).toBe(67890); + expect(result?.limit).toBe(30000); expect(result?.currentBalance).toBe(500); expect(result?.domainName).toBe('example.com'); }); @@ -3297,9 +3295,7 @@ describe('CardUtils', () => { it('should return merged root + TRAVEL_US when feedCountry is TRAVEL_US', () => { const result = getCardSettings(nestedSettings, 'TRAVEL_US'); - // Root-level paymentBankAccountID takes precedence over nested - expect(result?.paymentBankAccountID).toBe(12345); - // Nested-only field comes through + expect(result?.paymentBankAccountID).toBe(11111); expect(result?.isEnabled).toBe(true); expect(result?.domainName).toBe('example.com'); }); @@ -3311,10 +3307,8 @@ describe('CardUtils', () => { 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?.paymentBankAccountID).toBe(67890); + expect(result?.limit).toBe(30000); expect(result?.currentBalance).toBe(500); expect(result?.domainName).toBe('example.com'); });