diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 096da2d06f64..d76c48d60d7b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5108,12 +5108,21 @@ const CONST = { SEK: 'SE', ZAR: 'ZA', }, + // EUR is shared by several supported countries, so an EUR workspace picks the country itself + GOVERNMENT_RATE_SUPPORTED_EUR_COUNTRIES: ['AT', 'BE', 'FI', 'DE', 'NL', 'PT', 'ES'] as const, // Unit each country publishes its rates in GOVERNMENT_RATE_COUNTRY_TO_UNIT: { US: 'mi', GB: 'mi', CA: 'km', AU: 'km', + AT: 'km', + BE: 'km', + FI: 'km', + DE: 'km', + NL: 'km', + PT: 'km', + ES: 'km', NO: 'km', SE: 'km', ZA: 'km', @@ -9889,6 +9898,7 @@ const CONST = { MORE_DROPDOWN: 'WorkspaceDistanceRates-MoreDropdown', BULK_ACTIONS_DROPDOWN: 'WorkspaceDistanceRates-BulkActionsDropdown', UNIT_SELECTOR: 'WorkspaceDistanceRates-UnitSelector', + COUNTRY_SELECTOR: 'WorkspaceDistanceRates-CountrySelector', }, WORKFLOWS: { AUTO_REPORTING_FREQUENCY: 'WorkspaceWorkflows-AutoReportingFrequency', @@ -10384,7 +10394,7 @@ const COUNTRIES_US_BANK_FLOW: string[] = [CONST.COUNTRY.US, CONST.COUNTRY.PR, CO type Country = keyof typeof CONST.ALL_COUNTRIES; /** A country whose government mileage rates Expensify can auto-update */ -type GovernmentRateCountry = ValueOf; +type GovernmentRateCountry = ValueOf | TupleToUnion; type IOUType = ValueOf; type IOUAction = ValueOf; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1e81fda2035f..97c0885388dc 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3070,6 +3070,24 @@ const ROUTES = { return `workspaces/${policyID}/overview/currency${query ? `?${query}` : ''}` as const; }, }, + WORKSPACE_OVERVIEW_CURRENCY_GOVERNMENT_RATE_COUNTRY: { + route: 'workspaces/:policyID/overview/currency/government-rate-country/:currencyCode', + getRoute: ( + policyID: string, + currencyCode: string, + {isForcedToChangeCurrency, shouldStartExpensifyCardEnrollment}: {isForcedToChangeCurrency?: boolean; shouldStartExpensifyCardEnrollment?: boolean} = {}, + ) => { + const params = new URLSearchParams(); + if (isForcedToChangeCurrency) { + params.set('isForcedToChangeCurrency', 'true'); + } + if (shouldStartExpensifyCardEnrollment) { + params.set('shouldStartExpensifyCardEnrollment', 'true'); + } + const query = params.toString(); + return `workspaces/${policyID}/overview/currency/government-rate-country/${currencyCode}${query ? `?${query}` : ''}` as const; + }, + }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { route: 'workspaces/:policyID/accounting/quickbooks-online/export', getRoute: (policyID: string | undefined) => { @@ -3922,6 +3940,10 @@ const ROUTES = { route: 'workspaces/:policyID/distance-rates/settings/unit', getRoute: (policyID: string) => `workspaces/${policyID}/distance-rates/settings/unit` as const, }, + WORKSPACE_DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY: { + route: 'workspaces/:policyID/distance-rates/settings/government-rate-country', + getRoute: (policyID: string) => `workspaces/${policyID}/distance-rates/settings/government-rate-country` as const, + }, WORKSPACE_DISTANCE_RATES_COMMUTER_EXCLUSIONS: { route: 'workspaces/:policyID/distance-rates/settings/commuter-exclusions', getRoute: (policyID: string) => `workspaces/${policyID}/distance-rates/settings/commuter-exclusions` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 3064c5ce5184..c29e2aedf456 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -893,6 +893,7 @@ const SCREENS = { DYNAMIC_TAG_SETTINGS: 'Dynamic_Tag_Settings', DYNAMIC_TAG_LIST_VIEW: 'Dynamic_Tag_List_View', CURRENCY: 'Workspace_Overview_Currency', + CURRENCY_GOVERNMENT_RATE_COUNTRY: 'Workspace_Overview_Currency_Government_Rate_Country', DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS: 'Dynamic_Workspace_Overview_Address', DYNAMIC_WORKSPACE_OVERVIEW_PLAN: 'Dynamic_Workspace_Overview_Plan_Type', WORKFLOWS: 'Workspace_Workflows', @@ -964,6 +965,7 @@ const SCREENS = { CREATE_DISTANCE_RATE_UPGRADE: 'Create_Distance_Rate_Upgrade', DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', DISTANCE_RATES_UNIT: 'Distance_Rates_Unit', + DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY: 'Distance_Rates_Government_Rate_Country', DISTANCE_RATES_COMMUTER_EXCLUSIONS: 'Distance_Rates_Commuter_Exclusions', DISTANCE_RATE_DETAILS: 'Distance_Rate_Details', DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', diff --git a/src/components/GovernmentRateCountrySelector.tsx b/src/components/GovernmentRateCountrySelector.tsx new file mode 100644 index 000000000000..daf5c8b96169 --- /dev/null +++ b/src/components/GovernmentRateCountrySelector.tsx @@ -0,0 +1,97 @@ +// Shared country picker for the government distance rate auto-update flow, used by the distance rates settings page and the currency change page. + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getGovernmentRateCountryOptions} from '@libs/PolicyDistanceRatesUtils'; +import type {Option} from '@libs/searchOptions'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; +import FormAlertWithSubmitButton from './FormAlertWithSubmitButton'; +import Header from './Header'; +import ScreenWrapper from './ScreenWrapper'; +import SelectionList from './SelectionList'; +import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem'; +import Text from './Text'; + +type GovernmentRateCountrySelectorProps = { + /** Header title shown in the composed Header */ + headerTitle: string; + + /** Country that is already stored, shown as selected before the user picks anything */ + initialSelectedCountry?: string; + + /** Renders the offline blocking view instead of the list when the page data is missing */ + isBlocked: boolean; + + onBackButtonPress: () => void; + + /** Called with the picked country when the user saves */ + onSave: (selectedCountry: string) => void; + + /** Optional intro paragraph shown above the list */ + introText?: string; + + testID: string; +}; + +function GovernmentRateCountrySelector({headerTitle, initialSelectedCountry, isBlocked, onBackButtonPress, onSave, introText, testID}: GovernmentRateCountrySelectorProps) { + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + + // The stored country can load after first render, so only an explicit pick overrides it + const [pickedCountry, setPickedCountry] = useState(); + const selectedCountry = pickedCountry ?? initialSelectedCountry; + + const countries = getGovernmentRateCountryOptions(translate, localeCompare, selectedCountry); + + const saveCountry = () => { + if (!selectedCountry) { + return; + } + onSave(selectedCountry); + }; + + const FullPageBlockingView = isBlocked ? FullPageOfflineBlockingView : View; + + return ( + +
+ + +
+ + {!isBlocked && ( + <> + {!!introText && {introText}} + setPickedCountry(option.value)} + shouldSingleExecuteRowSelect + addBottomSafeAreaPadding + footerContent={ + + } + /> + + )} + +
+ ); +} + +export default GovernmentRateCountrySelector; diff --git a/src/hooks/useApplyWorkspaceCurrencyChange.ts b/src/hooks/useApplyWorkspaceCurrencyChange.ts new file mode 100644 index 000000000000..7f85d641dcb8 --- /dev/null +++ b/src/hooks/useApplyWorkspaceCurrencyChange.ts @@ -0,0 +1,96 @@ +import {getExpensifyCardEnrollmentRoute, isCurrencySupportedForECards} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getEligibleExistingBusinessBankAccounts} from '@libs/WorkflowUtils'; + +import {clearCorpayBankAccountFields} from '@userActions/BankAccounts'; +import {clearDraftValues} from '@userActions/FormActions'; +import {isCurrencySupportedForGlobalReimbursement, updateGeneralSettings} from '@userActions/Policy/Policy'; +import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useOnyx from './useOnyx'; +import useReviewWorkspaceSettingsTaskCompletion from './useReviewWorkspaceSettingsTaskCompletion'; + +type ApplyWorkspaceCurrencyChangeOptions = { + /** Whether the currency change is forced by the global reimbursement setup, which continues to bank account connection */ + isForcedToChangeCurrency?: boolean; + /** Whether the currency change should proceed to Expensify Card enrollment */ + shouldStartExpensifyCardEnrollment?: boolean; + /** Country whose government mileage rates should auto-update, needed when several countries share the new currency */ + governmentRateCountry?: string; + /** Where to go back to after the change, when the caller sits deeper than one screen in the stack */ + backTo?: Route; +}; + +/** + * Returns a function that applies a workspace currency change: clears bank account drafts, writes the new currency, + * and re-enables government rate auto-update when a country was chosen for a shared (EUR) currency. + */ +function useApplyWorkspaceCurrencyChange(policy: OnyxEntry) { + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [governmentMileageRates] = useOnyx(ONYXKEYS.GOVERNMENT_MILEAGE_RATES); + const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); + + return ( + currencyCode: string, + {isForcedToChangeCurrency = false, shouldStartExpensifyCardEnrollment = false, governmentRateCountry, backTo}: ApplyWorkspaceCurrencyChangeOptions = {}, + ) => { + if (!policy) { + return; + } + clearDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); + updateGeneralSettings(policy, policy.name ?? '', currencyCode, getReviewWorkspaceSettingsTaskCompletion(), { + governmentRateCountry, + governmentMileageRates: governmentMileageRates ?? [], + }); + clearCorpayBankAccountFields(); + + const isUkEuCurrencySupported = isCurrencySupportedForECards(currencyCode); + const canEnrollNewCardProgram = currencyCode === CONST.CURRENCY.USD || isUkEuCurrencySupported; + if (shouldStartExpensifyCardEnrollment && canEnrollNewCardProgram) { + Navigation.navigate( + getExpensifyCardEnrollmentRoute({ + policyID: policy.id, + currencyCode, + isUkEuCurrencySupported, + bankAccountsList: bankAccountList, + supportedCountriesByCurrency, + achData: reimbursementAccount?.achData, + }), + {forceReplace: true}, + ); + return; + } + + const isSupportedForGlobalReimbursement = isCurrencySupportedForGlobalReimbursement(currencyCode); + if (isForcedToChangeCurrency && isSupportedForGlobalReimbursement) { + const hasValidExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, currencyCode, true).length > 0; + if (hasValidExistingAccounts) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(policy.id, ROUTES.WORKSPACE_WORKFLOWS.getRoute(policy.id))); + return; + } + navigateToBankAccountRoute({ + policyID: policy.id, + backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policy.id), + navigationOptions: {forceReplace: true}, + }); + return; + } + if (backTo) { + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(backTo)); + return; + } + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }; +} + +export default useApplyWorkspaceCurrencyChange; diff --git a/src/languages/de.ts b/src/languages/de.ts index d1ed24f955b3..ae1388640bcf 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7678,9 +7678,32 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, startDate: 'Startdatum', endDate: 'Enddatum', autoGeneratedRateTooltip: 'Dieser Satz wurde automatisch erzeugt.', + automaticRates: 'Automatische Sätze', autoUpdateGovernmentRate: 'Regierungssätze automatisch aktualisieren', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Neue Sätze automatisch erstellen, wenn ${countryPhrase} neue Richtlinien veröffentlicht.`, - governmentRateCountries: {US: 'die Vereinigten Staaten', CA: 'Kanada', GB: 'Großbritannien', AU: 'Australien', NO: 'Norwegen', SE: 'Schweden', ZA: 'Südafrika'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Neue Sätze automatisch erstellen, wenn ${countryPhrase} neue Richtlinien veröffentlicht. Bestehende Sätze werden nicht geändert.`, + governmentRateCountries: { + US: 'die Vereinigten Staaten', + CA: 'Kanada', + GB: 'Großbritannien', + AU: 'Australien', + AT: 'Österreich', + BE: 'Belgien', + FI: 'Finnland', + DE: 'Deutschland', + NL: 'die Niederlande', + PT: 'Portugal', + ES: 'Spanien', + NO: 'Norwegen', + SE: 'Schweden', + ZA: 'Südafrika', + }, + governmentRateCountryGeneric: 'Ihr Land', + governmentRateSourceCountry: 'Quellland der Entfernungssätze', + governmentRateCountrySelectionPrompt: + 'Um die Funktion zur automatischen Aktualisierung staatlicher Entfernungssätze weiterhin zu nutzen, bestätigen Sie bitte, welches Land Sie künftig verwenden möchten.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Achtung! Wenn Sie die Währung des Arbeitsbereichs auf ${currency} ändern, werden stattdessen die staatlichen Kilometersätze verwendet, die ${countryPhrase} veröffentlicht. Möchten Sie wirklich fortfahren?`, }, editor: { descriptionInputLabel: 'Beschreibung', diff --git a/src/languages/el.ts b/src/languages/el.ts index 0f12a1c2795f..2b75c3538587 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7967,9 +7967,32 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ startDateMustBeBeforeEndDate: 'Η ημερομηνία έναρξης πρέπει να είναι πριν από την ημερομηνία λήξης', }, autoGeneratedRateTooltip: 'Αυτός ο συντελεστής δημιουργείται αυτόματα.', + automaticRates: 'Αυτόματες τιμές', autoUpdateGovernmentRate: 'Αυτόματη ενημέρωση κρατικών συντελεστών', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Δημιουργείτε αυτόματα νέες τιμές όταν η ${countryPhrase} δημοσιεύει νέες οδηγίες.`, - governmentRateCountries: {US: 'οι Ηνωμένες Πολιτείες', CA: 'Καναδάς', GB: 'Μεγάλη Βρετανία', AU: 'Αυστραλία', NO: 'Νορβηγία', SE: 'Σουηδία', ZA: 'Νότια Αφρική'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Δημιουργείτε αυτόματα νέες τιμές όταν η ${countryPhrase} δημοσιεύει νέες οδηγίες. Δεν τροποποιεί υπάρχουσες τιμές.`, + governmentRateCountries: { + US: 'οι Ηνωμένες Πολιτείες', + CA: 'Καναδάς', + GB: 'Μεγάλη Βρετανία', + AU: 'Αυστραλία', + AT: 'Αυστρία', + BE: 'Βέλγιο', + FI: 'Φινλανδία', + DE: 'Γερμανία', + NL: 'Ολλανδία', + PT: 'Πορτογαλία', + ES: 'Ισπανία', + NO: 'Νορβηγία', + SE: 'Σουηδία', + ZA: 'Νότια Αφρική', + }, + governmentRateCountryGeneric: 'τη χώρα σας', + governmentRateSourceCountry: 'Χώρα προέλευσης τιμών απόστασης', + governmentRateCountrySelectionPrompt: + 'Για να συνεχίσετε να χρησιμοποιείτε τη λειτουργία αυτόματης ενημέρωσης κρατικών τιμών απόστασης, επιβεβαιώστε ποια χώρα θέλετε να χρησιμοποιείτε στο εξής.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Προσοχή! Η αλλαγή του νομίσματος του χώρου εργασίας σε ${currency} θα χρησιμοποιεί πλέον τις κρατικές χιλιομετρικές τιμές που δημοσιεύει ${countryPhrase}. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;`, }, editor: { descriptionInputLabel: 'Περιγραφή', diff --git a/src/languages/en.ts b/src/languages/en.ts index cdaf690d6634..64f9587f0867 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8137,17 +8137,30 @@ const translations = { startDate: 'Start date', endDate: 'End date', autoGeneratedRateTooltip: 'This rate is auto-generated.', - autoUpdateGovernmentRate: 'Auto-update government rates', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Automatically create new rates when ${countryPhrase} publishes new guidance.`, + automaticRates: 'Automatic rates', + autoUpdateGovernmentRate: 'Auto-update government rate', + autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Automatically create new rates when ${countryPhrase} publishes new guidance. Doesn't modify existing rates.`, governmentRateCountries: { US: 'the United States', CA: 'Canada', GB: 'Great Britain', AU: 'Australia', + AT: 'Austria', + BE: 'Belgium', + FI: 'Finland', + DE: 'Germany', + NL: 'the Netherlands', + PT: 'Portugal', + ES: 'Spain', NO: 'Norway', SE: 'Sweden', ZA: 'South Africa', }, + governmentRateCountryGeneric: 'your country', + governmentRateSourceCountry: 'Distance rates source country', + governmentRateCountrySelectionPrompt: 'To continue using the auto-update government distance rates feature, please confirm which country you want to use going forward.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Heads up! Changing the workspace currency to ${currency} will start using the government mileage rates ${countryPhrase} publishes instead. Are you sure you want to proceed?`, errors: { rateNameRequired: 'Rate name is required', existingRateName: 'A distance rate with this name already exists', diff --git a/src/languages/es.ts b/src/languages/es.ts index 748b800bed7c..3f98e7be6f79 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7695,9 +7695,32 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, startDate: 'Fecha de inicio', endDate: 'Fecha de fin', autoGeneratedRateTooltip: 'Esta tasa se genera automáticamente.', + automaticRates: 'Tarifas automáticas', autoUpdateGovernmentRate: 'Actualizar automáticamente las tasas gubernamentales', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Crea automáticamente nuevas tasas cuando ${countryPhrase} publique nuevas directrices.`, - governmentRateCountries: {US: 'Estados Unidos', CA: 'Canadá', GB: 'Gran Bretaña', AU: 'Australia', NO: 'Noruega', SE: 'Suecia', ZA: 'Sudáfrica'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Crea automáticamente nuevas tasas cuando ${countryPhrase} publique nuevas directrices. No modifica las tasas existentes.`, + governmentRateCountries: { + US: 'Estados Unidos', + CA: 'Canadá', + GB: 'Gran Bretaña', + AU: 'Australia', + AT: 'Austria', + BE: 'Bélgica', + FI: 'Finlandia', + DE: 'Alemania', + NL: 'los Países Bajos', + PT: 'Portugal', + ES: 'España', + NO: 'Noruega', + SE: 'Suecia', + ZA: 'Sudáfrica', + }, + governmentRateCountryGeneric: 'tu país', + governmentRateSourceCountry: 'País de origen de las tasas de distancia', + governmentRateCountrySelectionPrompt: + 'Para seguir usando la función de actualización automática de tasas de distancia gubernamentales, confirma qué país quieres usar en adelante.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `¡Atención! Cambiar la moneda del espacio de trabajo a ${currency} hará que se usen las tasas de kilometraje gubernamentales que publica ${countryPhrase}. ¿Seguro que quieres continuar?`, }, editor: { nameInputLabel: 'Nombre', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ca9973b167f7..251610111e36 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7697,9 +7697,32 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, startDate: 'Date de début', endDate: 'Date de fin', autoGeneratedRateTooltip: 'Ce taux est généré automatiquement.', + automaticRates: 'Tarifs automatiques', autoUpdateGovernmentRate: 'Mettre automatiquement à jour les taux gouvernementaux', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Créer automatiquement de nouveaux taux lorsque ${countryPhrase} publie de nouvelles directives.`, - governmentRateCountries: {US: 'les États-Unis', CA: 'Canada', GB: 'Grande-Bretagne', AU: 'Australie', NO: 'Norvège', SE: 'Suède', ZA: 'Afrique du Sud'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Créer automatiquement de nouveaux taux lorsque ${countryPhrase} publie de nouvelles directives. Ne modifie pas les taux existants.`, + governmentRateCountries: { + US: 'les États-Unis', + CA: 'Canada', + GB: 'Grande-Bretagne', + AU: 'Australie', + AT: "l'Autriche", + BE: 'la Belgique', + FI: 'la Finlande', + DE: "l'Allemagne", + NL: 'les Pays-Bas', + PT: 'le Portugal', + ES: "l'Espagne", + NO: 'Norvège', + SE: 'Suède', + ZA: 'Afrique du Sud', + }, + governmentRateCountryGeneric: 'votre pays', + governmentRateSourceCountry: 'Pays source des taux de distance', + governmentRateCountrySelectionPrompt: + "Pour continuer à utiliser la fonction de mise à jour automatique des taux de distance gouvernementaux, veuillez confirmer le pays que vous souhaitez utiliser à l'avenir.", + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Attention ! Si vous changez la devise de l'espace de travail pour ${currency}, les taux kilométriques gouvernementaux que ${countryPhrase} publie seront utilisés à la place. Voulez-vous vraiment continuer ?`, }, editor: { descriptionInputLabel: 'Description', diff --git a/src/languages/it.ts b/src/languages/it.ts index 784b2a5b01f5..cff470a7923c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7634,9 +7634,32 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, startDate: 'Data di inizio', endDate: 'Data di fine', autoGeneratedRateTooltip: 'Questa tariffa è generata automaticamente.', + automaticRates: 'Tariffe automatiche', autoUpdateGovernmentRate: 'Aggiorna automaticamente le aliquote governative', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Crea automaticamente nuove aliquote quando ${countryPhrase} pubblica nuove linee guida.`, - governmentRateCountries: {US: 'gli Stati Uniti', CA: 'Canada', GB: 'Gran Bretagna', AU: 'Australia', NO: 'Norvegia', SE: 'Svezia', ZA: 'Sudafrica'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Crea automaticamente nuove aliquote quando ${countryPhrase} pubblica nuove linee guida. Non modifica le aliquote esistenti.`, + governmentRateCountries: { + US: 'gli Stati Uniti', + CA: 'Canada', + GB: 'Gran Bretagna', + AU: 'Australia', + AT: "l'Austria", + BE: 'il Belgio', + FI: 'la Finlandia', + DE: 'la Germania', + NL: 'i Paesi Bassi', + PT: 'il Portogallo', + ES: 'la Spagna', + NO: 'Norvegia', + SE: 'Svezia', + ZA: 'Sudafrica', + }, + governmentRateCountryGeneric: 'il tuo paese', + governmentRateSourceCountry: 'Paese di origine delle aliquote chilometriche', + governmentRateCountrySelectionPrompt: + 'Per continuare a usare la funzione di aggiornamento automatico delle aliquote chilometriche governative, conferma quale paese vuoi usare in futuro.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Attenzione! Cambiando la valuta dello spazio di lavoro in ${currency}, verranno utilizzate le aliquote chilometriche governative che ${countryPhrase} pubblica. Sei sicuro di voler procedere?`, }, editor: { descriptionInputLabel: 'Descrizione', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index db8092693314..be2cc65a82f4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7542,9 +7542,31 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から startDate: '開始日', endDate: '終了日', autoGeneratedRateTooltip: 'このレートは自動生成されています。', + automaticRates: '自動レート', autoUpdateGovernmentRate: '政府レートを自動更新', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `${countryPhrase} が新しいガイダンスを公表したときに、新しいレートを自動的に作成します。`, - governmentRateCountries: {US: 'アメリカ合衆国', CA: 'カナダ', GB: 'グレートブリテン', AU: 'オーストラリア', NO: 'ノルウェー', SE: 'スウェーデン', ZA: '南アフリカ'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `${countryPhrase} が新しいガイダンスを公表したときに、新しいレートを自動的に作成します。既存のレートは変更されません。`, + governmentRateCountries: { + US: 'アメリカ合衆国', + CA: 'カナダ', + GB: 'グレートブリテン', + AU: 'オーストラリア', + AT: 'オーストリア', + BE: 'ベルギー', + FI: 'フィンランド', + DE: 'ドイツ', + NL: 'オランダ', + PT: 'ポルトガル', + ES: 'スペイン', + NO: 'ノルウェー', + SE: 'スウェーデン', + ZA: '南アフリカ', + }, + governmentRateCountryGeneric: 'お住まいの国', + governmentRateSourceCountry: '距離レートのソース国', + governmentRateCountrySelectionPrompt: '政府の距離レート自動更新機能を引き続き使用するには、今後使用する国を確認してください。', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `注意!ワークスペースの通貨を${currency}に変更すると、代わりに${countryPhrase}が公表する政府の距離レートが使用されます。続行してもよろしいですか?`, }, editor: { descriptionInputLabel: '説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 95a5a0fe5a03..978ac3337941 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7615,9 +7615,32 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, startDate: 'Startdatum', endDate: 'Einddatum', autoGeneratedRateTooltip: 'Dit tarief is automatisch gegenereerd.', + automaticRates: 'Automatische tarieven', autoUpdateGovernmentRate: 'Overheidspercentages automatisch bijwerken', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Maak automatisch nieuwe tarieven aan wanneer ${countryPhrase} nieuwe richtlijnen publiceert.`, - governmentRateCountries: {US: 'de Verenigde Staten', CA: 'Canada', GB: 'Groot-Brittannië', AU: 'Australië', NO: 'Noorwegen', SE: 'Zweden', ZA: 'Zuid-Afrika'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Maak automatisch nieuwe tarieven aan wanneer ${countryPhrase} nieuwe richtlijnen publiceert. Bestaande tarieven worden niet gewijzigd.`, + governmentRateCountries: { + US: 'de Verenigde Staten', + CA: 'Canada', + GB: 'Groot-Brittannië', + AU: 'Australië', + AT: 'Oostenrijk', + BE: 'België', + FI: 'Finland', + DE: 'Duitsland', + NL: 'Nederland', + PT: 'Portugal', + ES: 'Spanje', + NO: 'Noorwegen', + SE: 'Zweden', + ZA: 'Zuid-Afrika', + }, + governmentRateCountryGeneric: 'uw land', + governmentRateSourceCountry: 'Bronland voor afstandstarieven', + governmentRateCountrySelectionPrompt: + 'Om de functie voor het automatisch bijwerken van overheidsafstandstarieven te blijven gebruiken, bevestig welk land je vanaf nu wilt gebruiken.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Let op! Als je de valuta van de werkruimte wijzigt naar ${currency}, worden voortaan de overheidskilometertarieven gebruikt die ${countryPhrase} publiceert. Weet je zeker dat je door wilt gaan?`, }, editor: { descriptionInputLabel: 'Beschrijving', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 34ff796dcb72..e14879ca94a4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7638,9 +7638,32 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, startDate: 'Data rozpoczęcia', endDate: 'Data zakończenia', autoGeneratedRateTooltip: 'Ta stawka jest generowana automatycznie.', + automaticRates: 'Automatyczne stawki', autoUpdateGovernmentRate: 'Automatycznie aktualizuj stawki rządowe', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Automatycznie twórz nowe stawki, gdy ${countryPhrase} publikuje nowe wytyczne.`, - governmentRateCountries: {US: 'Stany Zjednoczone', CA: 'Kanada', GB: 'Wielka Brytania', AU: 'Australia', NO: 'Norwegia', SE: 'Szwecja', ZA: 'Republika Południowej Afryki'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Automatycznie twórz nowe stawki, gdy ${countryPhrase} publikuje nowe wytyczne. Nie modyfikuje istniejących stawek.`, + governmentRateCountries: { + US: 'Stany Zjednoczone', + CA: 'Kanada', + GB: 'Wielka Brytania', + AU: 'Australia', + AT: 'Austria', + BE: 'Belgia', + FI: 'Finlandia', + DE: 'Niemcy', + NL: 'Holandia', + PT: 'Portugalia', + ES: 'Hiszpania', + NO: 'Norwegia', + SE: 'Szwecja', + ZA: 'Republika Południowej Afryki', + }, + governmentRateCountryGeneric: 'Twój kraj', + governmentRateSourceCountry: 'Kraj pochodzenia stawek za dystans', + governmentRateCountrySelectionPrompt: + 'Aby nadal korzystać z funkcji automatycznej aktualizacji rządowych stawek za dystans, potwierdź, którego kraju chcesz używać w przyszłości.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Uwaga! Zmiana waluty obszaru roboczego na ${currency} spowoduje korzystanie z rządowych stawek kilometrowych, które publikuje ${countryPhrase}. Czy na pewno chcesz kontynuować?`, }, editor: { descriptionInputLabel: 'Opis', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 30c9e1ce70cb..4bb750993e94 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7620,9 +7620,32 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, startDate: 'Data de início', endDate: 'Data de término', autoGeneratedRateTooltip: 'Essa tarifa é gerada automaticamente.', + automaticRates: 'Tarifas automáticas', autoUpdateGovernmentRate: 'Atualizar automaticamente taxas governamentais', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `Crie automaticamente novas taxas quando ${countryPhrase} publicar novas orientações.`, - governmentRateCountries: {US: 'Estados Unidos', CA: 'Canadá', GB: 'Grã-Bretanha', AU: 'Austrália', NO: 'Noruega', SE: 'Suécia', ZA: 'África do Sul'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => + `Crie automaticamente novas taxas quando ${countryPhrase} publicar novas orientações. Não modifica as taxas existentes.`, + governmentRateCountries: { + US: 'Estados Unidos', + CA: 'Canadá', + GB: 'Grã-Bretanha', + AU: 'Austrália', + AT: 'Áustria', + BE: 'Bélgica', + FI: 'Finlândia', + DE: 'Alemanha', + NL: 'os Países Baixos', + PT: 'Portugal', + ES: 'Espanha', + NO: 'Noruega', + SE: 'Suécia', + ZA: 'África do Sul', + }, + governmentRateCountryGeneric: 'seu país', + governmentRateSourceCountry: 'País de origem das taxas de distância', + governmentRateCountrySelectionPrompt: + 'Para continuar usando o recurso de atualização automática de taxas de distância governamentais, confirme qual país você deseja usar daqui em diante.', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `Atenção! Alterar a moeda do espaço de trabalho para ${currency} passará a usar as taxas de quilometragem governamentais que ${countryPhrase} publica. Tem certeza de que deseja continuar?`, }, editor: { descriptionInputLabel: 'Descrição', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 932056dda602..590d98fe4c03 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7360,9 +7360,30 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM startDate: '开始日期', endDate: '结束日期', autoGeneratedRateTooltip: '此费率为自动生成。', + automaticRates: '自动费率', autoUpdateGovernmentRate: '自动更新政府费率', - autoUpdateGovernmentRateDescription: (countryPhrase: string) => `当${countryPhrase}发布新指引时自动创建新费率。`, - governmentRateCountries: {US: '美国', CA: '加拿大', GB: '英国', AU: '澳大利亚', NO: '挪威', SE: '瑞典', ZA: '南非'}, + autoUpdateGovernmentRateDescription: (countryPhrase: string) => `当${countryPhrase}发布新指引时自动创建新费率。不会修改现有费率。`, + governmentRateCountries: { + US: '美国', + CA: '加拿大', + GB: '英国', + AU: '澳大利亚', + AT: '奥地利', + BE: '比利时', + FI: '芬兰', + DE: '德国', + NL: '荷兰', + PT: '葡萄牙', + ES: '西班牙', + NO: '挪威', + SE: '瑞典', + ZA: '南非', + }, + governmentRateCountryGeneric: '您的国家', + governmentRateSourceCountry: '里程费率来源国家', + governmentRateCountrySelectionPrompt: '要继续使用政府里程费率自动更新功能,请确认您以后要使用的国家。', + currencyChangeGovernmentRateWarning: (currency: string, countryPhrase: string) => + `注意!将工作区货币更改为${currency}后,将改用${countryPhrase}发布的政府里程费率。确定要继续吗?`, }, editor: { descriptionInputLabel: '描述', diff --git a/src/libs/API/parameters/SetWorkspaceDistanceAutoUpdateParams.ts b/src/libs/API/parameters/SetWorkspaceDistanceAutoUpdateParams.ts index 951fc06a36d1..9c5f177e0d42 100644 --- a/src/libs/API/parameters/SetWorkspaceDistanceAutoUpdateParams.ts +++ b/src/libs/API/parameters/SetWorkspaceDistanceAutoUpdateParams.ts @@ -4,6 +4,9 @@ type SetWorkspaceDistanceAutoUpdateParams = { /** Stringified sourceRateID -> optimistic customUnitRateID map, so the server persists the same IDs */ optimisticRateIDs?: string; + + /** ISO country code an EUR workspace auto-updates government rates for, since the currency is shared by several supported countries */ + countryCode?: string; }; export default SetWorkspaceDistanceAutoUpdateParams; diff --git a/src/libs/API/parameters/UpdateWorkspaceGeneralSettingsParams.ts b/src/libs/API/parameters/UpdateWorkspaceGeneralSettingsParams.ts index ec4bb13cebc4..1038b02eec72 100644 --- a/src/libs/API/parameters/UpdateWorkspaceGeneralSettingsParams.ts +++ b/src/libs/API/parameters/UpdateWorkspaceGeneralSettingsParams.ts @@ -5,6 +5,12 @@ type UpdateWorkspaceGeneralSettingsParams = { /** Optimistic action ID for the "Review your workspace settings" onboarding task the backend completes as a side effect */ completedTaskReportActionID?: string; + + /** Country whose government mileage rates to enable for a shared (EUR) currency, applied by the backend in the same command */ + governmentRateCountry?: string; + + /** JSON map of source rate ID to client-side optimistic rate ID, so the backend persists the rates with the same IDs */ + optimisticRateIDs?: string; }; export default UpdateWorkspaceGeneralSettingsParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 41ebfca93df0..ebaa95be98ee 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -569,6 +569,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceOverviewClientIDPage').default, [SCREENS.WORKSPACE.SHARE]: () => require('../../../../pages/workspace/WorkspaceOverviewSharePage').default, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../../pages/workspace/WorkspaceOverviewCurrencyPage').default, + [SCREENS.WORKSPACE.CURRENCY_GOVERNMENT_RATE_COUNTRY]: () => require('../../../../pages/workspace/WorkspaceOverviewCurrencyGovernmentRateCountryPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS]: () => require('../../../../pages/workspace/categories/CategorySettingsPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_REQUIRED_FIELDS]: () => require('../../../../pages/workspace/categories/DynamicCategoryRequiredFieldsPage').default, [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, @@ -619,6 +620,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/distanceRates/CreateDistanceRatePage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_UNIT]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateUnitPage').default, + [SCREENS.WORKSPACE.DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY]: () => + require('../../../../pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: () => require('../../../../pages/workspace/distanceRates/PolicyCommuterExclusionsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default, [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateEditPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 6902146a51aa..9e6f267b7184 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -11,6 +11,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.CURRENCY]: { path: ROUTES.WORKSPACE_OVERVIEW_CURRENCY.route, }, + [SCREENS.WORKSPACE.CURRENCY_GOVERNMENT_RATE_COUNTRY]: { + path: ROUTES.WORKSPACE_OVERVIEW_CURRENCY_GOVERNMENT_RATE_COUNTRY.route, + }, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS]: DYNAMIC_ROUTES.WORKSPACE_OVERVIEW_ADDRESS.path, [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_PLAN]: DYNAMIC_ROUTES.WORKSPACE_OVERVIEW_PLAN.path, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_SETUP]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_SETUP.route}, @@ -1186,6 +1189,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DISTANCE_RATES_UNIT]: { path: ROUTES.WORKSPACE_DISTANCE_RATES_UNIT.route, }, + [SCREENS.WORKSPACE.DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY]: { + path: ROUTES.WORKSPACE_DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY.route, + }, [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { path: ROUTES.WORKSPACE_DISTANCE_RATES_COMMUTER_EXCLUSIONS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f88327a4b931..1caa2345c557 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -347,6 +347,12 @@ type SettingsNavigatorParamList = { isForcedToChangeCurrency?: boolean; shouldStartExpensifyCardEnrollment?: boolean; }; + [SCREENS.WORKSPACE.CURRENCY_GOVERNMENT_RATE_COUNTRY]: { + policyID: string; + currencyCode: string; + isForcedToChangeCurrency?: boolean; + shouldStartExpensifyCardEnrollment?: boolean; + }; [SCREENS.WORKSPACE.DYNAMIC_WORKSPACE_OVERVIEW_ADDRESS]: { policyID: string; country?: Country | ''; @@ -811,6 +817,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.DISTANCE_RATES_UNIT]: { policyID: string; }; + [SCREENS.WORKSPACE.DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY]: { + policyID: string; + }; [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { policyID: string; }; diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index f5526617efce..e3ab7bb51911 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -5,7 +5,7 @@ import CONST from '@src/CONST'; import type {GovernmentRateCountry, IOURequestType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; +import type {GovernmentMileageRate, Policy} from '@src/types/onyx'; import type {CustomUnit, Rate, RateAttributes, Unit} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -13,10 +13,13 @@ import type {NullishDeep, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {Option} from './searchOptions'; + import {getMicroSecondOnyxErrorWithTranslationKey} from './ErrorUtils'; import getPermittedDecimalSeparator from './getPermittedDecimalSeparator'; +import Log from './Log'; import {replaceAllDigits} from './MoneyRequestUtils'; -import {parseFloatAnyLocale} from './NumberUtils'; +import {generateHexadecimalValue, parseFloatAnyLocale} from './NumberUtils'; import StringUtils from './StringUtils'; import {isRequiredFulfilled} from './ValidationUtils'; @@ -289,7 +292,7 @@ function isGovernmentRateUnmodified(rate: Rate, currentUnit?: Unit): boolean { return isRateAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate; } -/** The country publishing government mileage rates for a currency, or undefined when we can't auto-update them. */ +/** The country publishing government mileage rates for a currency, or undefined when the currency alone cannot pick one. */ function getGovernmentRateCountryForCurrency(currency?: string): GovernmentRateCountry | undefined { if (!currency) { return undefined; @@ -299,20 +302,47 @@ function getGovernmentRateCountryForCurrency(currency?: string): GovernmentRateC return currencyToCountry[currency]; } +/** Whether the currency is shared by several supported countries, so the workspace chooses the country itself. */ +function isSharedGovernmentRateCurrency(currency?: string): boolean { + return currency === CONST.CURRENCY.EUR; +} + /** Whether we can auto-update government distance rates for this output currency. */ function isCurrencySupportedForAutoUpdate(currency?: string): boolean { - return !!getGovernmentRateCountryForCurrency(currency); + return !!getGovernmentRateCountryForCurrency(currency) || isSharedGovernmentRateCurrency(currency); +} + +/** The country whose government mileage rates this policy auto-updates: the stored choice for EUR, derived from the currency otherwise. */ +function getAutoUpdateGovernmentRateCountry(policy: Policy | null | undefined): GovernmentRateCountry | undefined { + if (!policy) { + return undefined; + } + + if (isSharedGovernmentRateCurrency(policy.outputCurrency)) { + const selectedCountry = policy.autoUpdateGovernmentRateCountry; + return CONST.CUSTOM_UNITS.GOVERNMENT_RATE_SUPPORTED_EUR_COUNTRIES.find((country) => country === selectedCountry); + } + + return getGovernmentRateCountryForCurrency(policy.outputCurrency); +} + +/** The unit a country publishes its mileage rates in. */ +function getExpectedUnitForCountry(country?: string): Unit | undefined { + if (!country) { + return undefined; + } + + const countryToUnit: Partial> = CONST.CUSTOM_UNITS.GOVERNMENT_RATE_COUNTRY_TO_UNIT; + return countryToUnit[country]; } /** The unit the currency's country publishes its mileage rates in. */ function getExpectedUnitForCurrency(currency?: string): Unit | undefined { - const country = getGovernmentRateCountryForCurrency(currency); - return country ? CONST.CUSTOM_UNITS.GOVERNMENT_RATE_COUNTRY_TO_UNIT[country] : undefined; + return getExpectedUnitForCountry(getGovernmentRateCountryForCurrency(currency)); } /** Translation key for the country phrase in the auto-update copy, e.g. "the United States". */ -function getGovernmentRateCountryPhraseTranslationKey(currency?: string): TranslationPaths | undefined { - const country = getGovernmentRateCountryForCurrency(currency); +function getGovernmentRateCountryPhraseTranslationKey(country?: GovernmentRateCountry): TranslationPaths | undefined { if (!country) { return undefined; } @@ -320,6 +350,180 @@ function getGovernmentRateCountryPhraseTranslationKey(currency?: string): Transl return `workspace.distanceRates.governmentRateCountries.${country}`; } +/** Sorted options for the countries that share the EUR government mileage rates. */ +function getGovernmentRateCountryOptions(translate: LocalizedTranslate, localeCompare: (a: string, b: string) => number, selectedCountry?: string): Option[] { + return CONST.CUSTOM_UNITS.GOVERNMENT_RATE_SUPPORTED_EUR_COUNTRIES.map((countryCode) => { + const countryName = translate(`allCountries.${countryCode}` as TranslationPaths); + return { + value: countryCode, + keyForList: countryCode, + text: countryName, + isSelected: selectedCountry === countryCode, + searchValue: StringUtils.sanitizeString(`${countryCode}${countryName}`), + }; + }).sort((a, b) => localeCompare(a.text, b.text)); +} + +/** + * Builds the optimistic, success and failure Onyx data for enabling or disabling government rate auto-update, + * including the optimistic copy of the country's reference rates and the distance unit correction. Shared by the + * SetWorkspaceDistanceAutoUpdate command and the merged currency change in updateGeneralSettings. + * + * `customUnit` can be missing when the server-created custom unit isn't in Onyx yet. The rate copying and unit correction + * are skipped then, and the server response fills them in. + * + * The previous enabled state and country describe the policy before the change, so a failed request restores exactly + * what the server still has. + */ +function buildOnyxDataForGovernmentRateAutoUpdate( + policyID: string, + customUnit: CustomUnit | undefined, + shouldAutoUpdateGovernmentDistanceRates: boolean, + governmentMileageRates: GovernmentMileageRate[], + outputCurrency: string | undefined, + countryCode?: string, + previousAutoUpdateEnabled?: boolean, + previousCountryCode?: string, +): {optimisticRateIDs: Record; onyxData: OnyxData} { + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const customUnitID = customUnit?.customUnitID; + + const optimisticRates: Record = {}; + const clearedRatePendingActions: Record> = {}; + const failureRates: Record = {}; + const optimisticRateIDs: Record = {}; + + if (shouldAutoUpdateGovernmentDistanceRates && customUnit) { + const copiedSourceRateIDs = new Set(Object.values(customUnit.rates ?? {}).map((rate) => rate.attributes?.governmentRate?.sourceRateID)); + + for (const governmentMileageRate of governmentMileageRates) { + // GOVERNMENT_MILEAGE_RATES is one key shared by every policy, so it can still hold another policy's rates + if (governmentMileageRate.currency !== outputCurrency) { + Log.warn('[buildOnyxDataForGovernmentRateAutoUpdate] Skipping a government reference rate loaded for another currency', { + policyID, + outputCurrency, + rateCurrency: governmentMileageRate.currency, + sourceRateID: governmentMileageRate.sourceRateID, + }); + continue; + } + + // Several countries share the EUR currency, so also match the selected country from the sourceRateID prefix + if (countryCode && governmentMileageRate.sourceRateID.split('_').at(0) !== countryCode) { + continue; + } + + // The server de-dupes by sourceRateID, so skip what the policy already has + if (copiedSourceRateIDs.has(governmentMileageRate.sourceRateID)) { + continue; + } + + const customUnitRateID = generateHexadecimalValue(13); + optimisticRateIDs[governmentMileageRate.sourceRateID] = customUnitRateID; + optimisticRates[customUnitRateID] = { + customUnitRateID, + name: governmentMileageRate.name, + rate: governmentMileageRate.rate, + currency: governmentMileageRate.currency, + enabled: governmentMileageRate.enabled ?? true, + startDate: governmentMileageRate.startDate, + ...(governmentMileageRate.endDate ? {endDate: governmentMileageRate.endDate} : {}), + attributes: { + governmentRate: { + sourceRateID: governmentMileageRate.sourceRateID, + rate: governmentMileageRate.rate, + startDate: governmentMileageRate.startDate, + ...(governmentMileageRate.endDate ? {endDate: governmentMileageRate.endDate} : {}), + }, + }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + clearedRatePendingActions[customUnitRateID] = {pendingAction: null}; + failureRates[customUnitRateID] = null; + } + } + + const currentUnit = customUnit?.attributes?.unit; + const expectedUnit = countryCode ? getExpectedUnitForCountry(countryCode) : getExpectedUnitForCurrency(outputCurrency); + const shouldCorrectUnit = shouldAutoUpdateGovernmentDistanceRates && !!expectedUnit && !!currentUnit && currentUnit !== expectedUnit; + + // The failure data restores the state the server still has, so the previous values decide what to write back + const failureAutoUpdateValue = previousAutoUpdateEnabled ? true : null; + + const optimisticCustomUnit: NullishDeep = { + ...(Object.keys(optimisticRates).length > 0 ? {rates: optimisticRates} : {}), + ...(shouldCorrectUnit ? {attributes: {unit: expectedUnit}, pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}} : {}), + }; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + shouldAutoUpdateGovernmentDistanceRates: shouldAutoUpdateGovernmentDistanceRates ? true : null, + ...(countryCode ? {autoUpdateGovernmentRateCountry: countryCode} : {}), + pendingFields: { + shouldAutoUpdateGovernmentDistanceRates: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + ...(countryCode ? {autoUpdateGovernmentRateCountry: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE} : {}), + }, + errorFields: {shouldAutoUpdateGovernmentDistanceRates: null}, + ...(customUnitID && Object.keys(optimisticCustomUnit).length > 0 ? {customUnits: {[customUnitID]: optimisticCustomUnit}} : {}), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + pendingFields: { + shouldAutoUpdateGovernmentDistanceRates: null, + ...(countryCode ? {autoUpdateGovernmentRateCountry: null} : {}), + }, + ...(customUnitID && (Object.keys(clearedRatePendingActions).length > 0 || shouldCorrectUnit) + ? { + customUnits: { + [customUnitID]: { + ...(Object.keys(clearedRatePendingActions).length > 0 ? {rates: clearedRatePendingActions} : {}), + ...(shouldCorrectUnit ? {pendingFields: {attributes: null}} : {}), + }, + }, + } + : {}), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + shouldAutoUpdateGovernmentDistanceRates: failureAutoUpdateValue, + ...(countryCode ? {autoUpdateGovernmentRateCountry: previousCountryCode ?? null} : {}), + pendingFields: { + shouldAutoUpdateGovernmentDistanceRates: null, + ...(countryCode ? {autoUpdateGovernmentRateCountry: null} : {}), + }, + errorFields: {shouldAutoUpdateGovernmentDistanceRates: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + ...(customUnitID && (Object.keys(failureRates).length > 0 || shouldCorrectUnit) + ? { + customUnits: { + [customUnitID]: { + ...(Object.keys(failureRates).length > 0 ? {rates: failureRates} : {}), + ...(shouldCorrectUnit ? {attributes: {unit: currentUnit}, pendingFields: {attributes: null}} : {}), + }, + }, + } + : {}), + }, + }, + ], + }; + + return {optimisticRateIDs, onyxData}; +} + function isCommuterExclusionEnabled(policy: Policy | null | undefined): policy is Policy & {id: string; commuterExclusions: NonNullable} { return !!policy?.id && !!policy.commuterExclusions; } @@ -358,11 +562,15 @@ export { validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates, + buildOnyxDataForGovernmentRateAutoUpdate, getRateStatus, getGovernmentRateCountryForCurrency, + isSharedGovernmentRateCurrency, isCurrencySupportedForAutoUpdate, + getAutoUpdateGovernmentRateCountry, getExpectedUnitForCurrency, getGovernmentRateCountryPhraseTranslationKey, + getGovernmentRateCountryOptions, isCommuterExclusionEnabled, isMapOrGPSRequired, getDistanceExpenseTypeForPolicy, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 81ab3be24593..423b31705bbb 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -20,9 +20,8 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import Log from '@libs/Log'; import {rand64} from '@libs/NumberUtils'; -import {buildOnyxDataForPolicyDistanceRateUpdates, getExpectedUnitForCurrency} from '@libs/PolicyDistanceRatesUtils'; +import {buildOnyxDataForGovernmentRateAutoUpdate, buildOnyxDataForPolicyDistanceRateUpdates} from '@libs/PolicyDistanceRatesUtils'; import {goBackWhenEnableFeature, removePendingFieldsFromCustomUnit} from '@libs/PolicyUtils'; import {getRoom} from '@libs/ReportUtils'; import {getWorkArrangementLabel} from '@libs/WorkArrangementUtils'; @@ -39,8 +38,6 @@ import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; -import {generateCustomUnitID} from './Policy'; - /** * Takes array of customUnitRates and removes pendingFields and errorFields from each rate - we don't want to send those via API */ @@ -837,140 +834,35 @@ function clearPolicyRequireMapOrGPSErrors(policyID: string) { } /** - * Turn the auto-updating of government distance rates on or off for a policy. - * - * On enable, government reference rates for `outputCurrency` are copied optimistically. `optimisticRateIDs` sends the - * client-generated IDs so the persisted rates keep them. The distance unit is corrected in the same write when it doesn't match - * the country's unit - only the unit, not the rate amounts, same as the manual unit change. + * Turns government rate auto-update on or off for a workspace. The rate copying, unit correction and missing `customUnit` + * behavior are documented on buildOnyxDataForGovernmentRateAutoUpdate, which provides the Onyx data used here. */ function setWorkspaceDistanceAutoUpdate( policyID: string, - customUnit: CustomUnit, + customUnit: CustomUnit | undefined, shouldAutoUpdateGovernmentDistanceRates: boolean, governmentMileageRates: GovernmentMileageRate[], outputCurrency: string | undefined, + countryCode?: string, + previousAutoUpdateEnabled?: boolean, + previousCountryCode?: string, ) { - const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const customUnitID = customUnit.customUnitID; - - const optimisticRates: Record = {}; - const clearedRatePendingActions: Record> = {}; - const failureRates: Record = {}; - const optimisticRateIDs: Record = {}; - - if (shouldAutoUpdateGovernmentDistanceRates) { - const copiedSourceRateIDs = new Set(Object.values(customUnit.rates ?? {}).map((rate) => rate.attributes?.governmentRate?.sourceRateID)); - - for (const governmentMileageRate of governmentMileageRates) { - // GOVERNMENT_MILEAGE_RATES is one key shared by every policy, so it can still hold another policy's rates - if (governmentMileageRate.currency !== outputCurrency) { - Log.warn('[setWorkspaceDistanceAutoUpdate] Skipping a government reference rate loaded for another currency', { - policyID, - outputCurrency, - rateCurrency: governmentMileageRate.currency, - sourceRateID: governmentMileageRate.sourceRateID, - }); - continue; - } - - // The server de-dupes by sourceRateID, so skip what the policy already has - if (copiedSourceRateIDs.has(governmentMileageRate.sourceRateID)) { - continue; - } - - const customUnitRateID = generateCustomUnitID(); - optimisticRateIDs[governmentMileageRate.sourceRateID] = customUnitRateID; - optimisticRates[customUnitRateID] = { - customUnitRateID, - name: governmentMileageRate.name, - rate: governmentMileageRate.rate, - currency: governmentMileageRate.currency, - enabled: governmentMileageRate.enabled ?? true, - startDate: governmentMileageRate.startDate, - ...(governmentMileageRate.endDate ? {endDate: governmentMileageRate.endDate} : {}), - attributes: { - governmentRate: { - sourceRateID: governmentMileageRate.sourceRateID, - rate: governmentMileageRate.rate, - startDate: governmentMileageRate.startDate, - ...(governmentMileageRate.endDate ? {endDate: governmentMileageRate.endDate} : {}), - }, - }, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - clearedRatePendingActions[customUnitRateID] = {pendingAction: null}; - failureRates[customUnitRateID] = null; - } - } - - const currentUnit = customUnit.attributes?.unit; - const expectedUnit = getExpectedUnitForCurrency(outputCurrency); - const shouldCorrectUnit = shouldAutoUpdateGovernmentDistanceRates && !!expectedUnit && !!currentUnit && currentUnit !== expectedUnit; - - const optimisticCustomUnit: NullishDeep = { - ...(Object.keys(optimisticRates).length > 0 ? {rates: optimisticRates} : {}), - ...(shouldCorrectUnit ? {attributes: {unit: expectedUnit}, pendingFields: {attributes: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}} : {}), - }; - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: { - shouldAutoUpdateGovernmentDistanceRates: shouldAutoUpdateGovernmentDistanceRates ? true : null, - pendingFields: {shouldAutoUpdateGovernmentDistanceRates: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - errorFields: {shouldAutoUpdateGovernmentDistanceRates: null}, - ...(Object.keys(optimisticCustomUnit).length > 0 ? {customUnits: {[customUnitID]: optimisticCustomUnit}} : {}), - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: { - pendingFields: {shouldAutoUpdateGovernmentDistanceRates: null}, - ...(Object.keys(clearedRatePendingActions).length > 0 || shouldCorrectUnit - ? { - customUnits: { - [customUnitID]: { - ...(Object.keys(clearedRatePendingActions).length > 0 ? {rates: clearedRatePendingActions} : {}), - ...(shouldCorrectUnit ? {pendingFields: {attributes: null}} : {}), - }, - }, - } - : {}), - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: { - shouldAutoUpdateGovernmentDistanceRates: shouldAutoUpdateGovernmentDistanceRates ? null : true, - pendingFields: {shouldAutoUpdateGovernmentDistanceRates: null}, - errorFields: {shouldAutoUpdateGovernmentDistanceRates: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, - ...(Object.keys(failureRates).length > 0 || shouldCorrectUnit - ? { - customUnits: { - [customUnitID]: { - ...(Object.keys(failureRates).length > 0 ? {rates: failureRates} : {}), - ...(shouldCorrectUnit ? {attributes: {unit: currentUnit}, pendingFields: {attributes: null}} : {}), - }, - }, - } - : {}), - }, - }, - ], - }; + const {optimisticRateIDs, onyxData} = buildOnyxDataForGovernmentRateAutoUpdate( + policyID, + customUnit, + shouldAutoUpdateGovernmentDistanceRates, + governmentMileageRates, + outputCurrency, + countryCode, + previousAutoUpdateEnabled, + previousCountryCode, + ); const parameters: SetWorkspaceDistanceAutoUpdateParams = { policyID, shouldAutoUpdateGovernmentDistanceRates, ...(Object.keys(optimisticRateIDs).length > 0 ? {optimisticRateIDs: JSON.stringify(optimisticRateIDs)} : {}), + ...(countryCode ? {countryCode} : {}), }; API.write(WRITE_COMMANDS.SET_WORKSPACE_DISTANCE_AUTO_UPDATE, parameters, onyxData); @@ -979,7 +871,7 @@ function setWorkspaceDistanceAutoUpdate( function clearWorkspaceDistanceAutoUpdateErrors(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { errorFields: {shouldAutoUpdateGovernmentDistanceRates: null}, - pendingFields: {shouldAutoUpdateGovernmentDistanceRates: null}, + pendingFields: {shouldAutoUpdateGovernmentDistanceRates: null, autoUpdateGovernmentRateCountry: null}, }); } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 0c5c8abf010f..52a831575c1c 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -96,6 +96,7 @@ import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; +import {buildOnyxDataForGovernmentRateAutoUpdate} from '@libs/PolicyDistanceRatesUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import { getCustomUnitsForDuplication, @@ -127,6 +128,7 @@ import type { BankAccountList, CardFeeds, DuplicateWorkspace, + GovernmentMileageRate, IntroSelected, InvitedEmailsToAccountIDs, LastPaymentMethod, @@ -166,7 +168,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; -import type {TupleToUnion, ValueOf} from 'type-fest'; +import type {ValueOf} from 'type-fest'; /* eslint-disable max-lines */ import {formatInTimeZone} from 'date-fns-tz'; @@ -356,8 +358,8 @@ function isCurrencySupportedForDirectReimbursement(currency: string) { /** * Checks if the currency is supported for global reimbursement */ -function isCurrencySupportedForGlobalReimbursement(currency: TupleToUnion) { - return CONST.DIRECT_REIMBURSEMENT_CURRENCIES.includes(currency); +function isCurrencySupportedForGlobalReimbursement(currency: string) { + return (CONST.DIRECT_REIMBURSEMENT_CURRENCIES as readonly string[]).includes(currency); } /** Check if the policy has invoicing company details */ @@ -2165,11 +2167,24 @@ function clearAvatarErrors(policyID: string) { }); } +type UpdateGeneralSettingsGovernmentRateOptions = { + /** Country whose government mileage rates to enable for a shared (EUR) currency, set in the same request */ + governmentRateCountry?: string; + /** Reference rates used to copy the country's rates optimistically while the request is in flight */ + governmentMileageRates?: GovernmentMileageRate[]; +}; + /** * Optimistically update the general settings. Set the general settings as pending until the response succeeds. * If the response fails set a general error message. Clear the error message when updating. */ -function updateGeneralSettings(policy: OnyxEntry, name: string, currencyValue?: string, reviewWorkspaceSettingsTaskData: OnboardingTaskCompletionOnyxData = {}) { +function updateGeneralSettings( + policy: OnyxEntry, + name: string, + currencyValue?: string, + reviewWorkspaceSettingsTaskData: OnboardingTaskCompletionOnyxData = {}, + {governmentRateCountry, governmentMileageRates = []}: UpdateGeneralSettingsGovernmentRateOptions = {}, +) { if (!policy?.id) { return; } @@ -2203,6 +2218,21 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency } } + // Enabling government rate auto-update and copying the chosen country's reference rates rides on this request, so the + // whole currency change stays a single API call. The server applies the same copy when it saves the currency. + const governmentRateAutoUpdateData = governmentRateCountry + ? buildOnyxDataForGovernmentRateAutoUpdate( + policy.id, + distanceUnit, + true, + governmentMileageRates, + currency, + governmentRateCountry, + !!policy.shouldAutoUpdateGovernmentDistanceRates, + policy.autoUpdateGovernmentRateCountry, + ) + : undefined; + const optimisticData: Array> = [ { // We use SET because it's faster than merge and avoids a race condition when setting the currency and navigating the user to the Bank account page in confirmCurrencyChangeAndHideModal @@ -2224,6 +2254,8 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency }, name, outputCurrency: currency, + // The server clears the stored government rate country whenever the currency changes, so mirror that here + ...(currencyPendingAction !== undefined && {autoUpdateGovernmentRateCountry: governmentRateCountry ?? null}), ...(customUnitID && { customUnits: { ...policy.customUnits, @@ -2235,6 +2267,7 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency }), }, }, + ...(governmentRateAutoUpdateData?.onyxData.optimisticData ?? []), ]; const finallyData: Array> = [ { @@ -2248,7 +2281,6 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency ...(customUnitID && { customUnits: { [customUnitID]: { - ...distanceUnit, rates: finallyRates, }, }, @@ -2256,6 +2288,9 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency }, }, ]; + // The builder's successData only applies to a successful response. finallyData also runs after a failure, so putting + // it there would merge pendingAction clears back under the rate IDs the failure data just deleted. + const successData: Array> = [...(governmentRateAutoUpdateData?.onyxData.successData ?? [])]; const errorFields: Policy['errorFields'] = { name: namePendingAction && ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), @@ -2273,6 +2308,8 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency errorFields, name: policy.name, outputCurrency: policy.outputCurrency, + // Restore the government rate country that the optimistic currency change cleared + ...(currencyPendingAction !== undefined && {autoUpdateGovernmentRateCountry: policy.autoUpdateGovernmentRateCountry ?? null}), ...(customUnitID && { customUnits: { [customUnitID]: { @@ -2283,6 +2320,7 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency }), }, }, + ...(governmentRateAutoUpdateData?.onyxData.failureData ?? []), ]; const params: UpdateWorkspaceGeneralSettingsParams = { @@ -2290,6 +2328,14 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency workspaceName: name, currency, completedTaskReportActionID: reviewWorkspaceSettingsTaskData.completedTaskReportActionID, + // The server mirrors this copy through its own SetWorkspaceDistanceAutoUpdate call, so send the client-side + // optimistic rate IDs to keep the persisted rates consistent with the optimistic Onyx state. + ...(governmentRateCountry && { + governmentRateCountry, + ...(Object.keys(governmentRateAutoUpdateData?.optimisticRateIDs ?? {}).length > 0 && { + optimisticRateIDs: JSON.stringify(governmentRateAutoUpdateData?.optimisticRateIDs), + }), + }), }; const persistedRequests = PersistedRequests.getAll(); @@ -2314,7 +2360,11 @@ function updateGeneralSettings(policy: OnyxEntry, name: string, currency return; } - API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS, params, withReviewWorkspaceSettingsTaskData({optimisticData, finallyData, failureData}, reviewWorkspaceSettingsTaskData)); + API.write( + WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS, + params, + withReviewWorkspaceSettingsTaskData({optimisticData, successData, finallyData, failureData}, reviewWorkspaceSettingsTaskData), + ); } function updateWorkspaceDescription(policyID: string, description: string, currentDescription: string | undefined, reviewWorkspaceSettingsTaskData: OnboardingTaskCompletionOnyxData = {}) { diff --git a/src/pages/workspace/WorkspaceOverviewCurrencyGovernmentRateCountryPage.tsx b/src/pages/workspace/WorkspaceOverviewCurrencyGovernmentRateCountryPage.tsx new file mode 100644 index 000000000000..e0275e103c07 --- /dev/null +++ b/src/pages/workspace/WorkspaceOverviewCurrencyGovernmentRateCountryPage.tsx @@ -0,0 +1,70 @@ +import GovernmentRateCountrySelector from '@components/GovernmentRateCountrySelector'; + +import useApplyWorkspaceCurrencyChange from '@hooks/useApplyWorkspaceCurrencyChange'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {isSharedGovernmentRateCurrency} from '@libs/PolicyDistanceRatesUtils'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; + +type WorkspaceOverviewCurrencyGovernmentRateCountryPageProps = PlatformStackScreenProps; + +function WorkspaceOverviewCurrencyGovernmentRateCountryPage({route}: WorkspaceOverviewCurrencyGovernmentRateCountryPageProps) { + const {policyID, currencyCode} = route.params; + const isForcedToChangeCurrency = !!route.params?.isForcedToChangeCurrency; + const shouldStartExpensifyCardEnrollment = !!route.params?.shouldStartExpensifyCardEnrollment; + const {translate} = useLocalize(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const applyWorkspaceCurrencyChange = useApplyWorkspaceCurrencyChange(policy); + + const goBackToCurrencyPage = () => Navigation.goBack(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policyID, {isForcedToChangeCurrency, shouldStartExpensifyCardEnrollment})); + + const saveCountry = (selectedCountry: string) => { + if (!policy) { + goBackToCurrencyPage(); + return; + } + + applyWorkspaceCurrencyChange(currencyCode, { + isForcedToChangeCurrency, + shouldStartExpensifyCardEnrollment, + governmentRateCountry: selectedCountry, + backTo: ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID), + }); + }; + + // The page only makes sense when switching to a currency that shares its government rates, so a crafted deep link lands on nothing + const isSharedCurrency = isSharedGovernmentRateCurrency(currencyCode); + + return ( + + + + ); +} + +export default WorkspaceOverviewCurrencyGovernmentRateCountryPage; diff --git a/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx b/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx index 006a13bb7c3a..46e8ef3b4638 100644 --- a/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewCurrencyPage.tsx @@ -1,33 +1,25 @@ import CurrencySelectionList from '@components/CurrencySelectionList'; import type {CurrencyListItem} from '@components/CurrencySelectionList/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; +import useApplyWorkspaceCurrencyChange from '@hooks/useApplyWorkspaceCurrencyChange'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceSettingsTaskCompletion'; import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; -import {getExpensifyCardEnrollmentRoute, isCurrencySupportedForECards} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getGovernmentRateCountryForCurrency, getGovernmentRateCountryPhraseTranslationKey, isSharedGovernmentRateCurrency} from '@libs/PolicyDistanceRatesUtils'; import {goBackFromInvalidPolicy} from '@libs/PolicyUtils'; -import {getEligibleExistingBusinessBankAccounts} from '@libs/WorkflowUtils'; - -import {clearCorpayBankAccountFields} from '@userActions/BankAccounts'; -import {clearDraftValues} from '@userActions/FormActions'; -import {isCurrencySupportedForGlobalReimbursement, updateGeneralSettings} from '@userActions/Policy/Policy'; -import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {TupleToUnion} from 'type-fest'; - import {useRoute} from '@react-navigation/native'; import React from 'react'; @@ -37,60 +29,59 @@ import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; type WorkspaceOverviewCurrencyPageProps = WithPolicyAndFullscreenLoadingProps; -type CurrencyType = TupleToUnion; function WorkspaceOverviewCurrencyPage({policy}: WorkspaceOverviewCurrencyPageProps) { const route = useRoute>(); const {translate} = useLocalize(); const isForcedToChangeCurrency = !!route.params?.isForcedToChangeCurrency; const shouldStartExpensifyCardEnrollment = !!route.params?.shouldStartExpensifyCardEnrollment; - const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); - const [supportedCountriesByCurrency] = useOnyx(ONYXKEYS.CARD_SUPPORTED_COUNTRIES); - const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const shouldBlockCurrencyChange = useShouldBlockCurrencyChange(policy?.id); - const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); + const {showConfirmModal} = useConfirmModal(); + const applyWorkspaceCurrencyChange = useApplyWorkspaceCurrencyChange(policy); + + const applyCurrencySelection = (currencyCode: string) => { + applyWorkspaceCurrencyChange(currencyCode, {isForcedToChangeCurrency, shouldStartExpensifyCardEnrollment}); + }; const onSelectCurrency = (item: CurrencyListItem) => { if (!policy) { return; } - clearDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM); - updateGeneralSettings(policy, policy?.name ?? '', item.currencyCode, getReviewWorkspaceSettingsTaskCompletion()); - clearCorpayBankAccountFields(); - const isUkEuCurrencySupported = isCurrencySupportedForECards(item.currencyCode); - const canEnrollNewCardProgram = item.currencyCode === CONST.CURRENCY.USD || isUkEuCurrencySupported; - if (shouldStartExpensifyCardEnrollment && canEnrollNewCardProgram) { + const isAutoUpdateOn = !!policy.shouldAutoUpdateGovernmentDistanceRates; + const isCurrencyChange = item.currencyCode !== policy.outputCurrency; + + // Several supported countries share EUR, so moving to EUR with auto-update on needs the country choice first + if (isAutoUpdateOn && isCurrencyChange && isSharedGovernmentRateCurrency(item.currencyCode)) { Navigation.navigate( - getExpensifyCardEnrollmentRoute({ - policyID: policy.id, - currencyCode: item.currencyCode, - isUkEuCurrencySupported, - bankAccountsList: bankAccountList, - supportedCountriesByCurrency, - achData: reimbursementAccount?.achData, + ROUTES.WORKSPACE_OVERVIEW_CURRENCY_GOVERNMENT_RATE_COUNTRY.getRoute(policy.id, item.currencyCode, { + isForcedToChangeCurrency, + shouldStartExpensifyCardEnrollment, }), - {forceReplace: true}, ); return; } - if (isForcedToChangeCurrency) { - if (isCurrencySupportedForGlobalReimbursement(item.currencyCode as CurrencyType)) { - const hasValidExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, item.currencyCode, true).length > 0; - if (hasValidExistingAccounts) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(policy.id, ROUTES.WORKSPACE_WORKFLOWS.getRoute(policy.id))); + // Moving to another supported currency with auto-update on swaps which government's rates get copied + const governmentRateCountry = getGovernmentRateCountryForCurrency(item.currencyCode); + if (isAutoUpdateOn && isCurrencyChange && governmentRateCountry) { + const countryPhraseTranslationKey = getGovernmentRateCountryPhraseTranslationKey(governmentRateCountry) ?? 'workspace.distanceRates.governmentRateCountryGeneric'; + showConfirmModal({ + title: translate('workspace.distanceRates.autoUpdateGovernmentRate'), + prompt: translate('workspace.distanceRates.currencyChangeGovernmentRateWarning', item.currencyCode, translate(countryPhraseTranslationKey)), + confirmText: translate('common.confirm'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { return; } - navigateToBankAccountRoute({ - policyID: policy.id, - backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policy.id), - navigationOptions: {forceReplace: true}, - }); - return; - } + applyCurrencySelection(item.currencyCode); + }); + return; } - Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + + applyCurrencySelection(item.currencyCode); }; return ( diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx new file mode 100644 index 000000000000..dfc5ef8ec2b4 --- /dev/null +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx @@ -0,0 +1,77 @@ +import GovernmentRateCountrySelector from '@components/GovernmentRateCountrySelector'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import {isSharedGovernmentRateCurrency} from '@libs/PolicyDistanceRatesUtils'; +import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; + +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import {setWorkspaceDistanceAutoUpdate} from '@userActions/Policy/DistanceRate'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type PolicyDistanceRateGovernmentRateCountryPageProps = PlatformStackScreenProps; + +function PolicyDistanceRateGovernmentRateCountryPage({route}: PolicyDistanceRateGovernmentRateCountryPageProps) { + const policyID = route.params.policyID; + const {translate} = useLocalize(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const [governmentMileageRates] = useOnyx(ONYXKEYS.GOVERNMENT_MILEAGE_RATES); + + const customUnit = getDistanceRateCustomUnit(policy); + const currentCountry = policy?.autoUpdateGovernmentRateCountry; + + const goBackToSettings = () => Navigation.goBack(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)); + + const saveCountry = (selectedCountry: string) => { + if (!customUnit || selectedCountry === currentCountry) { + goBackToSettings(); + return; + } + + // Saving a country turns the auto-update on with that country, or moves an enabled workspace to the new country + setWorkspaceDistanceAutoUpdate( + policyID, + customUnit, + true, + governmentMileageRates ?? [], + policy?.outputCurrency, + selectedCountry, + !!policy?.shouldAutoUpdateGovernmentDistanceRates, + currentCountry, + ); + goBackToSettings(); + }; + + // A deep link on a workspace whose currency maps to a single country would optimistically store a country the server ignores + return ( + + + + ); +} + +export default PolicyDistanceRateGovernmentRateCountryPage; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index d8bd1d0b2779..00aba65e9aa5 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -21,7 +21,14 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import {getGovernmentRateCountryPhraseTranslationKey, isCommuterExclusionEnabled, isCurrencySupportedForAutoUpdate, isMapOrGPSRequired} from '@libs/PolicyDistanceRatesUtils'; +import { + getAutoUpdateGovernmentRateCountry, + getGovernmentRateCountryPhraseTranslationKey, + isCommuterExclusionEnabled, + isCurrencySupportedForAutoUpdate, + isMapOrGPSRequired, + isSharedGovernmentRateCurrency, +} from '@libs/PolicyDistanceRatesUtils'; import {getDistanceRateCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; import {getUnitTranslationKey} from '@libs/WorkspacesSettingsUtils'; @@ -121,8 +128,11 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag clearPolicyDistanceRatesErrorFields(policyID, customUnit.customUnitID, {...errorFields, [fieldName]: null}); }; - const countryPhraseTranslationKey = getGovernmentRateCountryPhraseTranslationKey(policy?.outputCurrency); - const isAutoUpdateSupported = isCurrencySupportedForAutoUpdate(policy?.outputCurrency) && !!customUnit && !!countryPhraseTranslationKey; + const isSharedCurrency = isSharedGovernmentRateCurrency(policy?.outputCurrency); + const autoUpdateCountry = getAutoUpdateGovernmentRateCountry(policy); + const countryPhraseTranslationKey = getGovernmentRateCountryPhraseTranslationKey(autoUpdateCountry); + const isAutoUpdateSupported = isCurrencySupportedForAutoUpdate(policy?.outputCurrency) && !!customUnit; + const shouldShowCountryRow = isAutoUpdateSupported && isSharedCurrency && !!policy?.shouldAutoUpdateGovernmentDistanceRates; const navigateToUpgrade = () => { Navigation.navigate( @@ -153,7 +163,24 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag return; } - setWorkspaceDistanceAutoUpdate(policyID, customUnit, isOn, governmentMileageRates ?? [], policy?.outputCurrency); + // EUR is shared by several supported countries, so the workspace picks its country before anything is written + if (isOn && isSharedCurrency && !autoUpdateCountry) { + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY.getRoute(policyID)); + return; + } + + // The stored country tells the optimistic copy which rates to use, since the mileage rate key is shared by every policy. + // The server ignores the country on disable, so it is only sent when turning on. + setWorkspaceDistanceAutoUpdate( + policyID, + customUnit, + isOn, + governmentMileageRates ?? [], + policy?.outputCurrency, + isOn && isSharedCurrency ? autoUpdateCountry : undefined, + !!policy?.shouldAutoUpdateGovernmentDistanceRates, + isSharedCurrency ? autoUpdateCountry : undefined, + ); }; // Commuter exclusions are computed from the mapped route, so they enforce the requirement on their own. The @@ -227,17 +254,21 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag /> )} + {isAutoUpdateSupported && } + {isAutoUpdateSupported && ( + {translate('workspace.distanceRates.automaticRates')} + )} {isAutoUpdateSupported && ( clearWorkspaceDistanceAutoUpdateErrors(policyID)} > - + @@ -253,11 +284,32 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag /> - {translate('workspace.distanceRates.autoUpdateGovernmentRateDescription', translate(countryPhraseTranslationKey))} + {translate( + 'workspace.distanceRates.autoUpdateGovernmentRateDescription', + translate(countryPhraseTranslationKey ?? 'workspace.distanceRates.governmentRateCountryGeneric'), + )} )} + {shouldShowCountryRow && ( + clearWorkspaceDistanceAutoUpdateErrors(policyID)} + > + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_GOVERNMENT_RATE_COUNTRY.getRoute(policyID))} + interactive={canWriteDistanceRates} + wrapperStyle={[styles.ph5]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.DISTANCE_RATES.COUNTRY_SELECTOR} + /> + + )} + {isAutoUpdateSupported && } , diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 242ed1cc80be..86d294633c52 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -3,6 +3,7 @@ import * as APIModule from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import GoogleTagManager from '@libs/GoogleTagManager'; import {isPolicyPayer} from '@libs/PolicyUtils'; +// eslint-disable-next-line no-restricted-imports -- Namespace import is required to spy on ReportUtils without replacing the production module. import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -11,7 +12,18 @@ import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import {askToJoinPolicy, joinAccessiblePolicy} from '@src/libs/actions/Policy/Member'; import * as Policy from '@src/libs/actions/Policy/Policy'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Onboarding, PolicyJoinMember, PolicyReportField, Policy as PolicyType, Report, ReportAction, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; +import type { + GovernmentMileageRate, + Onboarding, + PolicyJoinMember, + PolicyReportField, + Policy as PolicyType, + Report, + ReportAction, + ReportActions, + Transaction, + TransactionViolations, +} from '@src/types/onyx'; import type {Participant, ReportNextStep} from '@src/types/onyx/Report'; import type Rule from '@src/types/onyx/Rule'; @@ -2259,6 +2271,7 @@ describe('actions/Policy', () => { describe('updateGeneralSettings', () => { const NEW_NAME = 'New Workspace Name'; const NEW_CURRENCY = CONST.CURRENCY.EUR; + const deRate: GovernmentMileageRate = {sourceRateID: 'DE_2026-01-01', currency: 'EUR', name: '2026 Germany', rate: 30, startDate: '2026-01-01', enabled: true}; it('should update workspace name optimistically and succeed', async () => { // Given a workspace and a paused fetch @@ -2403,6 +2416,110 @@ describe('actions/Policy', () => { expect(updatedRate?.pendingFields?.currency).toBeUndefined(); expect(updatedRate?.errorFields?.currency).not.toBeUndefined(); }); + + it('sends the government rate country and the optimistic rate IDs when switching to a shared currency', async () => { + const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve()); + const customUnitID = 'unit_123'; + const policy = { + ...createRandomPolicy(0), + name: 'Workspace', + outputCurrency: CONST.CURRENCY.USD, + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {}, + }, + }, + }; + + Policy.updateGeneralSettings(policy, policy.name, CONST.CURRENCY.EUR, {}, {governmentRateCountry: 'DE', governmentMileageRates: [deRate]}); + + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS); + const params = requireRecord(requireCallArgument(apiCallArgs, 1)); + expect(params.governmentRateCountry).toBe('DE'); + expect(Object.keys(parseJSONRecord(params.optimisticRateIDs))).toEqual(['DE_2026-01-01']); + + apiWriteSpy.mockRestore(); + }); + + it('sets the government rate country and copies its reference rates optimistically when switching to a shared currency', async () => { + const customUnitID = 'unit_123'; + const policy = { + ...createRandomPolicy(0), + name: 'Workspace', + outputCurrency: CONST.CURRENCY.USD, + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: {}, + }, + }, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + mockFetch.pause(); + Policy.updateGeneralSettings(policy, policy.name, CONST.CURRENCY.EUR, {}, {governmentRateCountry: 'DE', governmentMileageRates: [deRate]}); + await waitForBatchedUpdates(); + + // Then the flag turns on, the country is stored and Germany's rates are copied with the unit corrected + let updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.shouldAutoUpdateGovernmentDistanceRates).toBe(true); + expect(updatedPolicy?.autoUpdateGovernmentRateCountry).toBe('DE'); + const copiedRates = Object.values(updatedPolicy?.customUnits?.[customUnitID]?.rates ?? {}); + expect(copiedRates).toHaveLength(1); + expect(copiedRates.at(0)?.attributes?.governmentRate?.sourceRateID).toBe('DE_2026-01-01'); + expect(updatedPolicy?.customUnits?.[customUnitID]?.attributes?.unit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + + await mockFetch.resume(); + + updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.pendingFields?.shouldAutoUpdateGovernmentDistanceRates).toBeUndefined(); + expect(updatedPolicy?.pendingFields?.autoUpdateGovernmentRateCountry).toBeUndefined(); + expect(updatedPolicy?.customUnits?.[customUnitID]?.attributes?.unit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + }); + + it('clears the stored government rate country when the currency changes without a new country', async () => { + const policy = { + ...createRandomPolicy(0), + name: 'Workspace', + outputCurrency: CONST.CURRENCY.EUR, + autoUpdateGovernmentRateCountry: 'DE', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + mockFetch.pause(); + Policy.updateGeneralSettings(policy, policy.name, CONST.CURRENCY.USD); + await waitForBatchedUpdates(); + + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.autoUpdateGovernmentRateCountry ?? null).toBeNull(); + + // Release the paused request so it does not block the queue for the tests that follow + await mockFetch.resume(); + }); + + it('restores the previous flag and country when the currency change with a country fails', async () => { + const policy = { + ...createRandomPolicy(0), + name: 'Workspace', + outputCurrency: CONST.CURRENCY.USD, + shouldAutoUpdateGovernmentDistanceRates: true, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + mockFetch.fail(); + Policy.updateGeneralSettings(policy, policy.name, CONST.CURRENCY.EUR, {}, {governmentRateCountry: 'DE', governmentMileageRates: [deRate]}); + await waitForBatchedUpdates(); + + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.outputCurrency).toBe(CONST.CURRENCY.USD); + expect(updatedPolicy?.shouldAutoUpdateGovernmentDistanceRates).toBe(true); + expect(updatedPolicy?.autoUpdateGovernmentRateCountry ?? null).toBeNull(); + }); }); describe('updateAddress', () => { diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index f25bc3200e03..8bcf3189b86d 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -14,6 +14,7 @@ import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; import createRandomTransaction from '../utils/collections/transaction'; +import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const translate: LocalizedTranslate = (phrase, ...parameters) => { @@ -384,6 +385,103 @@ describe('DistanceRate', () => { // Disabling only stops future propagation expect(onyxPolicy.customUnits?.[customUnitID].rates[existingRateID]).toMatchObject({rate: 72.5, startDate: '2026-01-01'}); }); + + it('should only copy the rates of the selected country when the currency is shared and correct the unit', async () => { + const deRate: GovernmentMileageRate = {sourceRateID: 'DE_2026-01-01', currency: 'EUR', name: '2026 Germany', rate: 30, startDate: '2026-01-01', enabled: true}; + const frRate: GovernmentMileageRate = {sourceRateID: 'FR_2026-01-01', currency: 'EUR', name: '2026 France', rate: 32, startDate: '2026-01-01', enabled: true}; + const usRate: GovernmentMileageRate = {sourceRateID: 'US_2026-01-01', currency: 'USD', name: '2026 United States', rate: 72.5, startDate: '2026-01-01', enabled: true}; + + // A EUR workspace on miles picks Germany, whose rates are published in kilometers + const policy = buildPolicy({outputCurrency: 'EUR'}, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + pause(); + setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [deRate, frRate, usRate], policy.outputCurrency, 'DE'); + await waitForBatchedUpdates(); + + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.autoUpdateGovernmentRateCountry).toBe('DE'); + const sourceRateIDs = Object.values(onyxPolicy.customUnits?.[customUnitID].rates ?? {}).map((rate) => rate.attributes?.governmentRate?.sourceRateID); + expect(sourceRateIDs).toEqual(['DE_2026-01-01']); + expect(onyxPolicy.customUnits?.[customUnitID].attributes?.unit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + }); + + it('should still write the flag and country when the custom unit is not loaded yet', async () => { + const deRate: GovernmentMileageRate = {sourceRateID: 'DE_2026-01-01', currency: 'EUR', name: '2026 Germany', rate: 30, startDate: '2026-01-01', enabled: true}; + const policy = buildPolicy({outputCurrency: 'EUR'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + pause(); + setWorkspaceDistanceAutoUpdate(policy.id, undefined, true, [deRate], policy.outputCurrency, 'DE'); + await waitForBatchedUpdates(); + + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.shouldAutoUpdateGovernmentDistanceRates).toBe(true); + expect(onyxPolicy.autoUpdateGovernmentRateCountry).toBe('DE'); + expect(onyxPolicy.pendingFields?.shouldAutoUpdateGovernmentDistanceRates).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(onyxPolicy.customUnits?.[customUnitID].rates).toStrictEqual({}); + }); + + it('should restore the previous country and keep the flag on when a country change fails', async () => { + const deRate: GovernmentMileageRate = {sourceRateID: 'DE_2026-01-01', currency: 'EUR', name: '2026 Germany', rate: 30, startDate: '2026-01-01', enabled: true}; + const policy = buildPolicy( + {outputCurrency: 'EUR', shouldAutoUpdateGovernmentDistanceRates: true, autoUpdateGovernmentRateCountry: 'DE'}, + CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + ); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + const mockFetch = TestHelper.createGlobalFetchMock(); + const originalFetch = global.fetch; + global.fetch = mockFetch; + mockFetch.fail(); + setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [deRate], policy.outputCurrency, 'FR', true, 'DE'); + await waitForBatchedUpdates(); + global.fetch = originalFetch; + + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.shouldAutoUpdateGovernmentDistanceRates).toBe(true); + expect(onyxPolicy.autoUpdateGovernmentRateCountry).toBe('DE'); + expect(onyxPolicy.pendingFields?.shouldAutoUpdateGovernmentDistanceRates).toBeUndefined(); + expect(onyxPolicy.errorFields?.shouldAutoUpdateGovernmentDistanceRates).not.toBeUndefined(); + }); + + it('should clear the flag and keep the stored country when enabling fails', async () => { + const deRate: GovernmentMileageRate = {sourceRateID: 'DE_2026-01-01', currency: 'EUR', name: '2026 Germany', rate: 30, startDate: '2026-01-01', enabled: true}; + const policy = buildPolicy({outputCurrency: 'EUR', autoUpdateGovernmentRateCountry: 'DE'}, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + const mockFetch = TestHelper.createGlobalFetchMock(); + const originalFetch = global.fetch; + global.fetch = mockFetch; + mockFetch.fail(); + setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [deRate], policy.outputCurrency, 'DE', false, 'DE'); + await waitForBatchedUpdates(); + global.fetch = originalFetch; + + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.shouldAutoUpdateGovernmentDistanceRates).toBeUndefined(); + expect(onyxPolicy.autoUpdateGovernmentRateCountry).toBe('DE'); + }); + + it('should keep the flag on and the stored country when disabling fails', async () => { + const policy = buildPolicy( + {outputCurrency: 'EUR', shouldAutoUpdateGovernmentDistanceRates: true, autoUpdateGovernmentRateCountry: 'DE'}, + CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + ); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + const mockFetch = TestHelper.createGlobalFetchMock(); + const originalFetch = global.fetch; + global.fetch = mockFetch; + mockFetch.fail(); + setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), false, [], policy.outputCurrency, undefined, true, 'DE'); + await waitForBatchedUpdates(); + global.fetch = originalFetch; + + const onyxPolicy = await getPolicyFromOnyx(policy.id); + expect(onyxPolicy.shouldAutoUpdateGovernmentDistanceRates).toBe(true); + expect(onyxPolicy.autoUpdateGovernmentRateCountry).toBe('DE'); + }); }); describe('setEmployeeWorkArrangement', () => { diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index 2e62cc54a0b1..3c53e165c105 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -184,13 +184,13 @@ describe('PolicyDistanceRatesUtils', () => { }); describe('getGovernmentRateCountryPhraseTranslationKey', () => { - it('should return the country phrase key for a supported currency', () => { - expect(getGovernmentRateCountryPhraseTranslationKey('USD')).toBe('workspace.distanceRates.governmentRateCountries.US'); - expect(getGovernmentRateCountryPhraseTranslationKey('GBP')).toBe('workspace.distanceRates.governmentRateCountries.GB'); + it('should return the country phrase key for a supported country', () => { + expect(getGovernmentRateCountryPhraseTranslationKey('US')).toBe('workspace.distanceRates.governmentRateCountries.US'); + expect(getGovernmentRateCountryPhraseTranslationKey('GB')).toBe('workspace.distanceRates.governmentRateCountries.GB'); }); - it('should return undefined for an unsupported currency', () => { - expect(getGovernmentRateCountryPhraseTranslationKey('NZD')).toBeUndefined(); + it('should return undefined for a missing country', () => { + expect(getGovernmentRateCountryPhraseTranslationKey(undefined)).toBeUndefined(); }); });