diff --git a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts index ad9b6e7fe257..8565584eaecd 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useTaxAmount.ts @@ -2,7 +2,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateTaxAmount, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; +import {calculateTaxAmount, getCategoryTaxCode, getDefaultTaxCode, getTaxValue, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; @@ -61,9 +61,19 @@ function useTaxAmount({ const {getCurrencyDecimals} = useCurrencyListActions(); // Update the tax code when the default changes (for example, because the transaction currency changed) - const defaultTaxCode = getDefaultTaxCode(policy, transaction) ?? (isMovingTransactionFromTrackExpense ? (getDefaultTaxCode(policyForMovingExpenses, transaction) ?? '') : ''); + const workspaceDefaultTaxCode = getDefaultTaxCode(policy, transaction) ?? (isMovingTransactionFromTrackExpense ? (getDefaultTaxCode(policyForMovingExpenses, transaction) ?? '') : ''); + // A category tax rule outranks the workspace default, so resolve it here as well. Otherwise this default overwrites + // the rule's tax rate on the first render of the confirmation page. + const defaultTaxCode = getCategoryTaxCode(transaction?.category ?? '', transaction, policy) ?? workspaceDefaultTaxCode; const defaultTaxValue = getTaxValue(policy, transaction, defaultTaxCode) ?? null; - const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency); + const previousWorkspaceDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency); + // Only the workspace default follows the currency, so after a currency change the selection is compared against that + // default. The rest of the time it is compared against the category rule's rate, otherwise picking the workspace + // default while a rule is active reads as "no selection" and the rule's rate is written straight back over it. + const previousDefaultTaxCode = + transaction?.currency === previousTransactionCurrency + ? (getCategoryTaxCode(transaction?.category ?? '', transaction, policy, previousTransactionCurrency) ?? previousWorkspaceDefaultTaxCode) + : previousWorkspaceDefaultTaxCode; const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode; const commuterExclusionData = isDistanceRequest diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2c8630740931..d6585c8dfa27 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -64,6 +64,7 @@ import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTr import { calculateTaxAmount, getCalculatedTaxAmount, + getCategoryTaxCode, getCategoryTaxDetails, getDefaultTaxCode, getDistanceRateTaxUpdates, @@ -1967,6 +1968,7 @@ export { getAllSortedTransactions, getFormattedPostedDate, getPostedDate, + getCategoryTaxCode, getCategoryTaxDetails, isPerDiemRequest, isViolationDismissed, diff --git a/src/libs/TransactionUtils/tax.ts b/src/libs/TransactionUtils/tax.ts index 519fad0d41ad..c1ceea80bc66 100644 --- a/src/libs/TransactionUtils/tax.ts +++ b/src/libs/TransactionUtils/tax.ts @@ -13,7 +13,7 @@ import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, isSelectableTa import CONST from '@src/CONST'; import type {Policy, TaxRate, TaxRates, Transaction} from '@src/types/onyx'; -import type {Unit} from '@src/types/onyx/Policy'; +import type {ExpenseRule, Unit} from '@src/types/onyx/Policy'; import type {OnyxEntry} from 'react-native-onyx'; @@ -43,6 +43,41 @@ function getEnabledTaxRateCount(options: TaxRates) { return Object.values(options).filter((option: TaxRate) => !option.isDisabled).length; } +/** + * Whether a distance rate carries a tax rate the user can still pick. Disabling a tax rate leaves the rate pointing at + * it, so a rate the user can no longer pick is treated the same as a rate that never had one. + */ +function hasSelectableRateTaxCode(policy: OnyxEntry, customUnitRateID: string): boolean { + const rateTaxCode = getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID; + return isSelectableTaxCode(policy, rateTaxCode); +} + +/** + * The tax rate a category rule asks for, or the given default when no rule applies. Disabling a tax rate leaves the rule + * pointing at it, so a rule the user can no longer pick also falls back rather than saving a rate the expense cannot carry. + */ +function getSelectableCategoryTaxCode(policy: OnyxEntry, taxRules: ExpenseRule[], category: string, defaultTaxCode: string | undefined): string | undefined { + const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); + return categoryTaxCode && isSelectableTaxCode(policy, categoryTaxCode) ? categoryTaxCode : defaultTaxCode; +} + +function getTaxExpenseRules(policy: OnyxEntry): ExpenseRule[] { + return policy?.rules?.expenseRules?.filter((rule) => rule.tax) ?? []; +} + +/** + * The tax rate a category rule applies to a distance expense, or undefined when no rule can apply. + */ +function getDistanceCategoryTaxCode(policy: OnyxEntry, customUnitRateID: string, category: string, defaultTaxCode: string | undefined): string | undefined { + const taxRules = getTaxExpenseRules(policy); + // Tax tracking being off for distance rates hides the Tax field, so a value written there would be invisible, and a + // rate carrying its own tax rate outranks the rule. + if (taxRules.length === 0 || !getDistanceRateCustomUnit(policy)?.attributes?.taxEnabled || hasSelectableRateTaxCode(policy, customUnitRateID)) { + return undefined; + } + return getSelectableCategoryTaxCode(policy, taxRules, category, defaultTaxCode); +} + /** * Gets the tax code based on the type of transaction and selected currency. * If it is distance request, then returns the tax code corresponding to the custom unit rate @@ -55,17 +90,13 @@ function getDefaultTaxCode(policy: OnyxEntry, transaction: OnyxEntry, transaction: OnyxEntry, policy: OnyxEntry, getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']) { - const taxRules = policy?.rules?.expenseRules?.filter((rule) => rule.tax); - if (!taxRules || taxRules?.length === 0 || isDistanceRequest(transaction)) { +/** + * Resolves the tax code an expense defaults to once the policy's category tax rules are applied, or undefined when no + * rule can apply. + */ +function getCategoryTaxCode(category: string, transaction: OnyxEntry, policy: OnyxEntry, currency?: string): string | undefined { + if (isDistanceRequest(transaction)) { + const customUnitRateID = getRateID(transaction); + return getDistanceCategoryTaxCode(policy, customUnitRateID, category, getDefaultTaxCode(policy, transaction, undefined, customUnitRateID)); + } + + const taxRules = getTaxExpenseRules(policy); + if (taxRules.length === 0) { + return undefined; + } + + return getSelectableCategoryTaxCode(policy, taxRules, category, getDefaultTaxCode(policy, transaction, currency ?? getCurrency(transaction))); +} + +function getCategoryTaxDetails( + category: string, + transaction: OnyxEntry, + policy: OnyxEntry, + getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'], + currency?: string, +) { + // Distance tax is charged on the taxable portion of the mileage rate rather than the full amount, so reuse the + // rate-aware calculation instead of taxing the expense amount. + if (isDistanceRequest(transaction)) { + const { + categoryTaxCode: distanceCategoryTaxCode, + taxAmount, + taxValue, + } = getDistanceRateTaxUpdates(policy, transaction, getRateID(transaction), getCurrencyDecimals, undefined, category); + if (!distanceCategoryTaxCode) { + return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; + } + return {categoryTaxCode: distanceCategoryTaxCode, categoryTaxAmount: taxAmount, categoryTaxValue: taxValue}; + } + + const categoryTaxCode = getCategoryTaxCode(category, transaction, policy, currency); + if (!categoryTaxCode) { return {categoryTaxCode: undefined, categoryTaxAmount: undefined, categoryTaxValue: undefined}; } - const defaultTaxCode = getDefaultTaxCode(policy, transaction, getCurrency(transaction)); - const categoryTaxCode = getCategoryDefaultTaxRate(taxRules, category, defaultTaxCode); - const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode ?? ''); + const categoryTaxPercentage = getTaxValue(policy, transaction, categoryTaxCode); let categoryTaxAmount; if (categoryTaxPercentage) { @@ -227,6 +303,7 @@ function getCategoryTaxDetails(category: string, transaction: OnyxEntry { expect(categoryTaxValue).toBe('10%'); }); + describe('distance expenses', () => { + const distanceRateID = 'DISTANCE_RATE_ID'; + + function buildDistancePolicy(category: string, taxEnabled: boolean, rateTaxRateExternalID?: string, isRateTaxDisabled = false): Policy { + return { + ...createRandomPolicy(0), + taxRates: { + ...CONST.DEFAULT_TAX, + taxes: { + ...CONST.DEFAULT_TAX.taxes, + // eslint-disable-next-line @typescript-eslint/naming-convention + id_TAX_RATE_2: {name: 'Tax Rate 2', value: '8%', isDisabled: isRateTaxDisabled}, + }, + }, + rules: {expenseRules: createCategoryTaxExpenseRules(category, 'id_TAX_RATE_1')}, + customUnits: { + DISTANCE_CUSTOM_UNIT: { + attributes: {taxEnabled, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + customUnitID: 'DISTANCE_CUSTOM_UNIT', + enabled: true, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + rates: { + [distanceRateID]: { + attributes: {taxRateExternalID: rateTaxRateExternalID}, + currency: 'USD', + customUnitRateID: distanceRateID, + enabled: true, + name: 'Test rate', + rate: 67, + subRates: [], + }, + }, + }, + }, + }; + } + + function buildDistanceTransaction(): Transaction { + const transaction = generateTransaction(); + return { + ...transaction, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: {...transaction.comment, customUnit: {customUnitRateID: distanceRateID}}, + }; + } + + it('should apply the category rule when distance tax tracking is on and the rate has no tax rate of its own', () => { + // Given a policy whose distance rates track tax, where the selected rate has no tax rate of its own + const category = 'Advertising'; + const fakePolicy = buildDistancePolicy(category, true); + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, buildDistanceTransaction(), fakePolicy, getCurrencyDecimalsLocal); + + // Then the category rule fills the slot the rate left empty, rather than the workspace default + expect(categoryTaxCode).toBe('id_TAX_RATE_1'); + expect(categoryTaxValue).toBe('5%'); + }); + + it('should keep the rate tax rate when distance tax tracking is on and the rate has its own tax rate', () => { + // Given a policy whose distance rates track tax, where the selected rate carries its own tax rate + const category = 'Advertising'; + const fakePolicy = buildDistancePolicy(category, true, 'id_TAX_RATE_2'); + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxAmount, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails( + category, + buildDistanceTransaction(), + fakePolicy, + getCurrencyDecimalsLocal, + ); + + // Then the category rule is skipped, because the rate's own tax rate takes precedence over it + expect(categoryTaxCode).toBeUndefined(); + expect(categoryTaxAmount).toBeUndefined(); + expect(categoryTaxValue).toBeUndefined(); + }); + + it('should apply the category rule when the rate points at a tax rate that was disabled', () => { + // Given a distance rate that still points at a tax rate the admin has since disabled + const category = 'Advertising'; + const fakePolicy = buildDistancePolicy(category, true, 'id_TAX_RATE_2', true); + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, buildDistanceTransaction(), fakePolicy, getCurrencyDecimalsLocal); + + // Then the rate counts as having no tax rate, so the category rule applies no matter which of the two the user picked first + expect(categoryTaxCode).toBe('id_TAX_RATE_1'); + expect(categoryTaxValue).toBe('5%'); + }); + + it('should fall back to the workspace default when the category rule points at a disabled tax rate', () => { + // Given a category rule pointing at a tax rate the admin has since disabled, on a rate with no tax rate of its own + const category = 'Advertising'; + const fakePolicy: Policy = { + ...buildDistancePolicy(category, true), + taxRates: { + ...CONST.DEFAULT_TAX, + taxes: { + ...CONST.DEFAULT_TAX.taxes, + // eslint-disable-next-line @typescript-eslint/naming-convention + id_TAX_RATE_1: {name: 'Tax Rate 1', value: '5%', isDisabled: true}, + }, + }, + }; + + // When retrieving the tax from the associated category + const {categoryTaxCode, categoryTaxValue} = TransactionUtils.getCategoryTaxDetails(category, buildDistanceTransaction(), fakePolicy, getCurrencyDecimalsLocal); + + // Then the rule is skipped, because the expense cannot carry a tax rate the user is no longer offered + expect(categoryTaxCode).toBe('id_TAX_EXEMPT'); + expect(categoryTaxValue).toBe('0%'); + }); + }); + describe('should return undefined tax', () => { - it('if the transaction type is distance', () => { - // Given a policy with tax expense rules associated with a category + it('if the transaction type is distance and distance tax tracking is disabled', () => { + // Given a policy with tax expense rules associated with a category, and no distance rates tracking tax const category = 'Advertising'; const fakePolicy: Policy = { ...createRandomPolicy(0), @@ -518,6 +633,32 @@ describe('TransactionUtils', () => { expect(TransactionUtils.getDefaultTaxCode(policy, distanceTransaction)).toBeUndefined(); }); + it('taxes only the reimbursable distance when a commuter exclusion applies', () => { + // Given a 30 mile trip on a taxed rate, where the workspace excludes 10 miles as a commute + const policy = buildDistancePolicy({rateTaxCode: 'id_TAX_RATE_1'}); + const buildTrip = (customUnit: TransactionCustomUnit): Transaction => ({ + ...distanceTransaction, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, customUnitRateID: RATE_ID, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, ...customUnit}, + }, + }); + + // When computing the tax for the full trip, the excluded trip, and a trip of only the reimbursable part + const fullTrip = TransactionUtils.getDistanceRateTaxUpdates(policy, buildTrip({quantity: 30}), RATE_ID, getCurrencyDecimalsLocal); + const excludedTrip = TransactionUtils.getDistanceRateTaxUpdates( + policy, + buildTrip({quantity: 30, commuterExclusion: 10, reimbursableDistance: 20}), + RATE_ID, + getCurrencyDecimalsLocal, + ); + const shortTrip = TransactionUtils.getDistanceRateTaxUpdates(policy, buildTrip({quantity: 20}), RATE_ID, getCurrencyDecimalsLocal); + + // Then the commute is left out of the tax, so the expense is taxed as if it were only the reimbursable 20 miles + expect(excludedTrip.taxAmount).toBe(shortTrip.taxAmount); + expect(excludedTrip.taxAmount).toBeLessThan(fullTrip.taxAmount); + }); + it('makes getDistanceRateTaxUpdates drop the disabled tax rate', () => { // Given a distance rate that still points at a disabled tax rate const policy = buildDistancePolicy({rateTaxCode: 'id_TAX_RATE_1', isRateTaxDisabled: true}); diff --git a/tests/unit/hooks/useTaxAmount.test.ts b/tests/unit/hooks/useTaxAmount.test.ts index 5067e9994a28..bc00956c831c 100644 --- a/tests/unit/hooks/useTaxAmount.test.ts +++ b/tests/unit/hooks/useTaxAmount.test.ts @@ -21,14 +21,18 @@ jest.mock('@libs/DistanceRequestUtils', () => ({ }, })); +let mockCategoryTaxCode: string | undefined; +let mockHasTaxRateWithMatchingValue: boolean; + jest.mock('@libs/TransactionUtils', () => ({ calculateTaxAmount: (taxPercentage: string, taxableAmount: number) => { const pct = Number.parseFloat(String(taxPercentage).replace('%', '')) || 0; return (taxableAmount * pct) / 100; }, - getDefaultTaxCode: () => 'tax_default', + getCategoryTaxCode: () => mockCategoryTaxCode, + getDefaultTaxCode: (_policy: unknown, transaction: {currency?: string} | undefined, currency?: string) => ((currency ?? transaction?.currency) === 'EUR' ? 'tax_foreign' : 'tax_default'), getTaxValue: () => '10%', - hasTaxRateWithMatchingValue: () => false, + hasTaxRateWithMatchingValue: () => mockHasTaxRateWithMatchingValue, })); type Params = Parameters[0]; @@ -63,6 +67,8 @@ describe('useTaxAmount', () => { mockConvertToDistanceInMeters.mockImplementation((distance: number) => distance); mockGetCommuterExclusionDisplayData.mockReturnValue(null); mockGetTaxableAmount.mockReturnValue(100); + mockCategoryTaxCode = undefined; + mockHasTaxRateWithMatchingValue = false; }); it('returns the default tax code and value from policy resolution', () => { @@ -155,4 +161,41 @@ describe('useTaxAmount', () => { const {result} = renderHook(() => useTaxAmount(baseParams)); expect(result.current.shouldKeepCurrentTaxSelection).toBe(false); }); + + it('prefers the tax rate from the category rule over the workspace default', () => { + // Given a category whose tax rule resolves to a tax code of its own + mockCategoryTaxCode = 'tax_category'; + + // When the hook resolves the default tax code + const {result} = renderHook(() => useTaxAmount(baseParams)); + + // Then the category rule outranks the workspace default, so the confirmation page opens on the rule's rate + expect(result.current.defaultTaxCode).toBe('tax_category'); + }); + + it('resolves the tax again when the currency changes and the user had picked the old default', () => { + // Given a category rule, and a user who picked the workspace default before switching to a foreign currency + mockCategoryTaxCode = 'tax_category'; + mockHasTaxRateWithMatchingValue = true; + const transaction = {...baseTransaction, currency: 'EUR', taxCode: 'tax_default'}; + + // When the hook decides whether that selection survives the currency change + const {result} = renderHook(() => useTaxAmount({...baseParams, transaction, previousTransactionCurrency: 'USD'})); + + // Then it does not, because the workspace default follows the currency, so the rate is resolved from scratch + expect(result.current.shouldKeepCurrentTaxSelection).toBe(false); + }); + + it('keeps the workspace default when the user picks it over the category rule rate', () => { + // Given a category rule that resolves to its own tax code, and a user who picked the workspace default instead + mockCategoryTaxCode = 'tax_category'; + mockHasTaxRateWithMatchingValue = true; + const transaction = {...baseTransaction, taxCode: 'tax_default'}; + + // When the hook decides whether that selection survives + const {result} = renderHook(() => useTaxAmount({...baseParams, transaction})); + + // Then it is kept, because the selection is compared against the rule's rate rather than the workspace default + expect(result.current.shouldKeepCurrentTaxSelection).toBe(true); + }); });