diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f8600ed9c9ee..85744329d487 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -397,6 +397,8 @@ const ONYXKEYS = { POLICY_CREATE_DISTANCE_RATE_FORM: 'policyCreateDistanceRateForm', POLICY_CREATE_DISTANCE_RATE_FORM_DRAFT: 'policyCreateDistanceRateFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM: 'policyDistanceRateEditForm', + POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM: 'policyDistanceRateTaxReclaimableOnEditForm', + POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM_DRAFT: 'policyDistanceRateTaxReclaimableOnEditFormDraft', POLICY_DISTANCE_RATE_EDIT_FORM_DRAFT: 'policyDistanceRateEditFormDraft', CLOSE_ACCOUNT_FORM: 'closeAccount', CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft', @@ -538,6 +540,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; [ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm; [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM]: FormTypes.PolicyDistanceRateEditForm; + [ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM]: FormTypes.PolicyDistanceRateTaxReclaimableOnEditForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; [ONYXKEYS.FORMS.NEW_CHAT_NAME_FORM]: FormTypes.NewChatNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 61034382fefd..d88fe3170e79 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -796,6 +796,14 @@ const ROUTES = { route: 'settings/workspaces/:policyID/distance-rates/:rateID/edit', getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/edit` as const, }, + WORKSPACE_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-reclaimable/edit', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-reclaimable/edit` as const, + }, + WORKSPACE_DISTANCE_RATE_TAX_RATE_EDIT: { + route: 'settings/workspaces/:policyID/distance-rates/:rateID/tax-rate/edit', + getRoute: (policyID: string, rateID: string) => `settings/workspaces/${policyID}/distance-rates/${rateID}/tax-rate/edit` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6f32f980d6c2..fe6983623a8a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -321,6 +321,8 @@ const SCREENS = { DISTANCE_RATES_SETTINGS: 'Distance_Rates_Settings', DISTANCE_RATE_DETAILS: 'Distance_Rate_Details', DISTANCE_RATE_EDIT: 'Distance_Rate_Edit', + DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT: 'Distance_Rate_Tax_Reclaimable_On_Edit', + DISTANCE_RATE_TAX_RATE_EDIT: 'Distance_Rate_Tax_Rate_Edit', }, EDIT_REQUEST: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 653697e271a7..0a372ad7c718 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2259,6 +2259,8 @@ export default { foreignDefault: 'Foreign currency default', customTaxName: 'Custom tax name', value: 'Value', + taxReclaimableOn: 'Tax reclaimable on', + taxRate: 'Tax rate', error: { taxRateAlreadyExists: 'This tax name is already in use.', valuePercentageRange: 'Please enter a valid percentage between 0 and 100.', @@ -2266,6 +2268,7 @@ export default { deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.', updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.', createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.', + updateTaxClaimableFailureMessage: 'The reclaimable portion must be less than the distance rate amount.', }, deleteTaxConfirmation: 'Are you sure you want to delete this tax?', deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`, @@ -2555,12 +2558,15 @@ export default { centrallyManage: 'Centrally manage rates, choose to track in miles or kilometers, and set a default category.', rate: 'Rate', addRate: 'Add rate', + trackTax: 'Track tax', deleteRates: ({count}: DistanceRateOperationsParams) => `Delete ${Str.pluralize('rate', 'rates', count)}`, enableRates: ({count}: DistanceRateOperationsParams) => `Enable ${Str.pluralize('rate', 'rates', count)}`, disableRates: ({count}: DistanceRateOperationsParams) => `Disable ${Str.pluralize('rate', 'rates', count)}`, enableRate: 'Enable rate', status: 'Status', unit: 'Unit', + taxFeatureNotEnabledMessage: 'Taxes must be enabled on the workspace to use this feature. Head over to ', + changePromptMessage: ' to make that change.', defaultCategory: 'Default category', deleteDistanceRate: 'Delete distance rate', areYouSureDelete: ({count}: DistanceRateOperationsParams) => `Are you sure you want to delete ${Str.pluralize('this rate', 'these rates', count)}?`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 5d184677655e..bbf4327c89b8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2297,6 +2297,8 @@ export default { foreignDefault: 'Moneda extranjera por defecto', customTaxName: 'Nombre del impuesto', value: 'Valor', + taxRate: 'Tasa de impuesto', + taxReclaimableOn: 'Impuesto recuperable en', error: { taxRateAlreadyExists: 'Ya existe un impuesto con este nombre.', customNameRequired: 'El nombre del impuesto es obligatorio.', @@ -2304,6 +2306,7 @@ export default { deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.', updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.', createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.', + updateTaxClaimableFailureMessage: 'La porción recuperable debe ser menor al monto del importe por distancia.', }, deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?', deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, @@ -2594,12 +2597,15 @@ export default { centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto', rate: 'Tasa', addRate: 'Agregar tasa', + trackTax: 'Impuesto de seguimiento', deleteRates: ({count}: DistanceRateOperationsParams) => `Eliminar ${Str.pluralize('tasa', 'tasas', count)}`, enableRates: ({count}: DistanceRateOperationsParams) => `Activar ${Str.pluralize('tasa', 'tasas', count)}`, disableRates: ({count}: DistanceRateOperationsParams) => `Desactivar ${Str.pluralize('tasa', 'tasas', count)}`, enableRate: 'Activar tasa', status: 'Estado', unit: 'Unidad', + taxFeatureNotEnabledMessage: 'Los impuestos deben estar activados en el área de trabajo para poder utilizar esta función. Dirígete a ', + changePromptMessage: ' para hacer ese cambio.', defaultCategory: 'Categoría predeterminada', deleteDistanceRate: 'Eliminar tasa de distancia', areYouSureDelete: ({count}: DistanceRateOperationsParams) => `¿Estás seguro de que quieres eliminar ${Str.pluralize('esta tasa', 'estas tasas', count)}?`, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 6ca80d525682..3f1acaaed466 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -206,7 +206,10 @@ const WRITE_COMMANDS = { ADD_BILLING_CARD_AND_REQUEST_WORKSPACE_OWNER_CHANGE: 'AddBillingCardAndRequestPolicyOwnerChange', SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', + ENABLE_DISTANCE_REQUEST_TAX: 'EnableDistanceRequestTax', UPDATE_POLICY_DISTANCE_RATE_VALUE: 'UpdatePolicyDistanceRateValue', + UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE: 'UpdateDistanceTaxRate', + UPDATE_DISTANCE_TAX_CLAIMABLE_VALUE: 'UpdateDistanceTaxClaimableValue', SET_POLICY_DISTANCE_RATES_ENABLED: 'SetPolicyDistanceRatesEnabled', DELETE_POLICY_DISTANCE_RATES: 'DeletePolicyDistanceRates', DISMISS_TRACK_EXPENSE_ACTIONABLE_WHISPER: 'DismissActionableWhisper', @@ -419,12 +422,15 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; + [WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; // eslint-disable-next-line @typescript-eslint/no-explicit-any [WRITE_COMMANDS.UPDATE_POLICY_CONNECTION_CONFIG]: Parameters.UpdatePolicyConnectionConfigParams; [WRITE_COMMANDS.UPDATE_MANY_POLICY_CONNECTION_CONFIGS]: Parameters.UpdateManyPolicyConnectionConfigurationsParams; [WRITE_COMMANDS.REMOVE_POLICY_CONNECTION]: Parameters.RemovePolicyConnectionParams; [WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_RATE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams; + [WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams; + [WRITE_COMMANDS.UPDATE_DISTANCE_TAX_CLAIMABLE_VALUE]: Parameters.UpdatePolicyDistanceRateValueParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_ENABLED]: Parameters.SetPolicyDistanceRatesEnabledParams; [WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES]: Parameters.DeletePolicyDistanceRatesParams; [WRITE_COMMANDS.DISMISS_TRACK_EXPENSE_ACTIONABLE_WHISPER]: Parameters.DismissTrackExpenseActionableWhisperParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 67890a132d2d..531d53b0f3fe 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -247,6 +247,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateDetailsPage').default as React.ComponentType, [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateEditPage').default as React.ComponentType, + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: () => + require('../../../../pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage').default as React.ComponentType, + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRateTaxRateEditPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS_SETTINGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAG_SETTINGS]: () => require('../../../../pages/workspace/tags/TagSettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAG_LIST_VIEW]: () => require('../../../../pages/workspace/tags/WorkspaceViewTagsPage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 2aaceb96f52a..f91d290639ff 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -79,6 +79,8 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, SCREENS.WORKSPACE.DISTANCE_RATES_SETTINGS, SCREENS.WORKSPACE.DISTANCE_RATE_EDIT, + SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT, + SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT, SCREENS.WORKSPACE.DISTANCE_RATE_DETAILS, ], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b19fbc4c38e0..c4a065cb67d6 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -416,6 +416,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DISTANCE_RATE_EDIT]: { path: ROUTES.WORKSPACE_DISTANCE_RATE_EDIT.route, }, + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: { + path: ROUTES.WORKSPACE_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT.route, + }, + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: { + path: ROUTES.WORKSPACE_DISTANCE_RATE_TAX_RATE_EDIT.route, + }, [SCREENS.WORKSPACE.TAGS_SETTINGS]: { path: ROUTES.WORKSPACE_TAGS_SETTINGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d4ee4aba1f51..c52d36256547 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -220,6 +220,14 @@ type SettingsNavigatorParamList = { policyID: string; rateID: string; }; + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT]: { + policyID: string; + rateID: string; + }; + [SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT]: { + policyID: string; + rateID: string; + }; [SCREENS.WORKSPACE.TAGS_SETTINGS]: { policyID: string; }; diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index db739cc3b8c7..084356df7449 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -9,6 +9,8 @@ import * as NumberUtils from './NumberUtils'; type RateValueForm = typeof ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM | typeof ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM | typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_EDIT_FORM; +type TaxReclaimableForm = typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM; + function validateRateValue(values: FormOnyxValues, currency: string, toLocaleDigit: (arg: string) => string): FormInputErrors { const errors: FormInputErrors = {}; const parsedRate = MoneyRequestUtils.replaceAllDigits(values.rate, toLocaleDigit); @@ -24,6 +26,15 @@ function validateRateValue(values: FormOnyxValues, currency: stri return errors; } +function validateTaxClaimableValue(values: FormOnyxValues, rate: Rate): FormInputErrors { + const errors: FormInputErrors = {}; + + if (rate.rate && Number(values.taxClaimableValue) > rate.rate / 100) { + errors.taxClaimableValue = 'workspace.taxes.error.updateTaxClaimableFailureMessage'; + } + return errors; +} + /** * Get the optimistic rate name in a way that matches BE logic * @param rates @@ -33,4 +44,4 @@ function getOptimisticRateName(rates: Record): string { return existingRatesWithSameName.length ? `${CONST.CUSTOM_UNITS.DEFAULT_RATE} ${existingRatesWithSameName.length}` : CONST.CUSTOM_UNITS.DEFAULT_RATE; } -export {validateRateValue, getOptimisticRateName}; +export {validateRateValue, getOptimisticRateName, validateTaxClaimableValue}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 014ed5da3d48..1d9b3e6ecf2c 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -2253,9 +2253,9 @@ function getTrackExpenseInformation( createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID, actionableWhisperReportActionIDParam: actionableTrackExpenseWhisper?.reportActionID ?? '', onyxData: { - optimisticData: [...optimisticData, ...trackExpenseOnyxData[0]], - successData: [...successData, ...trackExpenseOnyxData[1]], - failureData: [...failureData, ...trackExpenseOnyxData[2]], + optimisticData: optimisticData.concat(trackExpenseOnyxData[0]), + successData: successData.concat(trackExpenseOnyxData[1]), + failureData: failureData.concat(trackExpenseOnyxData[2]), }, }; } diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index f48c3e9033ac..6b0a9c5c3ee0 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -520,6 +520,148 @@ function deletePolicyDistanceRates(policyID: string, customUnit: CustomUnit, rat API.write(WRITE_COMMANDS.DELETE_POLICY_DISTANCE_RATES, params, {optimisticData, successData, failureData}); } +function updateDistanceTaxClaimableValue(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { + const currentRates = customUnit.rates; + const optimisticRates: Record = {}; + const successRates: Record = {}; + const failureRates: Record = {}; + const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); + + for (const rateID of Object.keys(customUnit.rates)) { + if (rateIDs.includes(rateID)) { + const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); + optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; + successRates[rateID] = {...foundRate, pendingFields: {rate: null}}; + failureRates[rateID] = { + ...currentRates[rateID], + pendingFields: {rate: null}, + errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + }; + } + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: optimisticRates, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: successRates, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: failureRates, + }, + }, + }, + }, + ]; + + const params: UpdatePolicyDistanceRateValueParams = { + policyID, + customUnitID: customUnit.customUnitID, + customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)), + }; + + API.write(WRITE_COMMANDS.UPDATE_DISTANCE_TAX_CLAIMABLE_VALUE, params, {optimisticData, successData, failureData}); +} + +function updateDistanceTaxRate(policyID: string, customUnit: CustomUnit, customUnitRates: Rate[]) { + const currentRates = customUnit.rates; + const optimisticRates: Record = {}; + const successRates: Record = {}; + const failureRates: Record = {}; + const rateIDs = customUnitRates.map((rate) => rate.customUnitRateID); + + for (const rateID of Object.keys(customUnit.rates)) { + if (rateIDs.includes(rateID)) { + const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); + optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; + successRates[rateID] = {...foundRate, pendingFields: {rate: null}}; + failureRates[rateID] = { + ...currentRates[rateID], + pendingFields: {rate: null}, + errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + }; + } + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: optimisticRates, + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: successRates, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnit.customUnitID]: { + rates: failureRates, + }, + }, + }, + }, + ]; + + const params: UpdatePolicyDistanceRateValueParams = { + policyID, + customUnitID: customUnit.customUnitID, + customUnitRateArray: JSON.stringify(prepareCustomUnitRatesArray(customUnitRates)), + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_DISTANCE_TAX_RATE_VALUE, params, {optimisticData, successData, failureData}); +} + export { enablePolicyDistanceRates, openPolicyDistanceRatesPage, @@ -532,6 +674,8 @@ export { updatePolicyDistanceRateValue, setPolicyDistanceRatesEnabled, deletePolicyDistanceRates, + updateDistanceTaxClaimableValue, + updateDistanceTaxRate, }; export type {NewCustomUnit}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 2625e7d60dff..b4933248a6a6 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1396,7 +1396,9 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount // Convert to object with each key containing the error. We don’t // need to remove the members since that is handled by onClose of OfflineWithFeedback. - value: failureMembersState, + value: { + employeeList: failureMembersState, + }, }, ...membersChats.onyxFailureData, ...announceRoomMembers.onyxFailureData, @@ -3500,6 +3502,62 @@ function enablePolicyWorkflows(policyID: string, enabled: boolean) { } } +function enableDistanceRequestTax(policyID: string, customUnitName: string, customUnitID: string, attributes: Attributes) { + const policy = getPolicy(policyID); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + attributes, + }, + }, + pendingFields: { + customUnits: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + customUnits: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + customUnits: { + [customUnitID]: { + attributes: policy.customUnits ? policy.customUnits[customUnitID].attributes : null, + }, + }, + }, + }, + ], + }; + + const params = { + policyID, + customUnit: JSON.stringify({ + customUnitName, + customUnitID, + attributes, + }), + }; + API.write(WRITE_COMMANDS.ENABLE_DISTANCE_REQUEST_TAX, params, onyxData); +} + function openPolicyMoreFeaturesPage(policyID: string) { const params: OpenPolicyMoreFeaturesPageParams = {policyID}; @@ -3734,6 +3792,7 @@ export { enablePolicyReportFields, enablePolicyTaxes, enablePolicyWorkflows, + enableDistanceRequestTax, openPolicyMoreFeaturesPage, generateCustomUnitID, clearQBOErrorField, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index 012ba712d0aa..7f604efd20ee 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -9,6 +9,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import Switch from '@components/Switch'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -41,14 +42,17 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail const {windowWidth} = useWindowDimensions(); const [isWarningModalVisible, setIsWarningModalVisible] = useState(false); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - const policyID = route.params.policyID; const rateID = route.params.rateID; const customUnits = policy?.customUnits ?? {}; const customUnit = customUnits[Object.keys(customUnits)[0]]; const rate = customUnit?.rates[rateID]; const currency = rate?.currency ?? CONST.CURRENCY.USD; + const taxClaimablePercentage = rate.attributes?.taxClaimablePercentage; + const taxRateExternalID = rate.attributes?.taxRateExternalID; + const isDistanceTrackTaxEnabled = !!customUnit?.attributes?.taxEnabled; + const taxRate = taxRateExternalID ? `${policy?.taxRates?.taxes[taxRateExternalID].name} (${policy?.taxRates?.taxes[taxRateExternalID].value})` : ''; // Rates can be disabled or deleted as long as in the remaining rates there is always at least one enabled rate and there are no pending delete action const canDisableOrDeleteRate = Object.values(customUnit?.rates ?? {}).some( (distanceRate: Rate) => distanceRate?.enabled && rateID !== distanceRate?.customUnitRateID && distanceRate?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, @@ -62,6 +66,12 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail const editRateValue = () => { Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_EDIT.getRoute(policyID, rateID)); }; + const editTaxReclaimableValue = () => { + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT.getRoute(policyID, rateID)); + }; + const editTaxRateValue = () => { + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_TAX_RATE_EDIT.getRoute(policyID, rateID)); + }; const toggleRate = () => { if (!rate?.enabled || canDisableOrDeleteRate) { @@ -78,6 +88,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail }; const rateValueToDisplay = CurrencyUtils.convertAmountToDisplayString(rate?.rate, currency); + const taxClaimableValueToDisplay = taxClaimablePercentage && rate.rate ? CurrencyUtils.convertAmountToDisplayString(taxClaimablePercentage * rate.rate, currency) : ''; const unitToDisplay = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`); const threeDotsMenuItems = [ @@ -115,7 +126,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail threeDotsMenuItems={threeDotsMenuItems} threeDotsAnchorPosition={styles.threeDotsPopoverOffset(windowWidth)} /> - + + {isDistanceTrackTaxEnabled && ( + clearErrorFields('attributes')} + > + + + + + )} + {isDistanceTrackTaxEnabled && ( + clearErrorFields('attributes')} + > + + + )} setIsWarningModalVisible(false)} isVisible={isWarningModalVisible} @@ -163,7 +207,7 @@ function PolicyDistanceRateDetailsPage({policy, route}: PolicyDistanceRateDetail cancelText={translate('common.cancel')} danger /> - + ); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxRateEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxRateEditPage.tsx new file mode 100644 index 000000000000..e8b58b70f474 --- /dev/null +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxRateEditPage.tsx @@ -0,0 +1,90 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useMemo} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy'; +import withPolicy from '@pages/workspace/withPolicy'; +import * as DistanceRate from '@userActions/Policy/DistanceRate'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type ListItemType = { + value: string; + text: string; + isSelected: boolean; + keyForList: string; +}; + +type PolicyDistanceRateTaxRateEditPageProps = WithPolicyOnyxProps & StackScreenProps; + +function PolicyDistanceRateTaxRateEditPage({route, policy}: PolicyDistanceRateTaxRateEditPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const policyID = route.params.policyID; + const rateID = route.params.rateID; + const customUnits = policy?.customUnits ?? {}; + const customUnit = customUnits[Object.keys(customUnits)[0]]; + const rate = customUnit?.rates[rateID]; + const taxRateExternalID = rate.attributes?.taxRateExternalID; + const taxRateItems: ListItemType[] = useMemo(() => { + const taxes = policy?.taxRates?.taxes; + const result = Object.entries(taxes ?? {}).map(([key, value]) => ({ + value: key, + text: `${value.name} (${value.value})`, + isSelected: taxRateExternalID === key, + keyForList: key, + })); + return result; + }, [policy, taxRateExternalID]); + + const onTaxRateChange = (newTaxRate: ListItemType) => { + DistanceRate.updateDistanceTaxRate(policyID, customUnit, [ + { + ...rate, + attributes: { + ...rate.attributes, + taxRateExternalID: newTaxRate.value, + }, + }, + ]); + Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATE_DETAILS.getRoute(policyID, rateID)); + }; + + return ( + + + + item.isSelected)?.keyForList} + /> + + + ); +} + +PolicyDistanceRateTaxRateEditPage.displayName = 'PolicyDistanceRateTaxRateEditPage'; + +export default withPolicy(PolicyDistanceRateTaxRateEditPage); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx new file mode 100644 index 000000000000..7be33360de27 --- /dev/null +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx @@ -0,0 +1,102 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {validateTaxClaimableValue} from '@libs/PolicyDistanceRatesUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy'; +import withPolicy from '@pages/workspace/withPolicy'; +import * as DistanceRate from '@userActions/Policy/DistanceRate'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/PolicyDistanceRateTaxReclaimableOnEditForm'; + +type PolicyDistanceRateTaxReclaimableEditPageProps = WithPolicyOnyxProps & StackScreenProps; + +function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanceRateTaxReclaimableEditPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + + const policyID = route.params.policyID; + const rateID = route.params.rateID; + const customUnits = policy?.customUnits ?? {}; + const customUnit = customUnits[Object.keys(customUnits)[0]]; + const rate = customUnit.rates[rateID]; + const currency = rate.currency ?? CONST.CURRENCY.USD; + const extraDecimals = 1; + const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; + const currentTaxReclaimableOnValue = rate.attributes?.taxClaimablePercentage && rate.rate ? ((rate.attributes.taxClaimablePercentage * rate.rate) / 100).toFixed(decimals) : ''; + + const submitTaxReclaimableOn = (values: FormOnyxValues) => { + DistanceRate.updateDistanceTaxClaimableValue(policyID, customUnit, [ + { + ...rate, + attributes: { + ...rate.attributes, + taxClaimablePercentage: rate.rate ? (Number(values.taxClaimableValue) * 100) / rate.rate : undefined, + }, + }, + ]); + Navigation.goBack(); + }; + + const validate = useCallback((values: FormOnyxValues) => validateTaxClaimableValue(values, rate), [rate]); + + return ( + + + + + + + + + ); +} + +PolicyDistanceRateTaxReclaimableEditPage.displayName = 'PolicyDistanceRateTaxReclaimableEditPage'; + +export default withPolicy(PolicyDistanceRateTaxReclaimableEditPage); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index 15f3aabd76e0..2f1b9317e0a9 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -6,18 +6,25 @@ import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import type {ListItem} from '@components/SelectionList/types'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import type {UnitItemType} from '@components/UnitPicker'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import * as Category from '@userActions/Policy/Category'; import * as DistanceRate from '@userActions/Policy/DistanceRate'; +import * as Policy from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {CustomUnit} from '@src/types/onyx/Policy'; @@ -37,11 +44,12 @@ type PolicyDistanceRatesSettingsPageProps = PolicyDistanceRatesSettingsPageOnyxP function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: PolicyDistanceRatesSettingsPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const policyID = route.params.policyID; const customUnits = policy?.customUnits ?? {}; const customUnit = customUnits[Object.keys(customUnits)[0]]; const customUnitID = customUnit?.customUnitID ?? ''; + const isDistanceTrackTaxEnabled = !!customUnit?.attributes?.taxEnabled; + const isPolicyTrackTaxEnabled = !!policy?.tax?.trackingEnabled; const defaultCategory = customUnits[customUnitID]?.defaultCategory; const defaultUnit = customUnits[customUnitID]?.attributes.unit; @@ -66,6 +74,10 @@ function PolicyDistanceRatesSettingsPage({policy, policyCategories, route}: Poli DistanceRate.clearPolicyDistanceRatesErrorFields(policyID, customUnitID, {...errorFields, [fieldName]: null}); }; + const onToggleTrackTax = (isOn: boolean) => { + const attributes = {...customUnits[customUnitID].attributes, taxEnabled: isOn}; + Policy.enableDistanceRequestTax(policyID, customUnit.name, customUnitID, attributes); + }; return ( - - clearErrorFields('attributes')} - > - - - {policy?.areCategoriesEnabled && OptionsListUtils.hasEnabledOptions(policyCategories ?? {}) && ( + + clearErrorFields('defaultCategory')} + onClose={() => clearErrorFields('attributes')} > - - )} - + {policy?.areCategoriesEnabled && OptionsListUtils.hasEnabledOptions(policyCategories ?? {}) && ( + clearErrorFields('defaultCategory')} + > + + + )} + + + + {translate('workspace.distanceRates.trackTax')} + + + + {!isPolicyTrackTaxEnabled && ( + + + {translate('workspace.distanceRates.taxFeatureNotEnabledMessage')} + { + Navigation.dismissModal(); + Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + }} + > + {translate('workspace.common.moreFeatures')} + + {translate('workspace.distanceRates.changePromptMessage')} + + + )} + + + ); diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index 56ad756194a0..86d48facc386 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -39,6 +39,8 @@ type PolicyRoute = RouteProp< | typeof SCREENS.WORKSPACE.OWNER_CHANGE_CHECK | typeof SCREENS.WORKSPACE.TAX_EDIT | typeof SCREENS.WORKSPACE.ADDRESS + | typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RATE_EDIT + | typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT >; function getPolicyIDFromRoute(route: PolicyRoute): string { diff --git a/src/types/form/PolicyDistanceRateTaxReclaimableOnEditForm.ts b/src/types/form/PolicyDistanceRateTaxReclaimableOnEditForm.ts new file mode 100644 index 000000000000..b41facf3604f --- /dev/null +++ b/src/types/form/PolicyDistanceRateTaxReclaimableOnEditForm.ts @@ -0,0 +1,18 @@ +import type {ValueOf} from 'type-fest'; +import type Form from './Form'; + +const INPUT_IDS = { + TAX_CLAIMABLE_VALUE: 'taxClaimableValue', +} as const; + +type InputID = ValueOf; + +type PolicyDistanceRateTaxReclaimableOnEditForm = Form< + InputID, + { + [INPUT_IDS.TAX_CLAIMABLE_VALUE]: string; + } +>; + +export type {PolicyDistanceRateTaxReclaimableOnEditForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index abb0147bf170..d56ab69d6a4e 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -46,6 +46,7 @@ export type {WorkspaceTaxNameForm} from './WorkspaceTaxNameForm'; export type {WorkspaceTaxValueForm} from './WorkspaceTaxValueForm'; export type {WorkspaceTaxCustomName} from './WorkspaceTaxCustomName'; export type {PolicyCreateDistanceRateForm} from './PolicyCreateDistanceRateForm'; +export type {PolicyDistanceRateTaxReclaimableOnEditForm} from './PolicyDistanceRateTaxReclaimableOnEditForm'; export type {PolicyDistanceRateEditForm} from './PolicyDistanceRateEditForm'; export type {WalletAdditionalDetailsForm} from './WalletAdditionalDetailsForm'; export type {NewChatNameForm} from './NewChatNameForm';