diff --git a/src/CONST.ts b/src/CONST.ts index 865e97cc0133..cec7cbc0b8a5 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2945,6 +2945,7 @@ const CONST = { PARENT_CHILD_SEPARATOR: ': ', CATEGORY_LIST_THRESHOLD: 8, TAG_LIST_THRESHOLD: 8, + TAX_RATES_LIST_THRESHOLD: 8, COLON: ':', MAPBOX: { PADDING: 50, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 53cd37e71f67..dff3a0aa441b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -250,6 +250,7 @@ const ONYXKEYS = { POLICY_CATEGORIES: 'policyCategories_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', + POLICY_TAX_RATE: 'policyTaxRates_', POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', POLICY_REPORT_FIELDS: 'policyReportFields_', POLICY_RECENTLY_USED_REPORT_FIELDS: 'policyRecentlyUsedReportFields_', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index db17378684d6..49067d1c7b8f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -322,6 +322,16 @@ const ROUTES = { getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`create/${iouType}/amount/${transactionID}/${reportID}/`, backTo), }, + MONEY_REQUEST_STEP_TAX_RATE: { + route: 'create/:iouType/taxRate/:transactionID/:reportID?', + getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo: string) => + getUrlWithBackToParam(`create/${iouType}/taxRate/${transactionID}/${reportID}`, backTo), + }, + MONEY_REQUEST_STEP_TAX_AMOUNT: { + route: 'create/:iouType/taxAmount/:transactionID/:reportID?', + getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo: string) => + getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo), + }, MONEY_REQUEST_STEP_CATEGORY: { route: 'create/:iouType/category/:transactionID/:reportID/', getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c1d2059cd3b0..d86b7f893901 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -130,6 +130,8 @@ const SCREENS = { STEP_SCAN: 'Money_Request_Step_Scan', STEP_TAG: 'Money_Request_Step_Tag', STEP_WAYPOINT: 'Money_Request_Step_Waypoint', + STEP_TAX_AMOUNT: 'Money_Request_Step_Tax_Amount', + STEP_TAX_RATE: 'Money_Request_Step_Tax_Rate', ROOT: 'Money_Request', AMOUNT: 'Money_Request_Amount', PARTICIPANTS: 'Money_Request_Participants', diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index b75f4e2df845..13dce9337673 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -40,6 +40,7 @@ import SettlementButton from './SettlementButton'; import ShowMoreButton from './ShowMoreButton'; import Switch from './Switch'; import tagPropTypes from './tagPropTypes'; +import taxPropTypes from './taxPropTypes'; import Text from './Text'; import transactionPropTypes from './transactionPropTypes'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails'; @@ -164,6 +165,10 @@ const propTypes = { /** Collection of tags attached to a policy */ policyTags: tagPropTypes, + /* Onyx Props */ + /** Collection of tax rates attached to a policy */ + policyTaxRates: taxPropTypes, + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, }; @@ -200,6 +205,7 @@ const defaultProps = { shouldShowSmartScanFields: true, isPolicyExpenseChat: false, iou: iouDefaultProps, + policyTaxRates: {}, }; function MoneyRequestConfirmationList(props) { @@ -241,6 +247,9 @@ function MoneyRequestConfirmationList(props) { // A flag for showing the tags field const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledOptions(_.values(policyTagList))); + // A flag for showing tax fields - tax rate and tax amount + const shouldShowTax = props.isPolicyExpenseChat && props.policy.isTaxTrackingEnabled; + // A flag for showing the billable field const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true); @@ -252,6 +261,11 @@ function MoneyRequestConfirmationList(props) { shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount, props.isDistanceRequest ? currency : props.iouCurrencyCode, ); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode); + + const defaultTaxKey = props.policyTaxRates.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${props.policyTaxRates.taxes[defaultTaxKey].name} (${props.policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; + const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName; const isFocused = useIsFocused(); const [formError, setFormError] = useState(''); @@ -741,6 +755,40 @@ function MoneyRequestConfirmationList(props) { /> )} + {shouldShowTax && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + disabled={didConfirm} + interactive={!props.isReadOnly} + /> + )} + + {shouldShowTax && ( + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()), + ) + } + disabled={didConfirm} + interactive={!props.isReadOnly} + /> + )} + {shouldShowBillable && ( {translate('common.billable')} @@ -777,12 +825,15 @@ export default compose( key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, selector: DistanceRequestUtils.getDefaultMileageRate, }, - draftTransaction: { + splitTransactionDraft: { key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, }, policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, + policyTaxRates: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${policyID}`, + }, iou: { key: ONYXKEYS.IOU, }, diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index ecd95620c498..7ec95aec951f 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -39,6 +39,7 @@ import OptionsSelector from './OptionsSelector'; import SettlementButton from './SettlementButton'; import Switch from './Switch'; import tagPropTypes from './tagPropTypes'; +import taxPropTypes from './taxPropTypes'; import Text from './Text'; import transactionPropTypes from './transactionPropTypes'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails'; @@ -160,6 +161,10 @@ const propTypes = { /** Collection of tags attached to a policy */ policyTags: tagPropTypes, + /* Onyx Props */ + /** Collection of tax rates attached to a policy */ + policyTaxRates: taxPropTypes, + /** Transaction that represents the money request */ transaction: transactionPropTypes, }; @@ -194,6 +199,7 @@ const defaultProps = { isDistanceRequest: false, shouldShowSmartScanFields: true, isPolicyExpenseChat: false, + policyTaxRates: {}, }; function MoneyTemporaryForRefactorRequestConfirmationList({ @@ -235,6 +241,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ session: {accountID}, shouldShowSmartScanFields, transaction, + policyTaxRates, }) { const theme = useTheme(); const styles = useThemeStyles(); @@ -269,6 +276,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ // A flag for showing the tags field const shouldShowTags = isPolicyExpenseChat && OptionsListUtils.hasEnabledOptions(_.values(policyTagList)); + // A flag for showing tax rate + const shouldShowTax = isPolicyExpenseChat && policy.isTaxTrackingEnabled; + // A flag for showing the billable field const shouldShowBillable = !lodashGet(policy, 'disabledFields.defaultBillable', true); @@ -280,6 +290,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : iouAmount, isDistanceRequest ? currency : iouCurrencyCode, ); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction.taxAmount, iouCurrencyCode); + + const defaultTaxKey = policyTaxRates.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${policyTaxRates.taxes[defaultTaxKey].name} (${policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; + const taxRateTitle = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName; const isFocused = useIsFocused(); const [formError, setFormError] = useState(''); @@ -796,6 +811,35 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ rightLabel={canUseViolations && Boolean(policy.requiresTag) ? translate('common.required') : ''} /> )} + {shouldShowTax && ( + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())) + } + disabled={didConfirm} + interactive={!isReadOnly} + /> + )} + + {shouldShowTax && ( + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())) + } + disabled={didConfirm} + interactive={!isReadOnly} + /> + )} {shouldShowBillable && ( {translate('common.billable')} @@ -835,5 +879,8 @@ export default compose( policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, + policyTaxRates: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${policyID}`, + }, }), )(MoneyTemporaryForRefactorRequestConfirmationList); diff --git a/src/components/TaxPicker/index.js b/src/components/TaxPicker/index.js new file mode 100644 index 000000000000..f25a1b84bf64 --- /dev/null +++ b/src/components/TaxPicker/index.js @@ -0,0 +1,90 @@ +import lodashGet from 'lodash/get'; +import React, {useMemo, useState} from 'react'; +import _ from 'underscore'; +import OptionsSelector from '@components/OptionsSelector'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import {defaultProps, propTypes} from './taxPickerPropTypes'; + +function TaxPicker({selectedTaxRate, policyTaxRates, insets, onSubmit}) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const [searchValue, setSearchValue] = useState(''); + + const policyTaxRatesCount = TransactionUtils.getEnabledTaxRateCount(policyTaxRates.taxes); + const isTaxRatesCountBelowThreshold = policyTaxRatesCount < CONST.TAX_RATES_LIST_THRESHOLD; + + const shouldShowTextInput = !isTaxRatesCountBelowThreshold; + + const selectedOptions = useMemo(() => { + if (!selectedTaxRate) { + return []; + } + + return [ + { + name: selectedTaxRate, + enabled: true, + accountID: null, + }, + ]; + }, [selectedTaxRate]); + + const sections = useMemo(() => { + const {policyTaxRatesOptions} = OptionsListUtils.getFilteredOptions( + {}, + {}, + [], + searchValue, + selectedOptions, + [], + false, + false, + false, + {}, + [], + false, + {}, + [], + false, + false, + true, + policyTaxRates, + ); + return policyTaxRatesOptions; + }, [policyTaxRates, searchValue, selectedOptions]); + + const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (taxRate) => taxRate.searchText === selectedTaxRate)[0], 'keyForList'); + + return ( + + ); +} + +TaxPicker.displayName = 'TaxPicker'; +TaxPicker.propTypes = propTypes; +TaxPicker.defaultProps = defaultProps; + +export default TaxPicker; diff --git a/src/components/TaxPicker/taxPickerPropTypes.js b/src/components/TaxPicker/taxPickerPropTypes.js new file mode 100644 index 000000000000..289b4e19aaa4 --- /dev/null +++ b/src/components/TaxPicker/taxPickerPropTypes.js @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import taxPropTypes from '@components/taxPropTypes'; + +const propTypes = { + /** The selected tax rate of an expense */ + selectedTaxRate: PropTypes.string, + + /** Callback to fire when a tax is pressed */ + onSubmit: PropTypes.func.isRequired, + + /* Onyx Props */ + /** Collection of tax rates attached to a policy */ + policyTaxRates: taxPropTypes, +}; + +const defaultProps = { + selectedTaxRate: '', + policyTaxRates: {}, +}; + +export {propTypes, defaultProps}; diff --git a/src/components/taxPropTypes.js b/src/components/taxPropTypes.js new file mode 100644 index 000000000000..98c3a4a75257 --- /dev/null +++ b/src/components/taxPropTypes.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; + +const taxPropTypes = PropTypes.shape({ + /** Name of a tax */ + name: PropTypes.string, + + /** The value of a tax */ + value: PropTypes.string, + + /** Whether the tax is disabled */ + isDisabled: PropTypes.bool, +}); + +export default PropTypes.shape({ + /** Name of the tax */ + name: PropTypes.string, + + /** Default policy tax ID */ + defaultExternalID: PropTypes.string, + + /** Default value of taxes */ + defaultValue: PropTypes.string, + + /** Default foreign policy tax ID */ + foreignTaxDefault: PropTypes.string, + + /** List of tax names and values */ + taxes: PropTypes.objectOf(taxPropTypes), +}); diff --git a/src/components/transactionsDraftPropTypes.js b/src/components/transactionsDraftPropTypes.js new file mode 100644 index 000000000000..ca14c4537aa7 --- /dev/null +++ b/src/components/transactionsDraftPropTypes.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; + +const dataPropTypes = PropTypes.shape({ + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + code: PropTypes.string, +}); + +const taxRatePropTypes = PropTypes.shape({ + text: PropTypes.string.isRequired, + keyForList: PropTypes.string.isRequired, + searchText: PropTypes.string.isRequired, + tooltipText: PropTypes.string.isRequired, + isDisabled: PropTypes.bool, + data: dataPropTypes, +}); + +const transactionsDraftPropTypes = PropTypes.shape({ + taxRate: taxRatePropTypes, + taxAmount: PropTypes.number, +}); + +const taxRateDefaultProps = { + text: '', + keyForList: '', + searchText: '', + tooltipText: '', + isDisabled: false, + data: {}, +}; + +const transactionsDraftDefaultProps = { + taxRate: taxRateDefaultProps, + taxAmount: 0, +}; + +export {transactionsDraftPropTypes, transactionsDraftDefaultProps}; diff --git a/src/languages/en.ts b/src/languages/en.ts index b38c5b42f569..e223dd0a9aaf 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -281,6 +281,7 @@ export default { required: 'Required', showing: 'Showing', of: 'of', + default: 'Default', }, location: { useCurrent: 'Use current location', @@ -537,6 +538,8 @@ export default { }, iou: { amount: 'Amount', + taxAmount: 'Tax amount', + taxRate: 'Tax rate', approve: 'Approve', approved: 'Approved', cash: 'Cash', @@ -608,6 +611,7 @@ export default { error: { invalidCategoryLength: 'The length of the category chosen exceeds the maximum allowed (255). Please choose a different or shorten the category name first.', invalidAmount: 'Please enter a valid amount before continuing.', + invalidTaxAmount: ({amount}: RequestAmountParams) => `Maximum tax amount is ${amount}`, invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', genericCreateFailureMessage: 'Unexpected error requesting money, please try again later', diff --git a/src/languages/es.ts b/src/languages/es.ts index 42461e766b29..42743f43a098 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -271,6 +271,7 @@ export default { required: 'Obligatorio', showing: 'Mostrando', of: 'de', + default: 'Predeterminado', }, location: { useCurrent: 'Usar ubicación actual', @@ -530,6 +531,8 @@ export default { }, iou: { amount: 'Importe', + taxAmount: 'Importe del impuesto', + taxRate: 'Tasa de impuesto', approve: 'Aprobar', approved: 'Aprobado', cash: 'Efectivo', @@ -603,6 +606,7 @@ export default { error: { invalidCategoryLength: 'El largo de la categoría escogida excede el máximo permitido (255). Por favor escoge otra categoría o acorta la categoría primero.', invalidAmount: 'Por favor ingresa un monto válido antes de continuar.', + invalidTaxAmount: ({amount}: RequestAmountParams) => `El importe máximo del impuesto es ${amount}`, invalidSplit: 'La suma de las partes no equivale al monto total', other: 'Error inesperado, por favor inténtalo más tarde', genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde', diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 4829ce115592..b8f5b434cc4a 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -99,7 +99,7 @@ function convertToFrontendAmount(amountAsInt: number): number { * @param currency - IOU currency * @param shouldFallbackToTbd - whether to return 'TBD' instead of a falsy value (e.g. 0.00) */ -function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string { +function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string { if (shouldFallbackToTbd && !amountInCents) { return Localize.translateLocal('common.tbd'); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 51dada669131..151a795a7e36 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -77,6 +77,8 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/request/IOURequestStartPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: () => require('../../../pages/iou/request/step/IOURequestStepConfirmation').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_AMOUNT]: () => require('../../../pages/iou/request/step/IOURequestStepAmount').default as React.ComponentType, + [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: () => require('../../../pages/iou/request/step/IOURequestStepTaxAmountPage').default as React.ComponentType, + [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: () => require('../../../pages/iou/request/step/IOURequestStepTaxRatePage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: () => require('../../../pages/iou/request/step/IOURequestStepCategory').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_CURRENCY]: () => require('../../../pages/iou/request/step/IOURequestStepCurrency').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_DATE]: () => require('../../../pages/iou/request/step/IOURequestStepDate').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts index 2630a2d2afef..daccfa66bbbf 100644 --- a/src/libs/Navigation/linkingConfig.ts +++ b/src/libs/Navigation/linkingConfig.ts @@ -420,6 +420,8 @@ const linkingConfig: LinkingOptions = { }, }, [SCREENS.MONEY_REQUEST.AMOUNT]: ROUTES.MONEY_REQUEST_AMOUNT.route, + [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route, + [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1217e2cfa6b1..cb28b2314b60 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -214,6 +214,18 @@ type MoneyRequestNavigatorParamList = { iouType: string; reportID: string; }; + [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: { + iouType: string; + transactionID: string; + reportID: string; + backTo: string; + }; + [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: { + iouType: string; + transactionID: string; + reportID: string; + backTo: string; + }; [SCREENS.MONEY_REQUEST.MERCHANT]: { iouType: string; reportID: string; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index d9332efdc7a3..fa3538b58ca6 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -1057,6 +1057,159 @@ function getTagListSections(rawTags, recentlyUsedTags, selectedOptions, searchIn return tagSections; } +/** + * Represents the data for a single tax rate. + * + * @property {string} name - The name of the tax rate. + * @property {string} value - The value of the tax rate. + * @property {string} code - The code associated with the tax rate. + * @property {string} modifiedName - This contains the tax name and tax value as one name + * @property {boolean} [isDisabled] - Indicates if the tax rate is disabled. + */ + +/** + * Transforms tax rates to a new object format - to add codes and new name with concatenated name and value. + * + * @param {Object} policyTaxRates - The original tax rates object. + * @returns {Object.>} The transformed tax rates object. + */ +function transformedTaxRates(policyTaxRates) { + const defaultTaxKey = policyTaxRates.defaultExternalID; + const getModifiedName = (data, code) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`; + const taxes = Object.fromEntries(_.map(Object.entries(policyTaxRates.taxes), ([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}])); + return taxes; +} + +/** + * Sorts tax rates alphabetically by name. + * + * @param {Object} taxRates + * @returns {Array} + */ +function sortTaxRates(taxRates) { + const sortedtaxRates = _.chain(taxRates) + .values() + .sortBy((taxRate) => taxRate.name) + .value(); + + return sortedtaxRates; +} + +/** + * Builds the options for taxRates + * + * @param {Object[]} taxRates - an initial object array + * @returns {Array} + */ +function getTaxRatesOptions(taxRates) { + return _.map(taxRates, (taxRate) => ({ + text: taxRate.modifiedName, + keyForList: taxRate.code, + searchText: taxRate.modifiedName, + tooltipText: taxRate.modifiedName, + isDisabled: taxRate.isDisabled, + data: taxRate, + })); +} + +/** + * Builds the section list for tax rates + * + * @param {Object} policyTaxRates + * @param {Object[]} selectedOptions + * @param {String} searchInputValue + * @returns {Array} + */ +function getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue) { + const policyRatesSections = []; + + const taxes = transformedTaxRates(policyTaxRates); + + const sortedTaxRates = sortTaxRates(taxes); + const enabledTaxRates = _.filter(sortedTaxRates, (taxRate) => !taxRate.isDisabled); + const numberOfTaxRates = _.size(enabledTaxRates); + + let indexOffset = 0; + + // If all tax are disabled but there's a previously selected tag, show only the selected tag + if (numberOfTaxRates === 0 && selectedOptions.length > 0) { + const selectedTaxRateOptions = _.map(selectedOptions, (option) => ({ + modifiedName: option.name, + // Should be marked as enabled to be able to be de-selected + isDisabled: false, + })); + policyRatesSections.push({ + // "Selected" section + title: '', + shouldShow: false, + indexOffset, + data: getTaxRatesOptions(selectedTaxRateOptions), + }); + + return policyRatesSections; + } + + if (!_.isEmpty(searchInputValue)) { + const searchTaxRates = _.filter(enabledTaxRates, (taxRate) => taxRate.modifiedName.toLowerCase().includes(searchInputValue.toLowerCase())); + + policyRatesSections.push({ + // "Search" section + title: '', + shouldShow: true, + indexOffset, + data: getTaxRatesOptions(searchTaxRates), + }); + + return policyRatesSections; + } + + if (numberOfTaxRates < CONST.TAX_RATES_LIST_THRESHOLD) { + policyRatesSections.push({ + // "All" section when items amount less than the threshold + title: '', + shouldShow: false, + indexOffset, + data: getTaxRatesOptions(enabledTaxRates), + }); + + return policyRatesSections; + } + + const selectedOptionNames = _.map(selectedOptions, (selectedOption) => selectedOption.name); + const filteredTaxRates = _.filter(enabledTaxRates, (taxRate) => !_.includes(selectedOptionNames, taxRate.modifiedName)); + + if (!_.isEmpty(selectedOptions)) { + const selectedTaxRatesOptions = _.map(selectedOptions, (option) => { + const taxRateObject = _.find(taxes, (taxRate) => taxRate.modifiedName === option.name); + + return { + modifiedName: option.name, + isDisabled: Boolean(taxRateObject && taxRateObject.isDisabled), + }; + }); + + policyRatesSections.push({ + // "Selected" section + title: '', + shouldShow: true, + indexOffset, + data: getTaxRatesOptions(selectedTaxRatesOptions), + }); + + indexOffset += selectedOptions.length; + } + + policyRatesSections.push({ + // "All" section when number of items are more than the threshold + title: '', + shouldShow: true, + indexOffset, + data: getTaxRatesOptions(filteredTaxRates), + }); + + return policyRatesSections; +} + /** * Build the options * @@ -1098,6 +1251,8 @@ function getOptions( recentlyUsedTags = [], canInviteUser = true, includeSelectedOptions = false, + includePolicyTaxRates, + policyTaxRates, }, ) { if (includeCategories) { @@ -1110,6 +1265,7 @@ function getOptions( currentUserOption: null, categoryOptions, tagOptions: [], + policyTaxRatesOptions: [], }; } @@ -1123,6 +1279,21 @@ function getOptions( currentUserOption: null, categoryOptions: [], tagOptions, + policyTaxRatesOptions: [], + }; + } + + if (includePolicyTaxRates) { + const policyTaxRatesOptions = getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue); + + return { + recentReports: [], + personalDetails: [], + userToInvite: null, + currentUserOption: null, + categoryOptions: [], + tagOptions: [], + policyTaxRatesOptions, }; } @@ -1134,6 +1305,7 @@ function getOptions( currentUserOption: null, categoryOptions: [], tagOptions: [], + policyTaxRatesOptions: [], }; } @@ -1405,6 +1577,7 @@ function getOptions( currentUserOption, categoryOptions: [], tagOptions: [], + policyTaxRatesOptions: [], }; } @@ -1494,6 +1667,8 @@ function getIOUConfirmationOptionsFromParticipants(participants, amountText) { * @param {Array} [recentlyUsedTags] * @param {boolean} [canInviteUser] * @param {boolean} [includeSelectedOptions] + * @param {boolean} [includePolicyTaxRates] + * @param {Object} [policyTaxRates] * @returns {Object} */ function getFilteredOptions( @@ -1513,6 +1688,8 @@ function getFilteredOptions( recentlyUsedTags = [], canInviteUser = true, includeSelectedOptions = false, + includePolicyTaxRates = false, + policyTaxRates = {}, ) { return getOptions(reports, personalDetails, { betas, @@ -1532,6 +1709,8 @@ function getFilteredOptions( recentlyUsedTags, canInviteUser, includeSelectedOptions, + includePolicyTaxRates, + policyTaxRates, }); } @@ -1777,4 +1956,5 @@ export { getCategoryOptionTree, formatMemberForList, formatSectionsFromSearchTerm, + transformedTaxRates, }; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index e02fb8e4b8f1..615bea7ff18d 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -4,6 +4,7 @@ import {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {RecentWaypoint, Report, ReportAction, Transaction} from '@src/types/onyx'; +import PolicyTaxRate, {PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; import {Comment, Receipt, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import {EmptyObject} from '@src/types/utils/EmptyObject'; import {isCorporateCard, isExpensifyCard} from './CardUtils'; @@ -517,8 +518,25 @@ function getRecentTransactions(transactions: Record, size = 2): .slice(0, size); } +/** + * this is the formulae to calculate tax + */ +function calculateTaxAmount(percentage: string, amount: number) { + const divisor = Number(percentage.slice(0, -1)) / 100 + 1; + return Math.round(amount - amount / divisor) / 100; +} + +/** + * Calculates count of all tax enabled options + */ +function getEnabledTaxRateCount(options: PolicyTaxRates) { + return Object.values(options).filter((option: PolicyTaxRate) => !option.isDisabled).length; +} + export { buildOptimisticTransaction, + calculateTaxAmount, + getEnabledTaxRateCount, getUpdatedTransaction, getTransaction, getDescription, diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index d0b3342d3f63..22d660bd60be 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1081,6 +1081,8 @@ function updateDistanceRequest(transactionID, transactionThreadReportID, transac * @param {Object} [receipt] * @param {String} [category] * @param {String} [tag] + * @param {String} [taxCode] + * @param {Number} [taxAmount] * @param {Boolean} [billable] */ function requestMoney( @@ -1096,6 +1098,8 @@ function requestMoney( receipt = undefined, category = undefined, tag = undefined, + taxCode = '', + taxAmount = 0, billable = undefined, ) { // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function @@ -1126,6 +1130,8 @@ function requestMoney( receiptState: lodashGet(receipt, 'state'), category, tag, + taxCode, + taxAmount, billable, }, onyxData, @@ -3322,6 +3328,22 @@ function resetMoneyRequestTag() { Onyx.merge(ONYXKEYS.IOU, {tag: ''}); } +/** + * @param {String} transactionID + * @param {Object} taxRate + */ +function setMoneyRequestTaxRate(transactionID, taxRate) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxRate}); +} + +/** + * @param {String} transactionID + * @param {Number} taxAmount + */ +function setMoneyRequestTaxAmount(transactionID, taxAmount) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxAmount}); +} + /** * @param {Boolean} billable */ @@ -3461,6 +3483,8 @@ export { setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, setMoneyRequestTag, + setMoneyRequestTaxAmount, + setMoneyRequestTaxRate, setUpDistanceTransaction, navigateToNextPage, updateMoneyRequestDate, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.js b/src/pages/iou/request/step/IOURequestStepAmount.js index b0812271c647..84e0ac8533c5 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.js +++ b/src/pages/iou/request/step/IOURequestStepAmount.js @@ -1,15 +1,21 @@ import {useFocusEffect} from '@react-navigation/native'; +import PropTypes from 'prop-types'; import React, {useCallback, useRef} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import taxPropTypes from '@components/taxPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as ReportUtils from '@libs/ReportUtils'; import {getRequestType} from '@libs/TransactionUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/steps/MoneyRequestAmountForm'; import reportPropTypes from '@pages/reportPropTypes'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes'; import StepScreenWrapper from './StepScreenWrapper'; @@ -26,11 +32,28 @@ const propTypes = { /** The transaction object being modified in Onyx */ transaction: transactionPropTypes, + + /* Onyx Props */ + /** Collection of tax rates attached to a policy */ + policyTaxRates: taxPropTypes, + + /** The policy of the report */ + policy: PropTypes.shape({ + /** Is Tax tracking Enabled */ + isTaxTrackingEnabled: PropTypes.bool, + }), }; const defaultProps = { report: {}, transaction: {}, + policyTaxRates: {}, + policy: {}, +}; + +const getTaxAmount = (transaction, defaultTaxValue, amount) => { + const percentage = (transaction.taxRate ? transaction.taxRate.data.value : defaultTaxValue) || ''; + return TransactionUtils.calculateTaxAmount(percentage, amount); }; function IOURequestStepAmount({ @@ -40,6 +63,8 @@ function IOURequestStepAmount({ }, transaction, transaction: {currency: originalCurrency}, + policyTaxRates, + policy, }) { const {translate} = useLocalize(); const textInput = useRef(null); @@ -47,6 +72,9 @@ function IOURequestStepAmount({ const iouRequestType = getRequestType(transaction); const currency = selectedCurrency || originalCurrency; + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)); + const isTaxTrackingEnabled = isPolicyExpenseChat && policy.isTaxTrackingEnabled; + useFocusEffect( useCallback(() => { focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION); @@ -72,6 +100,13 @@ function IOURequestStepAmount({ */ const navigateToNextPage = ({amount}) => { const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(amount)); + + if ((iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || backTo) && isTaxTrackingEnabled) { + const taxAmount = getTaxAmount(transaction, policyTaxRates.defaultValue, amountInSmallestCurrencyUnits); + const taxAmountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount)); + IOU.setMoneyRequestTaxAmount(transaction.transactionID, taxAmountInSmallestCurrencyUnits); + } + IOU.setMoneyRequestAmount_temporaryForRefactor(transactionID, amountInSmallestCurrencyUnits, currency || CONST.CURRENCY.USD); if (backTo) { @@ -118,4 +153,15 @@ IOURequestStepAmount.propTypes = propTypes; IOURequestStepAmount.defaultProps = defaultProps; IOURequestStepAmount.displayName = 'IOURequestStepAmount'; -export default compose(withWritableReportOrNotFound, withFullTransactionOrNotFound)(IOURequestStepAmount); +export default compose( + withWritableReportOrNotFound, + withFullTransactionOrNotFound, + withOnyx({ + policyTaxRates: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${report ? report.policyID : '0'}`, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + }), +)(IOURequestStepAmount); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index cb6225b641fc..893c735aac3b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -76,6 +76,8 @@ function IOURequestStepConfirmation({ const [receiptFile, setReceiptFile] = useState(); const receiptFilename = lodashGet(transaction, 'filename'); const receiptPath = lodashGet(transaction, 'receipt.source'); + const transactionTaxCode = transaction.taxRate && transaction.taxRate.keyForList; + const transactionTaxAmount = transaction.taxAmount; const requestType = TransactionUtils.getRequestType(transaction); const headerTitle = iouType === CONST.IOU.TYPE.SPLIT ? translate('iou.split') : translate(TransactionUtils.getHeaderTitleTranslationKey(transaction)); const participants = useMemo( @@ -159,10 +161,12 @@ function IOURequestStepConfirmation({ receiptObj, transaction.category, transaction.tag, + transactionTaxCode, + transactionTaxAmount, transaction.billable, ); }, - [report, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID], + [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID], ); /** diff --git a/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js new file mode 100644 index 000000000000..8ee3abb56d00 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepTaxAmountPage.js @@ -0,0 +1,166 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useRef} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import taxPropTypes from '@components/taxPropTypes'; +import transactionPropTypes from '@components/transactionPropTypes'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as IOUUtils from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import MoneyRequestAmountForm from '@pages/iou/steps/MoneyRequestAmountForm'; +import reportPropTypes from '@pages/reportPropTypes'; +import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +const propTypes = { + /** Navigation route context info provided by react navigation */ + route: IOURequestStepRoutePropTypes.isRequired, + + /* Onyx Props */ + /** The report that the transaction belongs to */ + report: reportPropTypes, + + /** The transaction object being modified in Onyx */ + transaction: transactionPropTypes, + + /* Onyx Props */ + /** Collection of tax rates attached to a policy */ + policyTaxRates: taxPropTypes, +}; + +const defaultProps = { + report: {}, + transaction: {}, + policyTaxRates: {}, +}; + +const getTaxAmount = (transaction, defaultTaxValue) => { + const percentage = (transaction.taxRate ? transaction.taxRate.data.value : defaultTaxValue) || ''; + return CurrencyUtils.convertToBackendAmount(Number.parseFloat(TransactionUtils.calculateTaxAmount(percentage, transaction.amount))); +}; + +function IOURequestStepTaxAmountPage({ + route: { + params: {iouType, reportID, transactionID, backTo, currency: selectedCurrency}, + }, + transaction, + transaction: {currency: originalCurrency}, + report, + policyTaxRates, +}) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const textInput = useRef(null); + const isEditing = Navigation.getActiveRoute().includes('taxAmount'); + + const currency = selectedCurrency || originalCurrency; + + const focusTimeoutRef = useRef(null); + + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, []), + ); + + const navigateBack = () => { + Navigation.goBack(isEditing ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME); + }; + + const navigateToCurrencySelectionPage = () => { + // If the money request being created is a distance request, don't allow the user to choose the currency. + // Only USD is allowed for distance requests. + // Remove query from the route and encode it. + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CURRENCY.getRoute(iouType, transactionID, reportID, backTo ? 'confirm' : '', Navigation.getActiveRouteWithoutParams())); + }; + + const updateTaxAmount = (currentAmount) => { + const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(currentAmount.amount)); + IOU.setMoneyRequestTaxAmount(transactionID, amountInSmallestCurrencyUnits); + + IOU.setMoneyRequestCurrency_temporaryForRefactor(transactionID, currency || CONST.CURRENCY.USD); + + if (backTo) { + Navigation.goBack(backTo); + return; + } + + // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer + // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight + // to the confirm step. + if (report.reportID) { + IOU.setMoneyRequestParticipantsFromReport(transactionID, report); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID)); + return; + } + + // If there was no reportID, then that means the user started this flow from the global + menu + // and an optimistic reportID was generated. In that case, the next step is to select the participants for this request. + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); + }; + + const content = ( + (textInput.current = e)} + onCurrencyButtonPress={navigateToCurrencySelectionPage} + onSubmitButtonPress={updateTaxAmount} + /> + ); + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + + + + {content} + + + )} + + ); +} + +IOURequestStepTaxAmountPage.propTypes = propTypes; +IOURequestStepTaxAmountPage.defaultProps = defaultProps; +IOURequestStepTaxAmountPage.displayName = 'IOURequestStepTaxAmountPage'; + +export default compose( + withWritableReportOrNotFound, + withFullTransactionOrNotFound, + withOnyx({ + policyTaxRates: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${report ? report.policyID : '0'}`, + }, + }), +)(IOURequestStepTaxAmountPage); diff --git a/src/pages/iou/request/step/IOURequestStepTaxRatePage.js b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js new file mode 100644 index 000000000000..bae08cd8cb62 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepTaxRatePage.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TaxPicker from '@components/TaxPicker'; +import taxPropTypes from '@components/taxPropTypes'; +import transactionPropTypes from '@components/transactionPropTypes'; +import useLocalize from '@hooks/useLocalize'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import * as IOU from '@userActions/IOU'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +const propTypes = { + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** The type of IOU report, i.e. bill, request, send */ + iouType: PropTypes.string, + + /** The report ID of the IOU */ + reportID: PropTypes.string, + }), + }).isRequired, + + /* Onyx Props */ + /** Collection of tax rates attached to a policy */ + policyTaxRates: taxPropTypes, + + /** The transaction object being modified in Onyx */ + transaction: transactionPropTypes, +}; + +const defaultProps = { + policyTaxRates: {}, + transaction: {}, +}; + +const getTaxAmount = (taxRates, selectedTaxRate, amount) => { + const percentage = _.find(OptionsListUtils.transformedTaxRates(taxRates), (taxRate) => taxRate.modifiedName === selectedTaxRate).value; + return TransactionUtils.calculateTaxAmount(percentage, amount); +}; + +function IOURequestStepTaxRatePage({ + route: { + params: {iouType, reportID}, + }, + policyTaxRates, + transaction, +}) { + const {translate} = useLocalize(); + + function navigateBack() { + Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); + } + + const defaultTaxKey = policyTaxRates.defaultExternalID; + const defaultTaxName = (defaultTaxKey && `${policyTaxRates.taxes[defaultTaxKey].name} (${policyTaxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || ''; + const selectedTaxRate = (transaction.taxRate && transaction.taxRate.text) || defaultTaxName; + + const updateTaxRates = (taxes) => { + const taxAmount = getTaxAmount(policyTaxRates, taxes.text, transaction.amount); + const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(taxAmount)); + IOU.setMoneyRequestTaxRate(transaction.transactionID, taxes); + IOU.setMoneyRequestTaxAmount(transaction.transactionID, amountInSmallestCurrencyUnits); + + Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); + }; + + return ( + + {({insets}) => ( + <> + navigateBack()} + /> + + + )} + + ); +} + +IOURequestStepTaxRatePage.propTypes = propTypes; +IOURequestStepTaxRatePage.defaultProps = defaultProps; +IOURequestStepTaxRatePage.displayName = 'IOURequestStepTaxRatePage'; + +export default compose( + withWritableReportOrNotFound, + withFullTransactionOrNotFound, + withOnyx({ + policyTaxRates: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAX_RATE}${report ? report.policyID : '0'}`, + }, + }), +)(IOURequestStepTaxRatePage); diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index 2fe033cacca3..536944f4a2d8 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -16,12 +16,16 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getOperatingSystem from '@libs/getOperatingSystem'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; const propTypes = { /** IOU amount saved in Onyx */ amount: PropTypes.number, + /** Calculated tax amount based on selected tax rate */ + taxAmount: PropTypes.number, + /** Currency chosen by user or saved in Onyx */ currency: PropTypes.string, @@ -43,6 +47,7 @@ const propTypes = { const defaultProps = { amount: 0, + taxAmount: 0, currency: CONST.CURRENCY.USD, forwardedRef: null, isEditing: false, @@ -63,17 +68,19 @@ const getNewSelection = (oldSelection, prevLength, newLength) => { }; const isAmountInvalid = (amount) => !amount.length || parseFloat(amount) < 0.01; +const isTaxAmountInvalid = (currentAmount, taxAmount, isTaxAmountForm) => isTaxAmountForm && currentAmount > CurrencyUtils.convertToFrontendAmount(taxAmount); const AMOUNT_VIEW_ID = 'amountView'; const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; const NUM_PAD_VIEW_ID = 'numPadView'; -function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress, selectedTab}) { +function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress, selectedTab}) { const styles = useThemeStyles(); const {isExtraSmallScreenHeight} = useWindowDimensions(); const {translate, toLocaleDigit, numberFormat} = useLocalize(); const textInput = useRef(null); + const isTaxAmountForm = Navigation.getActiveRoute().includes('taxAmount'); const decimals = CurrencyUtils.getCurrencyDecimals(currency); const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; @@ -89,6 +96,8 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu const forwardDeletePressedRef = useRef(false); + const formattedTaxAmount = CurrencyUtils.convertToDisplayString(taxAmount, currency); + /** * Event occurs when a user presses a mouse button over an DOM element. * @@ -219,7 +228,12 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu */ const submitAndNavigateToNextPage = useCallback(() => { if (isAmountInvalid(currentAmount)) { - setFormError('iou.error.invalidAmount'); + setFormError(translate('iou.error.invalidAmount')); + return; + } + + if (isTaxAmountInvalid(currentAmount, taxAmount, isTaxAmountForm)) { + setFormError(translate('iou.error.invalidTaxAmount', {amount: formattedTaxAmount})); return; } @@ -229,7 +243,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu initializeAmount(backendAmount); onSubmitButtonPress({amount: currentAmount, currency}); - }, [onSubmitButtonPress, currentAmount, currency, initializeAmount]); + }, [onSubmitButtonPress, currentAmount, taxAmount, currency, isTaxAmountForm, formattedTaxAmount, translate, initializeAmount]); /** * Input handler to check for a forward-delete key (or keyboard shortcut) press. @@ -290,7 +304,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu )} diff --git a/src/types/onyx/PolicyTaxRates.ts b/src/types/onyx/PolicyTaxRates.ts new file mode 100644 index 000000000000..d549b620f51f --- /dev/null +++ b/src/types/onyx/PolicyTaxRates.ts @@ -0,0 +1,14 @@ +type PolicyTaxRate = { + /** Name of a tax */ + name: string; + + /** The value of a tax */ + value: string; + + /** Whether the tax is disabled */ + isDisabled?: boolean; +}; + +type PolicyTaxRates = Record; +export default PolicyTaxRate; +export type {PolicyTaxRates}; diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 999107f0b3ae..efe7fbca7b14 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -1987,6 +1987,148 @@ describe('OptionsListUtils', () => { expect(OptionsListUtils.sortCategories(categoriesIncorrectOrdering3)).toStrictEqual(result3); }); + it('getFilteredOptions() for taxRate', () => { + const search = 'rate'; + const emptySearch = ''; + const wrongSearch = 'bla bla'; + + const policyTaxRatesWithDefault = { + name: 'Tax', + defaultExternalID: 'CODE1', + defaultValue: '0%', + foreignTaxDefault: 'CODE1', + taxes: { + CODE2: { + name: 'Tax rate 2', + value: '3%', + }, + CODE3: { + name: 'Tax option 3', + value: '5%', + }, + CODE1: { + name: 'Tax exempt 1', + value: '0%', + }, + }, + }; + + const resultList = [ + { + title: '', + shouldShow: false, + indexOffset: 0, + // data sorted alphabetically by name + data: [ + { + // Adds 'Default' title to default tax. + // Adds value to tax name for more description. + text: 'Tax exempt 1 (0%) • Default', + keyForList: 'CODE1', + searchText: 'Tax exempt 1 (0%) • Default', + tooltipText: 'Tax exempt 1 (0%) • Default', + isDisabled: undefined, + // creates a data option. + data: { + name: 'Tax exempt 1', + code: 'CODE1', + modifiedName: 'Tax exempt 1 (0%) • Default', + value: '0%', + }, + }, + { + text: 'Tax option 3 (5%)', + keyForList: 'CODE3', + searchText: 'Tax option 3 (5%)', + tooltipText: 'Tax option 3 (5%)', + isDisabled: undefined, + data: { + name: 'Tax option 3', + code: 'CODE3', + modifiedName: 'Tax option 3 (5%)', + value: '5%', + }, + }, + { + text: 'Tax rate 2 (3%)', + keyForList: 'CODE2', + searchText: 'Tax rate 2 (3%)', + tooltipText: 'Tax rate 2 (3%)', + isDisabled: undefined, + data: { + name: 'Tax rate 2', + code: 'CODE2', + modifiedName: 'Tax rate 2 (3%)', + value: '3%', + }, + }, + ], + }, + ]; + + const searchResultList = [ + { + title: '', + shouldShow: true, + indexOffset: 0, + // data sorted alphabetically by name + data: [ + { + text: 'Tax rate 2 (3%)', + keyForList: 'CODE2', + searchText: 'Tax rate 2 (3%)', + tooltipText: 'Tax rate 2 (3%)', + isDisabled: undefined, + data: { + name: 'Tax rate 2', + code: 'CODE2', + modifiedName: 'Tax rate 2 (3%)', + value: '3%', + }, + }, + ], + }, + ]; + + const wrongSearchResultList = [ + { + title: '', + shouldShow: true, + indexOffset: 0, + data: [], + }, + ]; + + const result = OptionsListUtils.getFilteredOptions({}, {}, [], emptySearch, [], [], false, false, false, {}, [], false, {}, [], false, false, true, policyTaxRatesWithDefault); + + expect(result.policyTaxRatesOptions).toStrictEqual(resultList); + + const searchResult = OptionsListUtils.getFilteredOptions({}, {}, [], search, [], [], false, false, false, {}, [], false, {}, [], false, false, true, policyTaxRatesWithDefault); + expect(searchResult.policyTaxRatesOptions).toStrictEqual(searchResultList); + + const wrongSearchResult = OptionsListUtils.getFilteredOptions( + {}, + {}, + [], + wrongSearch, + [], + [], + false, + false, + false, + {}, + [], + false, + {}, + [], + false, + false, + true, + policyTaxRatesWithDefault, + ); + expect(wrongSearchResult.policyTaxRatesOptions).toStrictEqual(wrongSearchResultList); + }); + it('formatMemberForList()', () => { const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, {isSelected: key === '1'}));