Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7d6149a
Phase 3: Remove flat fallbacks and nest travel writes under TRAVEL_US
allgandalf Mar 7, 2026
1364b01
Nest Expensify Card optimistic writes under feedCountry program key
allgandalf Mar 7, 2026
b89cb2d
Merge branch 'Expensify:main' into rohansasne/phase3-remove-flat-fall…
allgandalf Mar 9, 2026
512f521
Fix useDefaultFundID test to use nested card settings format
allgandalf Mar 9, 2026
ca112a7
Fix TravelInvoicing tests to expect nested TRAVEL_US structure
allgandalf Mar 9, 2026
9a5ac55
Make feedCountry required and remove flat write fallbacks in Card act…
allgandalf Mar 9, 2026
b9f06c8
Fix deactivateTravelInvoicing test to remove root-level isEnabled exp…
allgandalf Mar 9, 2026
2ee9879
Merge branch 'Expensify:main' into rohansasne/phase3-remove-flat-fall…
allgandalf Mar 9, 2026
839d13c
Merge branch 'Expensify:main' into rohansasne/phase3-remove-flat-fall…
allgandalf Mar 11, 2026
7c03735
Read previousPaymentBankAccountID and previousMonthlySettlementDate f…
allgandalf Mar 11, 2026
c238041
Address review: rewrite comments, restore flat fallback for legacy do…
allgandalf Mar 12, 2026
0ae0bb5
Pass feedCountry to getCardSettings() in settlement pages
allgandalf Mar 12, 2026
b259976
Restore flat fallback in getCardSettings for legacy domains
allgandalf Mar 12, 2026
eae8c2e
Merge branch 'Expensify:main' into rohansasne/phase3-remove-flat-fall…
allgandalf Mar 12, 2026
d981dac
Merge branch 'Expensify:main' into rohansasne/phase3-remove-flat-fall…
allgandalf Mar 16, 2026
eb3a3b7
Add CardProgramKey type, rename getCardFeedCountry \u2192 getCardProg…
allgandalf Mar 16, 2026
0900bbb
Type updateSettlementFrequency and updateSettlementAccount with CardP…
allgandalf Mar 16, 2026
468768f
Update callers to use getCardProgramKey, rename feedCountry \u2192 pr…
allgandalf Mar 16, 2026
d8123a5
Fix test casts: use unknown as CardProgramKey instead of any
allgandalf Mar 16, 2026
37e842d
Rename feedCountry to programKey in updateSettlementFrequency and upd…
allgandalf Mar 16, 2026
e888bab
Merge branch 'Expensify:main' into rohansasne/phase3-remove-flat-fall…
allgandalf Mar 17, 2026
3d76813
Fix typecheck: cast feedCountry to CardProgramKey
allgandalf Mar 17, 2026
9df9985
Rename feedCountry local var to programKey in getCardCurrency
allgandalf Mar 17, 2026
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
70 changes: 49 additions & 21 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,34 +1105,61 @@ function isExpensifyCardFullySetUp(policy?: OnyxEntry<Policy>, cardSettings?: On
return !!(policy?.areExpensifyCardsEnabled && cardSettings?.paymentBankAccountID);
}

