Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
004bcdf
Apply category tax rules to distance expenses without a rate tax
Krishna2323 Sep 21, 2026
5d9a9e8
Apply category tax rules to distance expenses without a rate tax
Krishna2323 Sep 21, 2026
d878937
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Sep 24, 2026
ecc395a
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Sep 27, 2026
f79b26b
Apply the category tax rule to the confirmation page default
Krishna2323 Sep 27, 2026
1d2808e
fix eslint.
Krishna2323 Sep 27, 2026
393b8e8
Merge main and resolve conflicts
Krishna2323 Oct 12, 2026
2e63810
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Oct 12, 2026
5d7e592
Restore the GitHub Action bundles to main
Krishna2323 Oct 12, 2026
f596695
Mock getCategoryTaxDetails in the useTaxAmount test
Krishna2323 Oct 12, 2026
a519f64
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Oct 14, 2026
bdf1da8
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Oct 18, 2026
54d2665
Apply the category tax rule consistently for manual and distance expe…
Krishna2323 Oct 18, 2026
1cc0c03
Share the distance rate tax check across the tax helpers
Krishna2323 Oct 19, 2026
d10ef60
Validate the category tax rate and exclude the commute from distance tax
Krishna2323 Oct 19, 2026
7b0df50
fix failing tests.
Krishna2323 Oct 19, 2026
11149f5
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Oct 21, 2026
d03b690
Merge branch 'Expensify:main' into krishna2323/fix/100544-distance-ca…
Krishna2323 Oct 26, 2026
724dd8e
fix: follow the currency change when a category tax rule is active
Krishna2323 Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {getClearedPendingFields, getDistanceMerchantForTransaction, getUpdatedTr
import {
calculateTaxAmount,
getCalculatedTaxAmount,
getCategoryTaxCode,
getCategoryTaxDetails,
getDefaultTaxCode,
getDistanceRateTaxUpdates,
Expand Down Expand Up @@ -1967,6 +1968,7 @@ export {
getAllSortedTransactions,
getFormattedPostedDate,
getPostedDate,
getCategoryTaxCode,
getCategoryTaxDetails,
isPerDiemRequest,
isViolationDismissed,
Expand Down
119 changes: 98 additions & 21 deletions src/libs/TransactionUtils/tax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Policy>, 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<Policy>, 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<Policy>): 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<Policy>, 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
Expand All @@ -55,17 +90,13 @@ function getDefaultTaxCode(policy: OnyxEntry<Policy>, transaction: OnyxEntry<Tra
// Therefore, to correctly determine the tax code for the selected rate, we must use the newly selected distance rate's customUnitRateID (newCustomUnitRateID)
// instead of relying on the transaction's current customUnitRateID.
const customUnitRateID = newCustomUnitRateID ?? getRateID(transaction) ?? '';
const customUnitRate = getDistanceRateCustomUnitRate(policy, customUnitRateID);
const customUnit = getDistanceRateCustomUnit(policy);
const rateTaxCode = customUnitRate?.attributes?.taxRateExternalID;
// Disabling a tax rate leaves the distance rate still pointing at it, so a rate the user can no longer pick is
// treated the same as a missing one, and the policy default is only used while it is selectable.
const isRateTaxCodeUnusable = !!rateTaxCode && !isSelectableTaxCode(policy, rateTaxCode);
if ((!rateTaxCode || isRateTaxCodeUnusable) && customUnit?.attributes?.taxEnabled) {
if (!hasSelectableRateTaxCode(policy, customUnitRateID)) {
// The policy default is only used while it is selectable itself.
const defaultExternalID = policy?.taxRates?.defaultExternalID;
return isSelectableTaxCode(policy, defaultExternalID) ? defaultExternalID : undefined;
return customUnit?.attributes?.taxEnabled && isSelectableTaxCode(policy, defaultExternalID) ? defaultExternalID : undefined;
}
return isRateTaxCodeUnusable ? undefined : rateTaxCode;
return getDistanceRateCustomUnitRate(policy, customUnitRateID)?.attributes?.taxRateExternalID;
}
const defaultExternalID = policy?.taxRates?.defaultExternalID;
const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault;
Expand Down Expand Up @@ -111,17 +142,26 @@ function getDistanceRateTaxUpdates(
customUnitRateID: string,
getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'],
distanceUnit?: Unit,
): {taxAmount: number; taxCode: string; taxValue: string | undefined} {
// getDefaultTaxCode already returns the rate's own tax code when it is still selectable, and falls back to the policy
// default when it is empty, disabled, or pending delete.
const taxCode = getDefaultTaxCode(policy, transaction, undefined, customUnitRateID) ?? '';
const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit));
category?: string,
): {taxAmount: number; taxCode: string; taxValue: string | undefined; categoryTaxCode: string | undefined} {
const defaultTaxCode = getDefaultTaxCode(policy, transaction, undefined, customUnitRateID) ?? '';
// A rate's own tax rate wins over a category rule, because vehicle types coded to the same category can each
// reclaim at a different rate.
const categoryTaxCode = getDistanceCategoryTaxCode(policy, customUnitRateID, category ?? transaction?.category ?? '', defaultTaxCode);
const taxCode = categoryTaxCode ?? defaultTaxCode;
const unit = distanceUnit ?? transaction?.comment?.customUnit?.distanceUnit;
// A commute the workspace excludes from reimbursement is not reclaimable either, so only the rest of the trip is taxed.
const commuterExclusionData = DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES);
const taxableDistance = commuterExclusionData
? DistanceRequestUtils.convertToDistanceInMeters(commuterExclusionData.reimbursableDistance, commuterExclusionData.distanceUnit)
: getDistanceInMeters(transaction, unit);
const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, taxableDistance);
const taxValue = taxCode ? getTaxValue(policy, transaction, taxCode) : undefined;
const mileageRates = DistanceRequestUtils.getMileageRates(policy);
const rateCurrency = mileageRates[customUnitRateID]?.currency ?? transaction?.currency ?? CONST.CURRENCY.USD;
const taxAmount = convertToBackendAmount(calculateTaxAmount(taxValue, taxableAmount, getCurrencyDecimals(rateCurrency)));

return {taxAmount, taxCode, taxValue};
return {taxAmount, taxCode, taxValue, categoryTaxCode};
}

/**
Expand Down Expand Up @@ -206,15 +246,51 @@ function getTaxRateTitle(policy: OnyxEntry<Policy>, transaction: OnyxEntry<Trans
return '';
}

function getCategoryTaxDetails(category: string, transaction: OnyxEntry<Transaction>, policy: OnyxEntry<Policy>, 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<Transaction>, policy: OnyxEntry<Policy>, 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<Transaction>,
policy: OnyxEntry<Policy>,
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) {
Expand All @@ -227,6 +303,7 @@ function getCategoryTaxDetails(category: string, transaction: OnyxEntry<Transact
export {
calculateTaxAmount,
getCalculatedTaxAmount,
getCategoryTaxCode,
getCategoryTaxDetails,
getDefaultTaxCode,
getDistanceRateTaxUpdates,
Expand Down
Loading
Loading