diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a952a6d80935b..4768737e005d1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2575,6 +2575,10 @@ const ROUTES = { route: 'workspaces/:policyID/travel/settings/account', getRoute: (policyID: string) => `workspaces/${policyID}/travel/settings/account` as const, }, + WORKSPACE_TRAVEL_SETTINGS_FREQUENCY: { + route: 'workspaces/:policyID/travel/settings/frequency', + getRoute: (policyID: string) => `workspaces/${policyID}/travel/settings/frequency` as const, + }, WORKSPACE_CREATE_DISTANCE_RATE: { route: 'workspaces/:policyID/distance-rates/new', getRoute: (policyID: string, transactionID?: string, reportID?: string) => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5ef218dae1138..3d1f2ac254d0a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -748,6 +748,7 @@ const SCREENS = { DISTANCE_RATES: 'Distance_Rates', TRAVEL: 'Travel', TRAVEL_SETTINGS_ACCOUNT: 'Workspace_Travel_Settings_Account', + TRAVEL_SETTINGS_FREQUENCY: 'Workspace_Travel_Settings_Frequency', CREATE_DISTANCE_RATE: 'Create_Distance_Rate', CREATE_DISTANCE_RATE_UPGRADE: 'Create_Distance_Rate_Upgrade', DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', diff --git a/src/languages/de.ts b/src/languages/de.ts index 0095adbf84fcc..70fe9a37ca442 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5209,6 +5209,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU currentTravelLimitLabel: 'Aktuelles Reisekontingent', settlementAccountLabel: 'Verrechnungskonto', settlementFrequencyLabel: 'Auszahlungsfrequenz', + settlementFrequencyDescription: 'Wie oft Expensify Ihr Geschäftskonto belastet, um aktuelle Expensify Travel-Transaktionen zu begleichen.', }, }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 0194288859567..27649703bb77b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5162,6 +5162,7 @@ const translations = { currentTravelLimitLabel: 'Current travel limit', settlementAccountLabel: 'Settlement account', settlementFrequencyLabel: 'Settlement frequency', + settlementFrequencyDescription: 'How often Expensify will pull from your business bank account to settle recent Expensify Travel transactions.', }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 03178c8533b58..4d1b08015a5eb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4920,6 +4920,8 @@ ${amount} para ${merchant} - ${date}`, currentTravelLimitLabel: 'Límite actual de viajes', settlementAccountLabel: 'Cuenta de liquidación', settlementFrequencyLabel: 'Frecuencia de liquidación', + settlementFrequencyDescription: + 'Con qué frecuencia Expensify retirará fondos de la cuenta bancaria de tu empresa para liquidar transacciones recientes de Expensify Travel.', }, }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d8b9d96084afd..6a2e6850db3b1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5217,6 +5217,8 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. currentTravelLimitLabel: 'Plafond de déplacement actuel', settlementAccountLabel: 'Compte de règlement', settlementFrequencyLabel: 'Fréquence de règlement', + settlementFrequencyDescription: + 'Fréquence à laquelle Expensify prélèvera sur votre compte bancaire professionnel pour régler les transactions récentes d’Expensify Travel.', }, }, }, diff --git a/src/languages/it.ts b/src/languages/it.ts index e20f92296bdf8..236e3595807d9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5190,6 +5190,8 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. currentTravelLimitLabel: 'Limite di viaggio attuale', settlementAccountLabel: 'Conto di regolamento', settlementFrequencyLabel: 'Frequenza di regolamento', + settlementFrequencyDescription: + 'Con quale frequenza Expensify preleverà dal tuo conto bancario aziendale per saldare le recenti transazioni di Expensify Travel.', }, }, }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c54d3d385d419..a30b7993afd20 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5153,6 +5153,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO currentTravelLimitLabel: '現在の出張上限', settlementAccountLabel: '決済口座', settlementFrequencyLabel: '清算頻度', + settlementFrequencyDescription: 'Expensify が直近の Expensify Travel 取引を精算するために、あなたのビジネス銀行口座から資金を引き落とす頻度。', }, }, }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4ce08959a1201..40398cf3bbbe3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5183,6 +5183,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ currentTravelLimitLabel: 'Huidige reisl imiet', settlementAccountLabel: 'Verrekeningsrekening', settlementFrequencyLabel: 'Uitbetalingsfrequentie', + settlementFrequencyDescription: 'Hoe vaak Expensify geld van uw zakelijke bankrekening zal incasseren om recente Expensify Travel-transacties te vereffenen.', }, }, }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 99f6908e541ba..23019b9ca8bb0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5171,6 +5171,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy currentTravelLimitLabel: 'Aktualny limit podróży', settlementAccountLabel: 'Konto rozliczeniowe', settlementFrequencyLabel: 'Częstotliwość rozliczeń', + settlementFrequencyDescription: 'Jak często Expensify będzie pobierać środki z firmowego konta bankowego, aby rozliczyć ostatnie transakcje Expensify Travel.', }, }, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c6edd85708ccc..c2c2ee9b9130b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5175,6 +5175,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS currentTravelLimitLabel: 'Limite de viagem atual', settlementAccountLabel: 'Conta de liquidação', settlementFrequencyLabel: 'Frequência de liquidação', + settlementFrequencyDescription: + 'Com que frequência o Expensify vai debitar da sua conta bancária empresarial para liquidar as transações recentes do Expensify Travel.', }, }, }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5e94a0a04f7d7..5dcea0a3eed00 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5083,6 +5083,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM currentTravelLimitLabel: '当前出行限额', settlementAccountLabel: '结算账户', settlementFrequencyLabel: '结算频率', + settlementFrequencyDescription: 'Expensify 从您的企业银行账户中扣款以结算最近 Expensify Travel 交易的频率。', }, }, }, diff --git a/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts new file mode 100644 index 0000000000000..f82dfa2a15931 --- /dev/null +++ b/src/libs/API/parameters/UpdateTravelInvoicingSettlementFrequencyParams.ts @@ -0,0 +1,10 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type UpdateTravelInvoicingSettlementFrequencyParams = { + policyID: string; + workspaceAccountID: number; + settlementFrequency: ValueOf; +}; + +export default UpdateTravelInvoicingSettlementFrequencyParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index b177eafa1ef9b..9eb594c8ee367 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -472,6 +472,7 @@ export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams export type {default as DeleteDomainMemberParams} from './DeleteDomainMemberParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; +export type {default as UpdateTravelInvoicingSettlementFrequencyParams} from './UpdateTravelInvoicingSettlementFrequencyParams'; export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 063c11fb04edc..36cd89371ef9d 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -469,6 +469,7 @@ const WRITE_COMMANDS = { UPDATE_CARD_SETTLEMENT_FREQUENCY: 'UpdateCardSettlementFrequency', UPDATE_CARD_SETTLEMENT_ACCOUNT: 'UpdateCardSettlementAccount', SET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT: 'SetTravelInvoicingSettlementAccount', + UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY: 'UpdateTravelInvoiceSettlementFrequency', UPDATE_XERO_IMPORT_TRACKING_CATEGORIES: 'UpdateXeroImportTrackingCategories', UPDATE_XERO_IMPORT_TAX_RATES: 'UpdateXeroImportTaxRates', UPDATE_XERO_TENANT_ID: 'UpdateXeroTenantID', @@ -1054,6 +1055,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_FREQUENCY]: Parameters.UpdateCardSettlementFrequencyParams; [WRITE_COMMANDS.UPDATE_CARD_SETTLEMENT_ACCOUNT]: Parameters.UpdateCardSettlementAccountParams; [WRITE_COMMANDS.SET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT]: Parameters.SetTravelInvoicingSettlementAccountParams; + [WRITE_COMMANDS.UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY]: Parameters.UpdateTravelInvoicingSettlementFrequencyParams; [WRITE_COMMANDS.SET_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARDS]: Parameters.SetPersonalDetailsAndShipExpensifyCardsParams; [WRITE_COMMANDS.SELF_TOUR_VIEWED]: null; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f99f6c0a856fb..43d34562f1287 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -773,6 +773,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/expensifyCard/WorkspaceSettlementAccountPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_FREQUENCY]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceSettlementFrequencyPage').default, [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: () => require('../../../../pages/workspace/travel/WorkspaceTravelInvoicingSettlementAccountPage').default, + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: () => require('../../../../pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_SELECT_FEED]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardSelectorPage').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts').default, [SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index fdf4be96fe7df..1ccd1db516f2f 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -229,7 +229,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: { path: ROUTES.WORKSPACE_TRAVEL_SETTINGS_ACCOUNT.route, }, + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: { + path: ROUTES.WORKSPACE_TRAVEL_SETTINGS_FREQUENCY.route, + }, [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: { path: ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ce5436664bdda..cf4c43c5a3d5d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1282,6 +1282,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TRAVEL_SETTINGS_ACCOUNT]: { policyID: string; }; + [SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: { + policyID: string; + }; [SCREENS.WORKSPACE.COMPANY_CARDS_SETTINGS]: { policyID: string; }; diff --git a/src/libs/TravelInvoicingUtils.ts b/src/libs/TravelInvoicingUtils.ts index a02b1f763acfe..79cee01be2288 100644 --- a/src/libs/TravelInvoicingUtils.ts +++ b/src/libs/TravelInvoicingUtils.ts @@ -78,12 +78,16 @@ function getTravelSettlementAccount(cardSettings: OnyxEntry): string { + // Default to monthly per design doc when no settings exist if (!cardSettings) { - return CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY; + return CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY; } + // If monthlySettlementDate is set, it's monthly; otherwise it's daily return cardSettings.monthlySettlementDate ? CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY : CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY; } diff --git a/src/libs/actions/TravelInvoicing.ts b/src/libs/actions/TravelInvoicing.ts index bf13213e4abd1..78199ce7837ce 100644 --- a/src/libs/actions/TravelInvoicing.ts +++ b/src/libs/actions/TravelInvoicing.ts @@ -1,7 +1,8 @@ import Onyx from 'react-native-onyx'; import type {OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; -import type {OpenPolicyTravelPageParams, SetTravelInvoicingSettlementAccountParams} from '@libs/API/parameters'; +import type {OpenPolicyTravelPageParams, SetTravelInvoicingSettlementAccountParams, UpdateTravelInvoicingSettlementFrequencyParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import {getTravelInvoicingCardSettingsKey} from '@libs/TravelInvoicingUtils'; @@ -57,6 +58,21 @@ function openPolicyTravelPage(policyID: string, workspaceAccountID: number) { function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountID: number, settlementBankAccountID: number, previousPaymentBankAccountID?: number) { const cardSettingsKey = getTravelInvoicingCardSettingsKey(workspaceAccountID); + // Determine if we need to set the default frequency: + // - When enabling for the first time (no previous account): default to monthly + // - When disabling (zero bank account): clear the frequency + // - When changing accounts (previous account exists): don't touch frequency (undefined = no change) + const isFirstEnable = settlementBankAccountID !== 0 && !previousPaymentBankAccountID; + const isDisabling = settlementBankAccountID === 0; + + let monthlySettlementDate: Date | null | undefined; + if (isFirstEnable) { + monthlySettlementDate = new Date(); + } else if (isDisabling) { + monthlySettlementDate = null; + } + // Otherwise leave undefined - Onyx.merge will not overwrite existing value + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -64,8 +80,14 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI value: { paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, + monthlySettlementDate, isLoading: true, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + pendingFields: { + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + paymentBankAccountID: null, + }, }, }, ]; @@ -77,8 +99,14 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI value: { paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID: null, + monthlySettlementDate, isLoading: false, - pendingAction: null, + pendingFields: { + paymentBankAccountID: null, + }, + errorFields: { + paymentBankAccountID: null, + }, }, }, ]; @@ -91,9 +119,14 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI // Keep the attempted value visible (grayed out) until error is dismissed paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, + monthlySettlementDate, isLoading: false, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + pendingFields: { + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + paymentBankAccountID: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, }, }, ]; @@ -112,11 +145,113 @@ function setTravelInvoicingSettlementAccount(policyID: string, workspaceAccountI */ function clearTravelInvoicingSettlementAccountErrors(workspaceAccountID: number, paymentBankAccountID: number | null) { Onyx.merge(getTravelInvoicingCardSettingsKey(workspaceAccountID), { - errors: null, - pendingAction: null, paymentBankAccountID, previousPaymentBankAccountID: null, + pendingFields: { + paymentBankAccountID: null, + }, + errorFields: { + paymentBankAccountID: null, + }, + }); +} + +/** + * Updates the settlement frequency for Travel Invoicing. + * Optimistically updates the monthlySettlementDate based on the selected frequency. + * Supports offline behavior - changes are queued and synced when back online. + */ +function updateTravelInvoiceSettlementFrequency( + policyID: string, + workspaceAccountID: number, + frequency: ValueOf, + currentMonthlySettlementDate?: Date, +) { + const cardSettingsKey = getTravelInvoicingCardSettingsKey(workspaceAccountID); + + // If Monthly, set date (optimistically today). If Daily, set null. + const monthlySettlementDate = frequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY ? new Date() : null; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + monthlySettlementDate, + previousMonthlySettlementDate: currentMonthlySettlementDate, + pendingFields: { + monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + errorFields: { + monthlySettlementDate: null, + }, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + monthlySettlementDate, + previousMonthlySettlementDate: null, + pendingFields: { + monthlySettlementDate: null, + }, + errorFields: { + monthlySettlementDate: null, + }, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: cardSettingsKey, + value: { + monthlySettlementDate, + previousMonthlySettlementDate: currentMonthlySettlementDate ?? null, + pendingFields: { + monthlySettlementDate: null, + }, + errorFields: { + monthlySettlementDate: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + const params: UpdateTravelInvoicingSettlementFrequencyParams = { + policyID, + workspaceAccountID, + settlementFrequency: frequency, + }; + + API.write(WRITE_COMMANDS.UPDATE_TRAVEL_INVOICE_SETTLEMENT_FREQUENCY, params, {optimisticData, successData, failureData}); +} + +/** + * Clears any errors from the Travel Invoicing settlement frequency settings. + */ +function clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID: number, monthlySettlementDate: Date | null | undefined) { + Onyx.merge(getTravelInvoicingCardSettingsKey(workspaceAccountID), { + monthlySettlementDate: monthlySettlementDate ?? null, + previousMonthlySettlementDate: null, + pendingFields: { + monthlySettlementDate: null, + }, + errorFields: { + monthlySettlementDate: null, + }, }); } -export {openPolicyTravelPage, setTravelInvoicingSettlementAccount, clearTravelInvoicingSettlementAccountErrors}; +export { + openPolicyTravelPage, + setTravelInvoicingSettlementAccount, + clearTravelInvoicingSettlementAccountErrors, + clearTravelInvoicingSettlementFrequencyErrors, + updateTravelInvoiceSettlementFrequency, +}; diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index 6d9c69c3941e0..06353ee929b5f 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -12,7 +12,7 @@ import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {openExternalLink} from '@libs/actions/Link'; -import {clearTravelInvoicingSettlementAccountErrors, setTravelInvoicingSettlementAccount} from '@libs/actions/TravelInvoicing'; +import {clearTravelInvoicingSettlementAccountErrors, clearTravelInvoicingSettlementFrequencyErrors, setTravelInvoicingSettlementAccount} from '@libs/actions/TravelInvoicing'; import {getLastFourDigits} from '@libs/BankAccountUtils'; import {getEligibleBankAccountsForCard} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; @@ -76,7 +76,13 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // Settlement account display - show empty if no account is selected const settlementAccountNumber = hasSettlementAccount && settlementAccount?.last4 ? `${CONST.MASKED_PAN_PREFIX}${getLastFourDigits(settlementAccount?.last4 ?? '')}` : ''; // Get any errors from the settlement account update - const hasSettlementAccountError = Object.keys(cardSettings?.errors ?? {}).length > 0; + // Get errors for specific fields + const settlementAccountErrorKey = 'paymentBankAccountID'; + const settlementFrequencyErrorKey = 'monthlySettlementDate'; + const hasSettlementAccountError = !!cardSettings?.errorFields?.[settlementAccountErrorKey]; + const hasSettlementFrequencyError = !!cardSettings?.errorFields?.[settlementFrequencyErrorKey]; + const settlementAccountErrors = hasSettlementAccountError ? cardSettings?.errorFields?.[settlementAccountErrorKey] : null; + const settlementFrequencyErrors = hasSettlementFrequencyError ? cardSettings?.errorFields?.[settlementFrequencyErrorKey] : null; // Bank account eligibility for toggle handler const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); @@ -164,8 +170,8 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, cardSettings?.previousPaymentBankAccountID ?? null)} errorRowStyles={styles.mh2half} errorRowTextStyles={styles.mr3} @@ -181,16 +187,24 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec brickRoadIndicator={hasSettlementAccountError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> - {}} - wrapperStyle={[styles.sectionMenuItemTopDescription]} - titleStyle={styles.textNormalThemeText} - descriptionTextStyle={styles.textLabelSupportingNormal} - shouldShowRightIcon - // brickRoadIndicator={hasDelayedSubmissionError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> + clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, cardSettings?.previousMonthlySettlementDate)} + errorRowStyles={styles.mh2half} + errorRowTextStyles={styles.mr3} + > + Navigation.navigate(ROUTES.WORKSPACE_TRAVEL_SETTINGS_FREQUENCY.getRoute(policyID))} + wrapperStyle={[styles.sectionMenuItemTopDescription]} + titleStyle={styles.textNormalThemeText} + descriptionTextStyle={styles.textLabelSupportingNormal} + shouldShowRightIcon + brickRoadIndicator={hasSettlementFrequencyError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + ), }, diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx new file mode 100644 index 0000000000000..65bff193d94de --- /dev/null +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; +import {updateTravelInvoiceSettlementFrequency} from '@libs/actions/TravelInvoicing'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getTravelSettlementFrequency} from '@libs/TravelInvoicingUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +type WorkspaceTravelInvoicingSettlementFrequencyPageProps = PlatformStackScreenProps; + +type FrequencyItem = ListItem & { + value: ValueOf; +}; + +function WorkspaceTravelInvoicingSettlementFrequencyPage({route}: WorkspaceTravelInvoicingSettlementFrequencyPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params?.policyID; + const workspaceAccountID = useWorkspaceAccountID(policyID); + const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${CONST.TRAVEL.PROGRAM_TRAVEL_US}` as const, {canBeMissing: true}); + + const currentFrequency = getTravelSettlementFrequency(cardSettings); + const frequencies = [CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY, CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY]; + + function getSettlementFrequencyLabel(frequency: ValueOf) { + if (frequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY) { + return translate('workspace.common.frequency.monthly'); + } + if (frequency === CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY) { + return translate('workspace.common.frequency.immediate'); + } + } + + const data = frequencies?.map((frequency) => ({ + text: getSettlementFrequencyLabel(frequency), + value: frequency, + keyForList: frequency, + isSelected: frequency === currentFrequency, + })); + + const selectFrequency = (item: FrequencyItem) => { + updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, item.value, cardSettings?.monthlySettlementDate ? new Date(cardSettings.monthlySettlementDate) : undefined); + Navigation.goBack(); + }; + + return ( + + Navigation.goBack()} + /> + + data={data} + onSelectRow={selectFrequency} + ListItem={RadioListItem} + initiallyFocusedItemKey={currentFrequency} + customListHeaderContent={ + + {translate('workspace.moreFeatures.travel.travelInvoicing.centralInvoicingSection.subsections.settlementFrequencyDescription')} + + } + /> + + ); +} + +export default WorkspaceTravelInvoicingSettlementFrequencyPage; diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts index 6a2c23142497e..f755eae43cf25 100644 --- a/src/types/onyx/ExpensifyCardSettings.ts +++ b/src/types/onyx/ExpensifyCardSettings.ts @@ -17,6 +17,9 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether monthly option should appear in the settlement frequency settings */ isMonthlySettlementAllowed: boolean; + /** The previous monthly settlement date, used for reverting failed updates */ + previousMonthlySettlementDate?: Date; + /** The bank account chosen for the card settlement */ paymentBankAccountID: number; @@ -46,6 +49,9 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Number of the bank account used for the card settlement */ paymentBankAccountNumber?: string; + + /** Collections of form field errors */ + errorFields?: OnyxCommon.ErrorFields; }>; export default ExpensifyCardSettings; diff --git a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx index 4ff078401df04..ef296e6a22cbc 100644 --- a/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx +++ b/tests/ui/WorkspaceTravelInvoicingSectionTest.tsx @@ -50,6 +50,14 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ default: () => ({didScreenTransitionEnd: true}), })); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + getActiveRoute: jest.fn(() => ''), + }, +})); + const mockPolicy: Policy = { ...createRandomPolicy(parseInt(POLICY_ID, 10) || 1), type: CONST.POLICY.TYPE.CORPORATE, @@ -278,5 +286,37 @@ describe('WorkspaceTravelInvoicingSection', () => { // Then the settlement frequency label should be visible expect(screen.getByText('Settlement frequency')).toBeTruthy(); }); + + it('should show correct frequency value and navigate on press', async () => { + // Given Travel Invoicing is configured with Monthly frequency (default if monthlySettlementDate exists) + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, mockPolicy); + await Onyx.merge(travelInvoicingKey, { + paymentBankAccountID: 12345, + remainingLimit: 50000, + currentBalance: 10000, + monthlySettlementDate: '2023-10-01', + }); + await Onyx.merge(bankAccountKey, { + 12345: { + accountData: { + addressName: 'Test Company', + accountNumber: '****1234', + bankAccountID: 12345, + }, + }, + }); + await waitForBatchedUpdatesWithAct(); + }); + + // When rendering the component + renderWorkspaceTravelInvoicingSection(); + + await waitForBatchedUpdatesWithAct(); + + // Then it should display "Monthly" + expect(screen.getByText('Monthly')).toBeTruthy(); + expect(screen.getByText('Settlement frequency')).toBeTruthy(); + }); }); }); diff --git a/tests/unit/TravelInvoicingTest.ts b/tests/unit/TravelInvoicingTest.ts index 343684ebe6dfb..bc70d052e30be 100644 --- a/tests/unit/TravelInvoicingTest.ts +++ b/tests/unit/TravelInvoicingTest.ts @@ -1,10 +1,15 @@ import Onyx from 'react-native-onyx'; -import {clearTravelInvoicingSettlementAccountErrors, setTravelInvoicingSettlementAccount} from '@libs/actions/TravelInvoicing'; +import { + clearTravelInvoicingSettlementAccountErrors, + clearTravelInvoicingSettlementFrequencyErrors, + setTravelInvoicingSettlementAccount, + updateTravelInvoiceSettlementFrequency, +} from '@libs/actions/TravelInvoicing'; // We need to import API because it is used in the tests // eslint-disable-next-line no-restricted-syntax import * as API from '@libs/API'; +import {getTravelInvoicingCardSettingsKey} from '@libs/TravelInvoicingUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; describe('TravelInvoicing', () => { let spyAPIWrite: jest.SpyInstance; @@ -24,7 +29,7 @@ describe('TravelInvoicing', () => { const workspaceAccountID = 456; const settlementBankAccountID = 789; const previousPaymentBankAccountID = 111; - const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${CONST.TRAVEL.PROGRAM_TRAVEL_US}`; + const cardSettingsKey = getTravelInvoicingCardSettingsKey(workspaceAccountID); setTravelInvoicingSettlementAccount(policyID, workspaceAccountID, settlementBankAccountID, previousPaymentBankAccountID); @@ -41,8 +46,13 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, isLoading: true, + pendingFields: expect.objectContaining({ + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }), + errorFields: expect.objectContaining({ + paymentBankAccountID: null, + }), }), }), ]), @@ -52,8 +62,13 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID: null, - pendingAction: null, isLoading: false, + pendingFields: expect.objectContaining({ + paymentBankAccountID: null, + }), + errorFields: expect.objectContaining({ + paymentBankAccountID: null, + }), }), }), ]), @@ -63,9 +78,13 @@ describe('TravelInvoicing', () => { value: expect.objectContaining({ paymentBankAccountID: settlementBankAccountID, previousPaymentBankAccountID, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: expect.objectContaining({}), isLoading: false, + pendingFields: expect.objectContaining({ + paymentBankAccountID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }), + errorFields: expect.objectContaining({ + paymentBankAccountID: expect.anything() as unknown, + }), }), }), ]), @@ -73,18 +92,114 @@ describe('TravelInvoicing', () => { ); }); - it('clearTravelInvoicingSettlementAccountErrors clears errors, resets pendingAction, and restores restored paymentBankAccountID', () => { + it('clearTravelInvoicingSettlementAccountErrors clears errors and pendingFields', () => { const workspaceAccountID = 456; const restoredAccountID = 111; - const cardSettingsKey = `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${workspaceAccountID}_${CONST.TRAVEL.PROGRAM_TRAVEL_US}`; + const cardSettingsKey = getTravelInvoicingCardSettingsKey(workspaceAccountID); clearTravelInvoicingSettlementAccountErrors(workspaceAccountID, restoredAccountID); expect(spyOnyxMerge).toHaveBeenCalledWith(cardSettingsKey, { - errors: null, - pendingAction: null, paymentBankAccountID: restoredAccountID, previousPaymentBankAccountID: null, + pendingFields: { + paymentBankAccountID: null, + }, + errorFields: { + paymentBankAccountID: null, + }, + }); + }); + + it('clearTravelInvoicingSettlementFrequencyErrors clears errors', () => { + const workspaceAccountID = 456; + const cardSettingsKey = getTravelInvoicingCardSettingsKey(workspaceAccountID); + + const monthlySettlementDate = new Date('2026-01-01'); + clearTravelInvoicingSettlementFrequencyErrors(workspaceAccountID, monthlySettlementDate); + + expect(spyOnyxMerge).toHaveBeenCalledWith(cardSettingsKey, { + monthlySettlementDate: monthlySettlementDate ?? null, + previousMonthlySettlementDate: null, + pendingFields: { + monthlySettlementDate: null, + }, + errorFields: { + monthlySettlementDate: null, + }, }); }); + + it('updateTravelInvoiceSettlementFrequency sends correct optimistic, success, and failure data', () => { + const policyID = '123'; + const workspaceAccountID = 456; + const frequency = CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY; + const currentMonthlySettlementDate = new Date('2024-01-01'); + const cardSettingsKey = getTravelInvoicingCardSettingsKey(workspaceAccountID); + + // Set fake time to ensure deterministic optimistic data + const mockDate = new Date('2024-05-20'); + jest.useFakeTimers(); + jest.setSystemTime(mockDate); + + updateTravelInvoiceSettlementFrequency(policyID, workspaceAccountID, frequency, currentMonthlySettlementDate); + + expect(spyAPIWrite).toHaveBeenCalledWith( + 'UpdateTravelInvoiceSettlementFrequency', + { + policyID, + workspaceAccountID, + settlementFrequency: frequency, + }, + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + monthlySettlementDate: mockDate, + previousMonthlySettlementDate: currentMonthlySettlementDate, + pendingFields: expect.objectContaining({ + monthlySettlementDate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }), + errorFields: { + monthlySettlementDate: null, + }, + }), + }), + ]), + successData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + monthlySettlementDate: mockDate, + previousMonthlySettlementDate: null, + pendingFields: expect.objectContaining({ + monthlySettlementDate: null, + }), + errorFields: { + monthlySettlementDate: null, + }, + }), + }), + ]), + failureData: expect.arrayContaining([ + expect.objectContaining({ + key: cardSettingsKey, + value: expect.objectContaining({ + monthlySettlementDate: mockDate, + previousMonthlySettlementDate: currentMonthlySettlementDate, + pendingFields: expect.objectContaining({ + monthlySettlementDate: null, + }), + errorFields: { + monthlySettlementDate: expect.anything() as unknown, + }, + }), + }), + ]), + }), + ); + + jest.useRealTimers(); + }); }); diff --git a/tests/unit/TravelInvoicingUtilsTest.ts b/tests/unit/TravelInvoicingUtilsTest.ts index 99ad12a64a13c..f6886ede9bee2 100644 --- a/tests/unit/TravelInvoicingUtilsTest.ts +++ b/tests/unit/TravelInvoicingUtilsTest.ts @@ -112,9 +112,9 @@ describe('TravelInvoicingUtils', () => { }); describe('getTravelSettlementFrequency', () => { - it('Should return daily when cardSettings is undefined', () => { + it('Should return monthly (default) when cardSettings is undefined', () => { const result = getTravelSettlementFrequency(undefined); - expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.DAILY); + expect(result).toBe(CONST.EXPENSIFY_CARD.FREQUENCY_SETTING.MONTHLY); }); it('Should return daily when monthlySettlementDate is not set', () => {