function getCardSettings(cardSettings: OnyxEntry<ExpensifyCardSettings>, feedCountry?: string): ExpensifyCardSettingsBase | undefined {
/**
* The set of valid card program keys used to key nested settings in ExpensifyCardSettings.
* 'US' and 'GB' are geo-based programs, 'CURRENT' is the legacy pre-2024 US program,
* and 'TRAVEL_US' is the travel invoicing program. These map directly to the keys
* the backend nests card settings under.
*/
type CardProgramKey = typeof CONST.COUNTRY.US | typeof CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT | typeof CONST.COUNTRY.GB | typeof CONST.TRAVEL.PROGRAM_TRAVEL_US;

/**
* Detects which card program key exists in the card settings object.
* Returns the first matching program key (US, CURRENT, or GB), or undefined if none found.
* Used to determine the correct nested key for optimistic writes.
*/
function getCardProgramKey(cardSettings: OnyxEntry<ExpensifyCardSettings>): CardProgramKey | undefined {
if (!cardSettings) {
return undefined;
}

const programKeys: CardProgramKey[] = [CONST.COUNTRY.US, CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT, CONST.COUNTRY.GB];
return programKeys.find((key) => {
const value = cardSettings[key];
return value !== null && typeof value === 'object' && !Array.isArray(value);
});
}

function getCardSettings(cardSettings: OnyxEntry<ExpensifyCardSettings>, programKey?: CardProgramKey): ExpensifyCardSettingsBase | undefined {
if (!cardSettings) {
return undefined;
}

const getMergedProgramSettings = (programKey: string): ExpensifyCardSettingsBase | undefined => {
const programSettings = cardSettings[programKey as keyof typeof cardSettings];
const getMergedProgramSettings = (key: CardProgramKey): ExpensifyCardSettingsBase | undefined => {
const programSettings = cardSettings[key];
if (programSettings && typeof programSettings === 'object' && !Array.isArray(programSettings)) {
// 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;
// program-specific fields (e.g. paymentBankAccountID, monthlySettlementDate).
return {...cardSettings, ...programSettings} as ExpensifyCardSettingsBase;
}
return undefined;
};

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

// 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;
// Auto-detect: try known card programs in priority order.
// Newer domains nest settings under US/GB, legacy ones under CURRENT.
// The flat root fallback supports domains that the backend still sends without nesting
// (e.g. older accounts that haven't been migrated). Writes always go to the nested key
// (via getCardProgramKey), so this flat path is read-only display fallback only.
return (
getMergedProgramSettings(CONST.COUNTRY.US) ??
getMergedProgramSettings(CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) ??
getMergedProgramSettings(CONST.COUNTRY.GB) ??
(cardSettings as ExpensifyCardSettingsBase)
);
Comment thread
allgandalf marked this conversation as resolved.
}

function isCardPendingIssue(card?: Card) {
Expand Down Expand Up @@ -1422,21 +1449,21 @@ function getCardCurrency(card?: OnyxEntry<Card>, cardSettings?: OnyxEntry<Expens
}

// If not, attempt to get currency from the card settings.
const feedCountry = card?.nameValuePairs?.feedCountry;
const settings = getCardSettings(cardSettings, feedCountry);
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 (feedCountry === CONST.COUNTRY.US || feedCountry === CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT) {
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 (feedCountry === CONST.COUNTRY.GB) {
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;
Expand Down Expand Up @@ -1508,6 +1535,7 @@ export {
hasIssuedExpensifyCard,
isExpensifyCardFullySetUp,
getCardSettings,
getCardProgramKey,
filterAllInactiveCards,
filterInactiveCards,
isCardPendingIssue,
Expand Down Expand Up @@ -1545,4 +1573,4 @@ export {
getCardCurrency,
};

export type {CompanyCardFeedIcons, CompanyCardBankIcons};
export type {CompanyCardFeedIcons, CompanyCardBankIcons, CardProgramKey};
58 changes: 34 additions & 24 deletions src/libs/actions/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
UpdateExpensifyCardTitleParams,
} from '@libs/API/parameters';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import type {CardProgramKey} from '@libs/CardUtils';
import DateUtils from '@libs/DateUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Log from '@libs/Log';
Expand Down Expand Up @@ -645,36 +646,38 @@ function revealVirtualCardDetails(cardID: number, validateCode: string): Promise
});
}

function updateSettlementFrequency(workspaceAccountID: number, settlementFrequency: ValueOf<typeof CONST.EXPENSIFY_CARD.FREQUENCY_SETTING>, currentFrequency?: Date) {
function updateSettlementFrequency(
workspaceAccountID: number,
programKey: CardProgramKey,
settlementFrequency: ValueOf<typeof CONST.EXPENSIFY_CARD.FREQUENCY_SETTING>,
currentFrequency?: Date,
) {
const monthlySettlementDate = settlementFrequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY ? null : new Date();

const settlementValue = {[programKey]: {monthlySettlementDate}};
const failureValue = {[programKey]: {monthlySettlementDate: currentFrequency}};

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
value: {
monthlySettlementDate,
},
value: settlementValue,
},
];

const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
value: {
monthlySettlementDate,
},
value: settlementValue,
},
];

const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
value: {
monthlySettlementDate: currentFrequency,
},
value: failureValue,
},
];

Expand All @@ -686,42 +689,49 @@ function updateSettlementFrequency(workspaceAccountID: number, settlementFrequen
API.write(WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY, parameters, {optimisticData, successData, failureData});
}

function updateSettlementAccount(domainName: string, workspaceAccountID: number, policyID: string, settlementBankAccountID?: number, currentSettlementBankAccountID?: number) {
function updateSettlementAccount(
domainName: string,
workspaceAccountID: number,
policyID: string,
programKey: CardProgramKey,
settlementBankAccountID?: number,
currentSettlementBankAccountID?: number,
) {
if (!settlementBankAccountID) {
return;
}

const optimisticValue = {[programKey]: {paymentBankAccountID: settlementBankAccountID}, isLoading: true};

const successValue = {[programKey]: {paymentBankAccountID: settlementBankAccountID}, isLoading: false};

const failureValue = {
[programKey]: {paymentBankAccountID: currentSettlementBankAccountID},
isLoading: false,
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
};

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
value: {
paymentBankAccountID: settlementBankAccountID,
isLoading: true,
},
value: optimisticValue,
},
];

