Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
21 changes: 17 additions & 4 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,11 +1084,24 @@ 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)) {
return {...cardSettings, ...(programSettings as ExpensifyCardSettingsBase)} as ExpensifyCardSettingsBase;

Choose a reason for hiding this comment

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

P1 Badge Respect root updates when merging nested card settings

This merge order makes nested values always override root values, so root-level optimistic writes are dropped whenever a nested program object exists. In this repo, updateSettlementAccount and updateSettlementFrequency still write paymentBankAccountID/monthlySettlementDate at the root of PRIVATE_EXPENSIFY_CARD_SETTINGS (src/libs/actions/Card.ts), so after this change getCardSettings() can keep returning stale nested values and the settings UI may not reflect account/frequency changes until a full server refresh (or at all if no nested update arrives).

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good catch — flipped the merge order to {...programSettings, ...cardSettings} so root-level optimistic writes (e.g. paymentBankAccountID from updateSettlementAccount) take precedence over stale nested values. nested-only fields still come through since root won't have them

}
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
61 changes: 47 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,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');
});
});
});

Expand Down
Loading