diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index 46002ad75a58..353e78d97da2 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -45,9 +45,10 @@ import {rand64} from './NumberUtils'; import {getParticipantsOption, getReportOption} from './OptionsListUtils'; import Permissions from './Permissions'; import {getLoginByAccountID} from './PersonalDetailsUtils'; -import {getPolicyExpenseChat, getTransactionDetails, isMoneyRequestReport, isSelfDM, shouldEnableNegative} from './ReportUtils'; +import {isTaxTrackingEnabled} from './PolicyUtils'; +import {getPolicyExpenseChat, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from './ReportUtils'; import shouldUseDefaultExpensePolicy from './shouldUseDefaultExpensePolicy'; -import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue, hasReceipt} from './TransactionUtils'; +import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue, hasReceipt, isExpenseUnreported} from './TransactionUtils'; type SubmitAmountArgs = { report: OnyxEntry; @@ -136,6 +137,23 @@ function getIsP2PForAmount({chatReportForP2P, currentUserAccountID}: GetIsP2PFor return isParticipantP2P(firstParticipant); } +/** + * Determines whether a transaction's current tax code is still the auto-applied default for the given currency + * (i.e. the user has not manually picked a different rate). + */ +function isTaxCodeAutoDefaultForCurrency( + policy: OnyxEntry, + transaction: OnyxEntry, + currency: string | undefined, + taxCode: string | undefined, +): boolean { + if (taxCode === '') { + return false; + } + const defaultTaxCodeForCurrency = getDefaultTaxCode(policy, transaction, currency); + return !taxCode || taxCode === defaultTaxCodeForCurrency; +} + type SubmitAmountContext = { isEditing: boolean; isCreateAction: boolean; @@ -491,8 +509,26 @@ function submitCreateAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): v setMoneyRequestAmount(transactionID, amountInSmallestCurrencyUnits, selectedCurrency || CONST.CURRENCY.USD, shouldKeepUserInput, hasReceipt(transaction)); - if (isMovingTransactionFromTrackExpense(action)) { - const taxCode = selectedCurrency !== policy?.outputCurrency ? policy?.taxRates?.foreignTaxDefault : policy?.taxRates?.defaultExternalID; + // When the currency changes, re-apply the default tax rate for the new currency so the confirmation page and the + // created expense reflect the currency-appropriate default (e.g. the foreign default for a foreign currency). + // Only do this when the current tax code is still the auto-applied default for the previous currency, so a tax + // rate the user manually selected is preserved across the currency change. + const previousCurrency = getCurrency(transaction); + const isCurrentTaxAutoDefault = isTaxCodeAutoDefaultForCurrency(policy, transaction, previousCurrency, transaction?.taxCode); + const isPolicyExpenseChatParticipant = transaction?.participants?.some((participant) => participant.isPolicyExpenseChat) ?? false; + + // Mirror the tax contexts the confirmation list uses (see MoneyRequestConfirmationList's shouldShowTax): a Track + // expense from a self-DM/default workspace isn't a policy expense chat, but the confirmation page still shows tax + // for the track flow, so the currency recompute must run for it too. Otherwise Back → change currency → Next would + // leave the stale previous-currency default tax on the draft. + const isTaxEnabled = isTaxTrackingEnabled( + isPolicyExpenseChat(report) || isPolicyExpenseChatParticipant || iouType === CONST.IOU.TYPE.TRACK || isExpenseUnreported(transaction), + policy, + false, + ); + + if (isMovingTransactionFromTrackExpense(action) || (isTaxEnabled && selectedCurrency !== previousCurrency && isCurrentTaxAutoDefault)) { + const taxCode = getDefaultTaxCode(policy, transaction, selectedCurrency); if (taxCode) { setMoneyRequestTaxRate(transactionID, taxCode); const taxPercentage = getTaxValue(policy, transaction, taxCode) ?? ''; @@ -551,10 +587,14 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi return; } - // If currency has changed, then we get the default tax rate based on currency, otherwise we use the current tax rate selected in transaction, if we have it. + // When the currency changes we re-apply the new currency's default tax rate, but only when the current tax rate is + // still the auto-applied default for the previous currency. A tax rate the user picked manually is preserved across + // the currency change (mirrors the create-flow guard in `submitCreateAmount`). const transactionTaxCode = getTransactionDetails(currentTransaction)?.taxCode; const defaultTaxCode = getDefaultTaxCode(policy, currentTransaction, selectedCurrency) ?? ''; - const taxCode = (selectedCurrency !== transactionCurrency ? defaultTaxCode : transactionTaxCode) ?? defaultTaxCode; + const isCurrentTaxAutoDefault = isTaxCodeAutoDefaultForCurrency(policy, currentTransaction, transactionCurrency, transactionTaxCode); + const isTransactionTaxCodeValid = transactionTaxCode === '' || getTaxValue(policy, currentTransaction, transactionTaxCode ?? '') !== undefined; + const taxCode = ((selectedCurrency !== transactionCurrency && isCurrentTaxAutoDefault) || !isTransactionTaxCodeValid ? defaultTaxCode : transactionTaxCode) ?? defaultTaxCode; const taxPercentage = getTaxValue(policy, currentTransaction, taxCode) ?? ''; const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, newAmount, decimals)); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 8eaf5ecae913..7c65261db063 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -81,7 +81,8 @@ function IOURequestStepAmount({ const iouRequestType = getRequestType(transaction); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); - const policyID = isTrackExpense ? policyForMovingExpensesID : report?.policyID; + const participantPolicyID = transaction?.participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; + const policyID = (isTrackExpense ? policyForMovingExpensesID : report?.policyID) ?? participantPolicyID; const isReportArchived = useReportIsArchived(report?.reportID); const isIouReport = isMoneyRequestReport(report); diff --git a/tests/unit/IOUAmountSubmissionTest.ts b/tests/unit/IOUAmountSubmissionTest.ts index ff7def95b647..93553d7530b5 100644 --- a/tests/unit/IOUAmountSubmissionTest.ts +++ b/tests/unit/IOUAmountSubmissionTest.ts @@ -1,5 +1,7 @@ import {getIsP2PForAmount, submitAmount} from '@libs/IOUAmountSubmission'; +import {setMoneyRequestTaxAmount, setMoneyRequestTaxRate} from '@userActions/IOU/MoneyRequest'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Policy, Report, Transaction} from '@src/types/onyx'; @@ -8,6 +10,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -228,6 +231,208 @@ describe('AmountSubmission', () => { mockSetDraftSplitTransaction.mockClear(); mockUpdateMoneyRequestAmountAndCurrency.mockClear(); mockSetTransactionReport.mockClear(); + jest.mocked(setMoneyRequestTaxRate).mockClear(); + jest.mocked(setMoneyRequestTaxAmount).mockClear(); + }); + + const taxPolicy: Policy = { + ...createRandomPolicy(200, CONST.POLICY.TYPE.TEAM, 'Tax Workspace'), + outputCurrency: CONST.CURRENCY.USD, + tax: {trackingEnabled: true}, + taxRates: { + name: 'Tax', + defaultExternalID: 'idDefault', + foreignTaxDefault: 'idForeign', + defaultValue: '5%', + taxes: { + idDefault: {name: 'Default', value: '5%'}, + idForeign: {name: 'Foreign', value: '10%'}, + idManual: {name: 'Manual', value: '7%'}, + }, + }, + }; + + // The create-flow tax recompute only runs on a policy expense chat with tax tracking enabled, so tax tests + // start from a workspace chat report (mirrors continuing an expense from a tax-enabled workspace chat). + const taxReport: Report = { + ...createRandomReport(100, undefined), + reportID: 'report-100', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: '200', + }; + + const buildTaxTransaction = (taxCode: string): Transaction => ({ + transactionID: 'tx-1', + amount: -1000, + currency: CONST.CURRENCY.USD, + created: '2024-01-01', + merchant: 'Test', + reportID: 'report-100', + comment: {}, + taxCode, + }); + + it('applies the new currency default tax rate on create when currency changes and tax is the auto-applied default', () => { + submitAmount( + buildBaseArgs({ + report: taxReport, + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: buildTaxTransaction('idDefault'), + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(setMoneyRequestTaxRate).toHaveBeenCalledWith('tx-1', 'idForeign'); + }); + + it('preserves a manually selected tax rate on create when currency changes', () => { + submitAmount( + buildBaseArgs({ + report: taxReport, + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: buildTaxTransaction('idManual'), + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(setMoneyRequestTaxRate).not.toHaveBeenCalled(); + }); + + it('does not change the tax rate on create when the currency is unchanged', () => { + submitAmount( + buildBaseArgs({ + report: taxReport, + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: buildTaxTransaction('idDefault'), + selectedCurrency: CONST.CURRENCY.USD, + amount: '10', + }), + ); + + expect(setMoneyRequestTaxRate).not.toHaveBeenCalled(); + }); + + it('does not write a tax code or amount on create when the workspace has tax tracking disabled', () => { + submitAmount( + buildBaseArgs({ + report: taxReport, + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: {...taxPolicy, tax: {trackingEnabled: false}}, + transaction: buildTaxTransaction('idDefault'), + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(setMoneyRequestTaxRate).not.toHaveBeenCalled(); + expect(setMoneyRequestTaxAmount).not.toHaveBeenCalled(); + }); + + it('applies the new currency default tax rate on create from the FAB flow (no report) when the workspace is resolved from the participant', () => { + // Global-create (FAB) flow: there is no report context, so the workspace/tax tracking must be recognized + // from the transaction's selected policy-expense-chat participant instead of from `report`. + submitAmount( + buildBaseArgs({ + report: undefined, + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: {...buildTaxTransaction('idDefault'), participants: [{isPolicyExpenseChat: true, policyID: '200'}]}, + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(setMoneyRequestTaxRate).toHaveBeenCalledWith('tx-1', 'idForeign'); + }); + + it('applies the new currency default tax rate on edit when currency changes and tax is the auto-applied default', () => { + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: buildTaxTransaction('idDefault'), + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith(expect.objectContaining({taxCode: 'idForeign'})); + }); + + it('preserves a manually selected tax rate on edit when currency changes', () => { + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: buildTaxTransaction('idManual'), + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith(expect.objectContaining({taxCode: 'idManual'})); + }); + + it('heals a tax code that is not a valid policy rate to the currency default on edit', () => { + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + transaction: buildTaxTransaction('idNoLongerExists'), + selectedCurrency: CONST.CURRENCY.EUR, + amount: '10', + }), + ); + + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith(expect.objectContaining({taxCode: 'idForeign'})); + }); + + it('preserves a tax code whose stored value drifted from the policy on edit (heals only missing codes)', () => { + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + // The code still exists on the policy, but its stored value is stale (e.g. an admin edited the + // rate's percentage after this expense was created). The currency is unchanged, so the only reason + // to swap the code would be an invalid one — but a drifted value is not invalid, so it is preserved. + transaction: {...buildTaxTransaction('idManual'), taxValue: '3%'}, + selectedCurrency: CONST.CURRENCY.USD, + amount: '20', + }), + ); + + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith(expect.objectContaining({taxCode: 'idManual'})); + }); + + it('preserves an intentionally-cleared tax (empty code) on edit when only the amount changes', () => { + submitAmount( + buildBaseArgs({ + action: CONST.IOU.ACTION.EDIT, + iouType: CONST.IOU.TYPE.SUBMIT, + policy: taxPolicy, + // The user deliberately cleared the tax (`taxCode === ''`). Editing only the amount (same currency) + // must not re-add the workspace default tax — an empty code is a legitimate persisted selection. + transaction: buildTaxTransaction(''), + selectedCurrency: CONST.CURRENCY.USD, + amount: '20', + }), + ); + + expect(mockUpdateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith(expect.objectContaining({taxCode: ''})); }); it('calls sendMoneyElsewhere on non-edit + skip-confirm + PAY (non-wallet)', () => {