const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
value: {
paymentBankAccountID: settlementBankAccountID,
isLoading: false,
},
value: successValue,
},
];

const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}`,
value: {
paymentBankAccountID: currentSettlementBankAccountID,
isLoading: false,
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
},
value: failureValue,
},
];

Expand Down
59 changes: 36 additions & 23 deletions src/libs/actions/TravelInvoicing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
paymentBankAccountID: settlementBankAccountID,
previousPaymentBankAccountID,
monthlySettlementDate,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
paymentBankAccountID: settlementBankAccountID,
previousPaymentBankAccountID,
Comment thread
rlinoz marked this conversation as resolved.
monthlySettlementDate,
},
isLoading: true,
pendingFields: {
paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
Expand All @@ -110,9 +112,11 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
paymentBankAccountID: settlementBankAccountID,
previousPaymentBankAccountID: null,
monthlySettlementDate,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
paymentBankAccountID: settlementBankAccountID,
previousPaymentBankAccountID: null,
monthlySettlementDate,
},
isLoading: false,
pendingFields: {
paymentBankAccountID: null,
Expand All @@ -129,10 +133,11 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
// Keep the attempted value visible (grayed out) until error is dismissed
paymentBankAccountID: settlementBankAccountID,
previousPaymentBankAccountID,
monthlySettlementDate,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
paymentBankAccountID: settlementBankAccountID,
previousPaymentBankAccountID,
monthlySettlementDate,
},
isLoading: false,
pendingFields: {
paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
Expand All @@ -158,8 +163,10 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI
*/
function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, paymentBankAccountID: number | null) {
Onyx.merge(getTravelInvoicingCardSettingsKey(workspaceAccountID), {
paymentBankAccountID,
previousPaymentBankAccountID: null,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
paymentBankAccountID,
previousPaymentBankAccountID: null,
},
pendingFields: {
paymentBankAccountID: null,
},
Expand All @@ -185,8 +192,10 @@ function updateTravelInvoiceSettlementFrequency(workspaceAccountID: number, freq
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
monthlySettlementDate,
previousMonthlySettlementDate: currentMonthlySettlementDate,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
monthlySettlementDate,
previousMonthlySettlementDate: currentMonthlySettlementDate,
Comment thread
rlinoz marked this conversation as resolved.
},
pendingFields: {
monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
},
Expand All @@ -202,8 +211,10 @@ function updateTravelInvoiceSettlementFrequency(workspaceAccountID: number, freq
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
monthlySettlementDate,
previousMonthlySettlementDate: null,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
monthlySettlementDate,
previousMonthlySettlementDate: null,
},
pendingFields: {
monthlySettlementDate: null,
},
Expand All @@ -219,8 +230,10 @@ function updateTravelInvoiceSettlementFrequency(workspaceAccountID: number, freq
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
monthlySettlementDate,
previousMonthlySettlementDate: currentMonthlySettlementDate ?? null,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
monthlySettlementDate,
previousMonthlySettlementDate: currentMonthlySettlementDate ?? null,
},
pendingFields: {
monthlySettlementDate: null,
},
Expand All @@ -244,8 +257,10 @@ function updateTravelInvoiceSettlementFrequency(workspaceAccountID: number, freq
*/
function clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID: number, monthlySettlementDate: Date | null | undefined) {
Onyx.merge(getTravelInvoicingCardSettingsKey(workspaceAccountID), {
monthlySettlementDate: monthlySettlementDate ?? null,
previousMonthlySettlementDate: null,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
monthlySettlementDate: monthlySettlementDate ?? null,
previousMonthlySettlementDate: null,
},
pendingFields: {
monthlySettlementDate: null,
},
Expand Down Expand Up @@ -327,7 +342,6 @@ function deactivateTravelInvoicing(policyID: string, workspaceAccountID: number)
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
isEnabled: false,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
isEnabled: false,
},
Expand All @@ -342,10 +356,10 @@ function deactivateTravelInvoicing(policyID: string, workspaceAccountID: number)
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
pendingAction: null,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
isEnabled: false,
},
pendingAction: null,
},
},
];
Expand All @@ -355,7 +369,6 @@ function deactivateTravelInvoicing(policyID: string, workspaceAccountID: number)
onyxMethod: Onyx.METHOD.MERGE,
key: cardSettingsKey,
value: {
isEnabled: true,
[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {
isEnabled: true,
},
Expand Down
Loading
Loading