diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7a54e5e399d7..1e66d6e85381 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5556,6 +5556,8 @@ const CONST = { SEK: 'SE', ZAR: 'ZA', }, + // The country that publishes fuel-band rates, which replace its standard rate on workspaces tracking distance tax + GOVERNMENT_RATE_FUEL_BAND_COUNTRY: 'GB', // EUR is shared by several supported countries, so an EUR workspace picks the country itself GOVERNMENT_RATE_SUPPORTED_EUR_COUNTRIES: ['AT', 'BE', 'FI', 'DE', 'NL', 'PT', 'ES'] as const, // Unit each country publishes its rates in diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 1b4d89bdd1d7..a57c82297c3e 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -20,6 +20,7 @@ import getPermittedDecimalSeparator from './getPermittedDecimalSeparator'; import Log from './Log'; import {replaceAllDigits} from './MoneyRequestUtils'; import {generateHexadecimalValue, parseFloatAnyLocale} from './NumberUtils'; +import {getDistanceRateCustomUnit, isTaxTrackingEnabled} from './PolicyUtils'; import StringUtils from './StringUtils'; import {containsHtmlTag, isRequiredFulfilled} from './ValidationUtils'; @@ -290,11 +291,32 @@ function getGovernmentRateAmountForUnit(governmentRateAmount: number, sourceRate return Math.round(convertedAmount * 100) / 100; } +/** + * The series a government rate belongs to: its sourceRateID without the trailing "_", e.g. "GB" for the + * standard UK rate or "GB_" for a UK fuel band. Fuel band names contain underscores, so only the last one is cut. + */ +function getGovernmentRateSeries(sourceRateID: string): string { + const dateSeparatorIndex = sourceRateID.lastIndexOf('_'); + return dateSeparatorIndex === -1 ? sourceRateID : sourceRateID.slice(0, dateSeparatorIndex); +} + +/** Whether a government rate is a UK fuel band, whose sourceRateID carries the band between the country code and the date. */ +function isFuelBandGovernmentRate(sourceRateID: string): boolean { + return sourceRateID.split('_').length > 2; +} + +/** The tax reclaimable amount, in currency units, as the distance rate tax reclaimable page shows it. */ +function getTaxClaimableDisplayAmount(taxClaimablePercentage: number, rate: number): string { + return ((taxClaimablePercentage * rate) / 100).toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES); +} + /** * Whether a government-managed rate still matches the government-published snapshot it was copied from. * Returns true only when the rate amount, start date, and end date each match the snapshot in attributes.governmentRate. * The amount is compared within a small tolerance to absorb floating-point noise from the stored cents value. * A date omitted on both sides counts as a match; a date omitted on only one side does not. + * When the snapshot has a tax reclaimable fraction, the reclaimable amount must also match at the precision the edit page + * shows it, so re-entering the shown value counts as unmodified. Snapshots without one ignore the reclaimable amount. */ function isGovernmentRateUnmodified(rate: Rate, currentUnit?: Unit): boolean { const governmentRate = rate.attributes?.governmentRate; @@ -308,8 +330,11 @@ function isGovernmentRateUnmodified(rate: Rate, currentUnit?: Unit): boolean { // 0.29 yields 28.999999999999996), so compare amounts within a tolerance rather than requiring strict equality. const governmentRateAmount = getGovernmentRateAmountForUnit(governmentRate.rate, governmentRate.sourceRateID, currentUnit); const isRateAmountMatching = Math.abs(rate.rate - governmentRateAmount) < CONST.CUSTOM_UNITS.GOVERNMENT_RATE_MATCH_TOLERANCE; + const isTaxClaimableAmountMatching = + governmentRate.taxClaimablePercentage === undefined || + getTaxClaimableDisplayAmount(rate.attributes?.taxClaimablePercentage ?? 0, rate.rate) === getTaxClaimableDisplayAmount(governmentRate.taxClaimablePercentage, governmentRateAmount); - return isRateAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate; + return isRateAmountMatching && isTaxClaimableAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate; } /** The country publishing government mileage rates for a currency, or undefined when the currency alone cannot pick one. */ @@ -389,15 +414,18 @@ function getGovernmentRateCountryOptions(translate: LocalizedTranslate, localeCo * including the optimistic copy of the country's reference rates and the distance unit correction. Shared by the * SetWorkspaceDistanceAutoUpdate command and the merged currency change in updateGeneralSettings. * - * `customUnit` can be missing when the server-created custom unit isn't in Onyx yet. The rate copying and unit correction - * are skipped then, and the server response fills them in. + * The distance custom unit can be missing when the server-created custom unit isn't in Onyx yet. The rate copying and unit + * correction are skipped then, and the server response fills them in. + * + * A UK workspace tracking distance tax gets the fuel-band rates instead of the standard rate when the reference list has + * fuel bands, and the standard rate otherwise. Every copied rate keeps + * the enabled state of the previous rate in its series, matching the server's copy. * * The previous enabled state and country describe the policy before the change, so a failed request restores exactly * what the server still has. */ function buildOnyxDataForGovernmentRateAutoUpdate( - policyID: string, - customUnit: CustomUnit | undefined, + policy: Policy, shouldAutoUpdateGovernmentDistanceRates: boolean, governmentMileageRates: GovernmentMileageRate[], outputCurrency: string | undefined, @@ -405,7 +433,9 @@ function buildOnyxDataForGovernmentRateAutoUpdate( previousAutoUpdateEnabled?: boolean, previousCountryCode?: string, ): {optimisticRateIDs: Record; onyxData: OnyxData} { + const policyID = policy.id; const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const customUnit = getDistanceRateCustomUnit(policy); const customUnitID = customUnit?.customUnitID; const optimisticRates: Record = {}; @@ -415,8 +445,43 @@ function buildOnyxDataForGovernmentRateAutoUpdate( if (shouldAutoUpdateGovernmentDistanceRates && customUnit) { const copiedSourceRateIDs = new Set(Object.values(customUnit.rates ?? {}).map((rate) => rate.attributes?.governmentRate?.sourceRateID)); + // Until the server publishes fuel bands, it copies the standard UK rate to every workspace, so only use bands when there are some + const shouldUseFuelBandRates = isTaxTrackingEnabled(true, policy, true) && governmentMileageRates.some((rate) => isFuelBandGovernmentRate(rate.sourceRateID)); + + // Rates already in each series, so a copied rate can take the enabled state of the one before it. Rates copied in + // this request are added as they go, so a second rate of the same series inherits from the first. + const seriesRates = new Map>(); + const addSeriesRate = (sourceRateID: string, startDate: string | undefined, enabled: boolean | undefined) => { + if (!startDate) { + return; + } + const series = getGovernmentRateSeries(sourceRateID); + seriesRates.set(series, [...(seriesRates.get(series) ?? []), {startDate, enabled: enabled ?? true}]); + }; + for (const rate of Object.values(customUnit.rates ?? {})) { + const sourceRateID = rate.attributes?.governmentRate?.sourceRateID; + if (sourceRateID) { + addSeriesRate(sourceRateID, rate.startDate ?? rate.attributes?.governmentRate?.startDate, rate.enabled); + } + } + const getPreviousSeriesRateEnabled = (sourceRateID: string, startDate: string): boolean | undefined => { + let previousRate: {startDate: string; enabled: boolean} | undefined; + for (const seriesRate of seriesRates.get(getGovernmentRateSeries(sourceRateID)) ?? []) { + if (seriesRate.startDate < startDate && (!previousRate || seriesRate.startDate > previousRate.startDate)) { + previousRate = seriesRate; + } + } + return previousRate?.enabled; + }; - for (const governmentMileageRate of governmentMileageRates) { + // ISO 8601 dates sort chronologically as plain strings + const sortedGovernmentMileageRates = [...governmentMileageRates].sort((a, b) => { + if (a.startDate === b.startDate) { + return 0; + } + return a.startDate < b.startDate ? -1 : 1; + }); + for (const governmentMileageRate of sortedGovernmentMileageRates) { // GOVERNMENT_MILEAGE_RATES is one key shared by every policy, so it can still hold another policy's rates if (governmentMileageRate.currency !== outputCurrency) { Log.warn('[buildOnyxDataForGovernmentRateAutoUpdate] Skipping a government reference rate loaded for another currency', { @@ -429,7 +494,14 @@ function buildOnyxDataForGovernmentRateAutoUpdate( } // Several countries share the EUR currency, so also match the selected country from the sourceRateID prefix - if (countryCode && governmentMileageRate.sourceRateID.split('_').at(0) !== countryCode) { + const rateCountry = governmentMileageRate.sourceRateID.split('_').at(0); + if (countryCode && rateCountry !== countryCode) { + continue; + } + + // The UK publishes fuel-band rates alongside its standard rate. Workspaces using fuel bands get only the bands, + // and every other workspace gets only the standard rate. + if (rateCountry === CONST.CUSTOM_UNITS.GOVERNMENT_RATE_FUEL_BAND_COUNTRY && isFuelBandGovernmentRate(governmentMileageRate.sourceRateID) !== shouldUseFuelBandRates) { continue; } @@ -438,22 +510,29 @@ function buildOnyxDataForGovernmentRateAutoUpdate( continue; } + const enabled = getPreviousSeriesRateEnabled(governmentMileageRate.sourceRateID, governmentMileageRate.startDate) ?? governmentMileageRate.enabled ?? true; + addSeriesRate(governmentMileageRate.sourceRateID, governmentMileageRate.startDate, enabled); + + const taxClaimablePercentage = governmentMileageRate.taxClaimablePercentage; const customUnitRateID = generateHexadecimalValue(13); optimisticRateIDs[governmentMileageRate.sourceRateID] = customUnitRateID; + // The server links the tax rate, so taxRateExternalID comes back in its response optimisticRates[customUnitRateID] = { customUnitRateID, name: governmentMileageRate.name, rate: governmentMileageRate.rate, currency: governmentMileageRate.currency, - enabled: governmentMileageRate.enabled ?? true, + enabled, startDate: governmentMileageRate.startDate, ...(governmentMileageRate.endDate ? {endDate: governmentMileageRate.endDate} : {}), attributes: { + ...(taxClaimablePercentage !== undefined ? {taxClaimablePercentage} : {}), governmentRate: { sourceRateID: governmentMileageRate.sourceRateID, rate: governmentMileageRate.rate, startDate: governmentMileageRate.startDate, ...(governmentMileageRate.endDate ? {endDate: governmentMileageRate.endDate} : {}), + ...(taxClaimablePercentage !== undefined ? {taxClaimablePercentage} : {}), }, }, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, @@ -595,6 +674,8 @@ export { isMapOrGPSRequired, getDistanceExpenseTypeForPolicy, isGovernmentRateUnmodified, + getGovernmentRateSeries, + getTaxClaimableDisplayAmount, getDistanceRateNameError, getDistanceRateNameErrorMessage, getDistanceRateValueError, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 8f84878638b4..ff52a2af2cc8 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -1157,8 +1157,7 @@ function clearPolicyRequireMapOrGPSErrors(policyID: string) { * behavior are documented on buildOnyxDataForGovernmentRateAutoUpdate, which provides the Onyx data used here. */ function setWorkspaceDistanceAutoUpdate( - policyID: string, - customUnit: CustomUnit | undefined, + policy: Policy, shouldAutoUpdateGovernmentDistanceRates: boolean, governmentMileageRates: GovernmentMileageRate[], outputCurrency: string | undefined, @@ -1167,8 +1166,7 @@ function setWorkspaceDistanceAutoUpdate( previousCountryCode?: string, ) { const {optimisticRateIDs, onyxData} = buildOnyxDataForGovernmentRateAutoUpdate( - policyID, - customUnit, + policy, shouldAutoUpdateGovernmentDistanceRates, governmentMileageRates, outputCurrency, @@ -1178,7 +1176,7 @@ function setWorkspaceDistanceAutoUpdate( ); const parameters: SetWorkspaceDistanceAutoUpdateParams = { - policyID, + policyID: policy.id, shouldAutoUpdateGovernmentDistanceRates, ...(Object.keys(optimisticRateIDs).length > 0 ? {optimisticRateIDs: JSON.stringify(optimisticRateIDs)} : {}), ...(countryCode ? {countryCode} : {}), diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 65cef3f89e17..d1cdde2c5ff1 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2282,8 +2282,7 @@ function updateGeneralSettings( // whole currency change stays a single API call. The server applies the same copy when it saves the currency. const governmentRateAutoUpdateData = governmentRateCountry ? buildOnyxDataForGovernmentRateAutoUpdate( - policy.id, - distanceUnit, + policy, true, governmentMileageRates, currency, diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx index dfc5ef8ec2b4..74f101dfab34 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateGovernmentRateCountryPage.tsx @@ -35,22 +35,13 @@ function PolicyDistanceRateGovernmentRateCountryPage({route}: PolicyDistanceRate const goBackToSettings = () => Navigation.goBack(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)); const saveCountry = (selectedCountry: string) => { - if (!customUnit || selectedCountry === currentCountry) { + if (!policy || !customUnit || selectedCountry === currentCountry) { goBackToSettings(); return; } // Saving a country turns the auto-update on with that country, or moves an enabled workspace to the new country - setWorkspaceDistanceAutoUpdate( - policyID, - customUnit, - true, - governmentMileageRates ?? [], - policy?.outputCurrency, - selectedCountry, - !!policy?.shouldAutoUpdateGovernmentDistanceRates, - currentCountry, - ); + setWorkspaceDistanceAutoUpdate(policy, true, governmentMileageRates ?? [], policy.outputCurrency, selectedCountry, !!policy.shouldAutoUpdateGovernmentDistanceRates, currentCountry); goBackToSettings(); }; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx index 937aa2a821a6..7b49f534bbe7 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateTaxReclaimableEditPage.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {validateTaxClaimableValue} from '@libs/PolicyDistanceRatesUtils'; +import {getTaxClaimableDisplayAmount, validateTaxClaimableValue} from '@libs/PolicyDistanceRatesUtils'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -27,7 +27,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PolicyDistanceRateTaxReclaimableOnEditForm'; -import React, {useCallback} from 'react'; +import React from 'react'; type PolicyDistanceRateTaxReclaimableEditPageProps = WithPolicyOnyxProps & PlatformStackScreenProps; @@ -42,8 +42,7 @@ function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanc const customUnit = getDistanceRateCustomUnit(policy); const rate = customUnit?.rates[rateID]; const currency = rate?.currency ?? CONST.CURRENCY.USD; - const currentTaxReclaimableOnValue = - rate?.attributes?.taxClaimablePercentage && rate?.rate ? ((rate.attributes.taxClaimablePercentage * rate.rate) / 100).toFixed(CONST.MAX_TAX_RATE_DECIMAL_PLACES) : ''; + const currentTaxReclaimableOnValue = rate?.attributes?.taxClaimablePercentage && rate?.rate ? getTaxClaimableDisplayAmount(rate.attributes.taxClaimablePercentage, rate.rate) : ''; const submitTaxReclaimableOn = (values: FormOnyxValues) => { if (values.taxClaimableValue === currentTaxReclaimableOnValue) { @@ -65,10 +64,7 @@ function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanc Navigation.goBack(); }; - const validate = useCallback( - (values: FormOnyxValues) => validateTaxClaimableValue(values, rate, translate), - [rate, translate], - ); + const validate = (values: FormOnyxValues) => validateTaxClaimableValue(values, rate, translate); return ( { - if (!customUnit) { + if (!policy || !customUnit) { return; } @@ -172,13 +172,12 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag // The stored country tells the optimistic copy which rates to use, since the mileage rate key is shared by every policy. // The server ignores the country on disable, so it is only sent when turning on. setWorkspaceDistanceAutoUpdate( - policyID, - customUnit, + policy, isOn, governmentMileageRates ?? [], - policy?.outputCurrency, + policy.outputCurrency, isOn && isSharedCurrency ? autoUpdateCountry : undefined, - !!policy?.shouldAutoUpdateGovernmentDistanceRates, + !!policy.shouldAutoUpdateGovernmentDistanceRates, isSharedCurrency ? autoUpdateCountry : undefined, ); }; diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 0d663f74ae2c..c8cabec0466a 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -311,17 +311,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { case CONST.UPGRADE_FEATURE_INTRO_MAPPING.governmentDistanceRates.id: { const storedGovernmentRateCountry = getAutoUpdateGovernmentRateCountry(policy); const isSharedCurrency = isSharedGovernmentRateCurrency(policy?.outputCurrency); - if (!distanceRateCustomUnit || (isSharedCurrency && !storedGovernmentRateCountry)) { + if (!policy || !distanceRateCustomUnit || (isSharedCurrency && !storedGovernmentRateCountry)) { break; } - setWorkspaceDistanceAutoUpdate( - policyID, - distanceRateCustomUnit, - true, - governmentMileageRates ?? [], - policy?.outputCurrency, - isSharedCurrency ? storedGovernmentRateCountry : undefined, - ); + setWorkspaceDistanceAutoUpdate(policy, true, governmentMileageRates ?? [], policy.outputCurrency, isSharedCurrency ? storedGovernmentRateCountry : undefined); break; } case CONST.UPGRADE_FEATURE_INTRO_MAPPING.publicReceiptVisibility.id: diff --git a/src/types/onyx/GovernmentMileageRate.ts b/src/types/onyx/GovernmentMileageRate.ts index e482d40ff760..b1cd7e34fa01 100644 --- a/src/types/onyx/GovernmentMileageRate.ts +++ b/src/types/onyx/GovernmentMileageRate.ts @@ -1,6 +1,6 @@ /** A government-published reference mileage rate. Reference entries only - they get a customUnitRateID when copied onto a policy. */ type GovernmentMileageRate = { - /** Deterministic "_" ID (e.g. "US_2026-01-01") */ + /** Deterministic "_" ID (e.g. "US_2026-01-01"), or "__" for a UK fuel band */ sourceRateID: string; /** Currency the rate is published in */ @@ -20,6 +20,9 @@ type GovernmentMileageRate = { /** Whether the rate is enabled when copied onto a policy */ enabled?: boolean; + + /** Fraction of the rate that is tax reclaimable, only published for UK fuel bands */ + taxClaimablePercentage?: number; }; export default GovernmentMileageRate; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 996b25eefb6f..d06f1afc2b66 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -30,6 +30,9 @@ type GovernmentRateSnapshot = { /** Government-published end date (ISO 8601) at the time the rate was copied, omitted if the source rate has none */ endDate?: string; + + /** Government-published tax reclaimable fraction at the time the rate was copied, omitted if the source rate has none */ + taxClaimablePercentage?: number; }; /** General-purpose optional attributes of the policy distance rate */ diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index ecfce48824e5..f1fbb952d1e5 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -2642,6 +2642,69 @@ describe('actions/Policy', () => { expect(updatedPolicy?.shouldAutoUpdateGovernmentDistanceRates).toBe(true); expect(updatedPolicy?.autoUpdateGovernmentRateCountry ?? null).toBeNull(); }); + + describe('UK fuel-band rates', () => { + const customUnitID = 'unit_123'; + const ukStandardRate: GovernmentMileageRate = {sourceRateID: 'GB_2011-04-06', currency: 'GBP', name: 'United Kingdom', rate: 45, startDate: '2011-04-06', enabled: true}; + const petrolBand: GovernmentMileageRate = { + sourceRateID: 'GB_PETROL_UP_TO_1400_2026-09-01', + currency: 'GBP', + name: 'Cars & Vans (Petrol/Hybrid) - Up to 1400cc', + rate: 45, + startDate: '2026-09-01', + enabled: true, + taxClaimablePercentage: 13 / 45, + }; + + // A workspace tracking tax with distance tax on, which gets the fuel bands instead of the standard UK rate + const buildEligiblePolicy = () => ({ + ...createRandomPolicy(0), + name: 'Workspace', + outputCurrency: CONST.CURRENCY.USD, + shouldAutoUpdateGovernmentDistanceRates: true, + tax: {trackingEnabled: true}, + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, taxEnabled: true}, + rates: {}, + }, + }, + }); + + it('copies only the fuel bands when a government rate country is given for GBP', () => { + // Given an eligible workspace + const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve()); + const policy = buildEligiblePolicy(); + + // When the currency changes to GBP with GB as the government rate country + Policy.updateGeneralSettings(policy, policy.name, CONST.CURRENCY.GBP, {}, {governmentRateCountry: 'GB', governmentMileageRates: [ukStandardRate, petrolBand]}); + + // Then only the fuel band is copied optimistically, matching what the server copies + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS); + const params = requireRecord(requireCallArgument(apiCallArgs, 1)); + expect(Object.keys(parseJSONRecord(params.optimisticRateIDs))).toEqual(['GB_PETROL_UP_TO_1400_2026-09-01']); + + apiWriteSpy.mockRestore(); + }); + + it('copies no rates optimistically when the currency changes to GBP without a country', () => { + // Given an eligible workspace + const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve()); + const policy = buildEligiblePolicy(); + + // When the currency changes to GBP the way the currency page does it, without a government rate country + Policy.updateGeneralSettings(policy, policy.name, CONST.CURRENCY.GBP, {}, {governmentMileageRates: [ukStandardRate, petrolBand]}); + + // Then no rates are copied optimistically, because the server copies the fuel bands and its response adds them + const apiCallArgs = apiWriteSpy.mock.calls.find((call) => call.at(0) === WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS); + const params = requireRecord(requireCallArgument(apiCallArgs, 1)); + expect(params.optimisticRateIDs).toBeUndefined(); + + apiWriteSpy.mockRestore(); + }); + }); }); describe('updateAddress', () => { diff --git a/tests/unit/DistanceRateTest.ts b/tests/unit/DistanceRateTest.ts index d3e6cce00ce3..5f965095efe2 100644 --- a/tests/unit/DistanceRateTest.ts +++ b/tests/unit/DistanceRateTest.ts @@ -259,14 +259,6 @@ describe('DistanceRate', () => { }; } - function getDistanceCustomUnit(policy: Policy): CustomUnit { - const distanceCustomUnit = policy.customUnits?.[customUnitID]; - if (!distanceCustomUnit) { - throw new Error('The test policy is missing its distance custom unit'); - } - return distanceCustomUnit; - } - afterEach(() => { resetQueue(); return Onyx.clear(); @@ -277,7 +269,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); pause(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, usGovernmentRates, policy.outputCurrency); + setWorkspaceDistanceAutoUpdate(policy, true, usGovernmentRates, policy.outputCurrency); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -327,7 +319,7 @@ describe('DistanceRate', () => { const canadianRate: GovernmentMileageRate = {sourceRateID: 'CA_2026-01-01', currency: 'CAD', name: '2026 Canada', rate: 73, startDate: '2026-01-01', enabled: true}; pause(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [...usGovernmentRates, canadianRate], policy.outputCurrency); + setWorkspaceDistanceAutoUpdate(policy, true, [...usGovernmentRates, canadianRate], policy.outputCurrency); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -346,7 +338,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); pause(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [], policy.outputCurrency); + setWorkspaceDistanceAutoUpdate(policy, true, [], policy.outputCurrency); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -359,7 +351,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); pause(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [], policy.outputCurrency); + setWorkspaceDistanceAutoUpdate(policy, true, [], policy.outputCurrency); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -381,7 +373,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); pause(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), false, usGovernmentRates, policy.outputCurrency); + setWorkspaceDistanceAutoUpdate(policy, false, usGovernmentRates, policy.outputCurrency); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -401,7 +393,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); pause(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [deRate, frRate, usRate], policy.outputCurrency, 'DE'); + setWorkspaceDistanceAutoUpdate(policy, true, [deRate, frRate, usRate], policy.outputCurrency, 'DE'); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -417,7 +409,7 @@ describe('DistanceRate', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); pause(); - setWorkspaceDistanceAutoUpdate(policy.id, undefined, true, [deRate], policy.outputCurrency, 'DE'); + setWorkspaceDistanceAutoUpdate({...policy, customUnits: undefined}, true, [deRate], policy.outputCurrency, 'DE'); await waitForBatchedUpdates(); const onyxPolicy = await getPolicyFromOnyx(policy.id); @@ -439,7 +431,7 @@ describe('DistanceRate', () => { const originalFetch = global.fetch; global.fetch = mockFetch; mockFetch.fail(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [deRate], policy.outputCurrency, 'FR', true, 'DE'); + setWorkspaceDistanceAutoUpdate(policy, true, [deRate], policy.outputCurrency, 'FR', true, 'DE'); await waitForBatchedUpdates(); global.fetch = originalFetch; @@ -459,7 +451,7 @@ describe('DistanceRate', () => { const originalFetch = global.fetch; global.fetch = mockFetch; mockFetch.fail(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), true, [deRate], policy.outputCurrency, 'DE', false, 'DE'); + setWorkspaceDistanceAutoUpdate(policy, true, [deRate], policy.outputCurrency, 'DE', false, 'DE'); await waitForBatchedUpdates(); global.fetch = originalFetch; @@ -479,7 +471,7 @@ describe('DistanceRate', () => { const originalFetch = global.fetch; global.fetch = mockFetch; mockFetch.fail(); - setWorkspaceDistanceAutoUpdate(policy.id, getDistanceCustomUnit(policy), false, [], policy.outputCurrency, undefined, true, 'DE'); + setWorkspaceDistanceAutoUpdate(policy, false, [], policy.outputCurrency, undefined, true, 'DE'); await waitForBatchedUpdates(); global.fetch = originalFetch; @@ -487,6 +479,172 @@ describe('DistanceRate', () => { expect(onyxPolicy.shouldAutoUpdateGovernmentDistanceRates).toBe(true); expect(onyxPolicy.autoUpdateGovernmentRateCountry).toBe('DE'); }); + + describe('UK fuel-band rates', () => { + const ukStandardRate: GovernmentMileageRate = {sourceRateID: 'GB_2011-04-06', currency: 'GBP', name: 'United Kingdom', rate: 45, startDate: '2011-04-06', enabled: true}; + const petrolBandJune: GovernmentMileageRate = { + sourceRateID: 'GB_PETROL_UP_TO_1400_2026-06-01', + currency: 'GBP', + name: 'Cars & Vans (Petrol/Hybrid) - Up to 1400cc', + rate: 45, + startDate: '2026-06-01', + endDate: '2026-08-31', + enabled: true, + taxClaimablePercentage: 12 / 45, + }; + const petrolBandSeptember: GovernmentMileageRate = { + ...petrolBandJune, + sourceRateID: 'GB_PETROL_UP_TO_1400_2026-09-01', + startDate: '2026-09-01', + endDate: undefined, + taxClaimablePercentage: 13 / 45, + }; + const dieselBand: GovernmentMileageRate = { + sourceRateID: 'GB_DIESEL_UP_TO_1600_2026-09-01', + currency: 'GBP', + name: 'Cars & Vans (Diesel) - Up to 1600cc', + rate: 45, + startDate: '2026-09-01', + enabled: true, + taxClaimablePercentage: 11 / 45, + }; + const ukGovernmentRates = [ukStandardRate, petrolBandJune, petrolBandSeptember, dieselBand]; + + function buildUKPolicy(isTrackingTax: boolean, isDistanceTaxEnabled: boolean, rates: CustomUnit['rates'] = {}): Policy { + return buildPolicy({ + outputCurrency: 'GBP', + tax: {trackingEnabled: isTrackingTax}, + customUnits: { + [customUnitID]: { + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, taxEnabled: isDistanceTaxEnabled}, + customUnitID, + enabled: true, + name: 'Distance', + rates, + }, + }, + }); + } + + async function enableAutoUpdate(policy: Policy, governmentMileageRates: GovernmentMileageRate[] = ukGovernmentRates): Promise { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + pause(); + setWorkspaceDistanceAutoUpdate(policy, true, governmentMileageRates, policy.outputCurrency); + await waitForBatchedUpdates(); + const onyxPolicy = await getPolicyFromOnyx(policy.id); + return Object.values(onyxPolicy.customUnits?.[customUnitID].rates ?? {}); + } + + const getRateBySourceID = (rates: Rate[], sourceRateID: string) => rates.find((rate) => rate.attributes?.governmentRate?.sourceRateID === sourceRateID); + + it('should copy only the fuel bands, with their reclaimable fraction, when the workspace tracks distance tax', async () => { + // Given a GBP workspace with tax tracking and distance tax on + const policy = buildUKPolicy(true, true); + + // When auto-update is turned on + const rates = await enableAutoUpdate(policy); + + // Then only the fuel bands are copied, since the server gives these workspaces the bands instead of the standard rate + expect(rates.map((rate) => rate.attributes?.governmentRate?.sourceRateID).sort()).toEqual([ + 'GB_DIESEL_UP_TO_1600_2026-09-01', + 'GB_PETROL_UP_TO_1400_2026-06-01', + 'GB_PETROL_UP_TO_1400_2026-09-01', + ]); + + // And each band keeps its reclaimable fraction on the rate and the snapshot, so it shows the lightning bolt + const dieselRate = getRateBySourceID(rates, dieselBand.sourceRateID); + expect(dieselRate?.attributes?.taxClaimablePercentage).toBe(11 / 45); + expect(dieselRate?.attributes?.governmentRate?.taxClaimablePercentage).toBe(11 / 45); + expect(dieselRate?.attributes?.taxRateExternalID).toBeUndefined(); + expect(dieselRate && isGovernmentRateUnmodified(dieselRate, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(true); + }); + + it('should copy only the standard rate when the workspace does not track distance tax', async () => { + // Given GBP workspaces where tax tracking or distance tax is off + const distanceTaxOffPolicy = buildUKPolicy(true, false); + const taxTrackingOffPolicy = buildUKPolicy(false, true); + + // When auto-update is turned on + const distanceTaxOffRates = await enableAutoUpdate(distanceTaxOffPolicy); + resetQueue(); + await Onyx.clear(); + const taxTrackingOffRates = await enableAutoUpdate(taxTrackingOffPolicy); + + // Then only the standard rate is copied, with no reclaimable fraction + for (const rates of [distanceTaxOffRates, taxTrackingOffRates]) { + expect(rates.map((rate) => rate.attributes?.governmentRate?.sourceRateID)).toEqual(['GB_2011-04-06']); + expect(rates.at(0)?.attributes?.taxClaimablePercentage).toBeUndefined(); + } + }); + + it('should copy the standard rate when the workspace tracks distance tax but no fuel bands are published', async () => { + // Given a GBP workspace with tax tracking and distance tax on, and reference rates with only the standard rate + const policy = buildUKPolicy(true, true); + + // When auto-update is turned on + const rates = await enableAutoUpdate(policy, [ukStandardRate]); + + // Then the standard rate is copied, since the server copies it to every workspace until it publishes fuel bands + expect(rates.map((rate) => rate.attributes?.governmentRate?.sourceRateID)).toEqual(['GB_2011-04-06']); + expect(rates.at(0)?.attributes?.taxClaimablePercentage).toBeUndefined(); + }); + + it('should still copy the rates of other countries when the workspace tracks distance tax', async () => { + // Given a USD workspace with tax tracking and distance tax on + const usPolicy = {...buildUKPolicy(true, true), outputCurrency: 'USD'}; + + // When auto-update is turned on + const rates = await enableAutoUpdate(usPolicy, usGovernmentRates); + + // Then the US rates are copied as before, because only the UK publishes fuel bands + expect(rates).toHaveLength(2); + }); + + it('should copy a fuel band without a reclaimable fraction when the reference rate has none', async () => { + // Given an eligible GBP workspace and a fuel band published without a reclaimable fraction + const policy = buildUKPolicy(true, true); + const bandWithoutPercentage: GovernmentMileageRate = {...dieselBand, taxClaimablePercentage: undefined}; + + // When auto-update is turned on + const rates = await enableAutoUpdate(policy, [bandWithoutPercentage]); + + // Then the band is copied without the field on the rate or the snapshot, and still shows the lightning bolt + const dieselRate = getRateBySourceID(rates, dieselBand.sourceRateID); + expect(dieselRate).toBeDefined(); + expect(dieselRate?.attributes?.taxClaimablePercentage).toBeUndefined(); + expect(dieselRate?.attributes?.governmentRate?.taxClaimablePercentage).toBeUndefined(); + expect(dieselRate && isGovernmentRateUnmodified(dieselRate, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(true); + }); + + it('should keep a disabled fuel band disabled for every later rate of its series in the same request', async () => { + // Given an eligible GBP workspace whose earlier petrol band an admin disabled + const policy = buildUKPolicy(true, true, { + [existingRateID]: { + customUnitRateID: existingRateID, + name: 'Cars & Vans (Petrol/Hybrid) - Up to 1400cc', + rate: 45, + currency: 'GBP', + enabled: false, + startDate: '2026-03-01', + endDate: '2026-05-31', + attributes: { + taxClaimablePercentage: 12 / 45, + governmentRate: {sourceRateID: 'GB_PETROL_UP_TO_1400_2026-03-01', rate: 45, startDate: '2026-03-01', endDate: '2026-05-31', taxClaimablePercentage: 12 / 45}, + }, + }, + }); + + // When auto-update copies two later petrol periods and a diesel band, listed out of date order + const rates = await enableAutoUpdate(policy, [dieselBand, petrolBandSeptember, petrolBandJune]); + + // Then both petrol periods stay disabled, the later one inheriting from the earlier one copied in this request + expect(getRateBySourceID(rates, petrolBandJune.sourceRateID)?.enabled).toBe(false); + expect(getRateBySourceID(rates, petrolBandSeptember.sourceRateID)?.enabled).toBe(false); + + // And the diesel band, which has no earlier rate in its series, uses the reference rate's enabled state + expect(getRateBySourceID(rates, dieselBand.sourceRateID)?.enabled).toBe(true); + }); + }); }); describe('setPolicyDistanceRatesUnit', () => { diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index 5469954525ed..e634d9b1e0e3 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -6,6 +6,7 @@ import { getExpectedUnitForCurrency, getGovernmentRateCountryForCurrency, getGovernmentRateCountryPhraseTranslationKey, + getGovernmentRateSeries, isCurrencySupportedForAutoUpdate, isGovernmentRateUnmodified, isMapOrGPSRequired, @@ -134,6 +135,87 @@ describe('PolicyDistanceRatesUtils', () => { // A snapshot missing its rate amount alongside an unset rate must not be reported as unmodified. expect(isGovernmentRateUnmodified(buildRate({rate: undefined}, {sourceRateID: 'US_2026-01-01', startDate: '2026-01-01', endDate: '2026-12-31'}))).toBe(false); }); + + describe('tax reclaimable amount', () => { + // A 14p fuel band where a sixth of the rate is reclaimable, which the edit page shows as 0.0233 + const fuelBandGovernmentRate = {sourceRateID: 'GB_PETROL_UP_TO_1400_2026-06-01', rate: 14, startDate: '2026-06-01', taxClaimablePercentage: 1 / 6}; + const buildFuelBandRate = (taxClaimablePercentage?: number) => + buildRate({rate: 14, startDate: '2026-06-01', endDate: undefined, attributes: {taxClaimablePercentage, governmentRate: fuelBandGovernmentRate}}, fuelBandGovernmentRate); + + // The edit page saves the entered amount as a fraction of the rate + const getSavedTaxClaimablePercentage = (enteredAmount: string, rate: number) => (Number(enteredAmount) * 100) / rate; + + it('should return true when the reclaimable amount matches the snapshot', () => { + // Given a fuel band copied with the government reclaimable fraction + const rate = buildFuelBandRate(1 / 6); + + // When checking whether it is unmodified + const isUnmodified = isGovernmentRateUnmodified(rate); + + // Then it is, so the lightning bolt shows + expect(isUnmodified).toBe(true); + }); + + it('should return false when the reclaimable amount was edited', () => { + // Given a fuel band whose reclaimable amount an admin changed to 0.03 + const rate = buildFuelBandRate(getSavedTaxClaimablePercentage('0.03', 14)); + + // When checking whether it is unmodified + const isUnmodified = isGovernmentRateUnmodified(rate); + + // Then it is not, because the edit page would show a different amount than the government value + expect(isUnmodified).toBe(false); + }); + + it('should return false when the reclaimable amount was cleared', () => { + // Given a fuel band whose snapshot has a reclaimable fraction but the rate has none + const rate = buildFuelBandRate(undefined); + + // When checking whether it is unmodified + const isUnmodified = isGovernmentRateUnmodified(rate); + + // Then it is not, because a missing reclaimable amount counts as zero + expect(isUnmodified).toBe(false); + }); + + it('should return true when the admin restores the reclaimable amount the edit page showed', () => { + // Given a fuel band edited away from its government value + const editedRate = buildFuelBandRate(getSavedTaxClaimablePercentage('0.03', 14)); + expect(isGovernmentRateUnmodified(editedRate)).toBe(false); + + // When the admin re-enters the shown 0.0233, which saves 2.33p rather than the government's 2.3333p + const restoredRate = buildFuelBandRate(getSavedTaxClaimablePercentage('0.0233', 14)); + + // Then the rate is unmodified again, since amounts are compared at the precision the edit page shows + expect(isGovernmentRateUnmodified(restoredRate)).toBe(true); + }); + + it('should ignore the reclaimable amount when the snapshot has none', () => { + // Given a US rate and a fuel band whose snapshots have no reclaimable fraction, where an admin set one on the rate + const usRate = buildRate({attributes: {taxClaimablePercentage: 0.1, governmentRate: baseGovernmentRate}}); + const fuelBandGovernmentRateWithoutPercentage = {sourceRateID: 'GB_PETROL_UP_TO_1400_2026-06-01', rate: 14, startDate: '2026-06-01'}; + const fuelBandRate = buildRate( + {rate: 14, startDate: '2026-06-01', endDate: undefined, attributes: {taxClaimablePercentage: 0.1, governmentRate: fuelBandGovernmentRateWithoutPercentage}}, + fuelBandGovernmentRateWithoutPercentage, + ); + + // When checking whether they are unmodified + // Then both are, because there is no government reclaimable amount to compare against + expect(isGovernmentRateUnmodified(usRate)).toBe(true); + expect(isGovernmentRateUnmodified(fuelBandRate)).toBe(true); + }); + }); + }); + + describe('getGovernmentRateSeries', () => { + it('should strip only the trailing start date from the sourceRateID', () => { + // Given a standard rate, a fuel band whose name contains underscores, and an ID without a date + // When getting their series + // Then only the part after the last underscore is removed + expect(getGovernmentRateSeries('GB_2026-04-06')).toBe('GB'); + expect(getGovernmentRateSeries('GB_PETROL_UP_TO_1400_2026-06-01')).toBe('GB_PETROL_UP_TO_1400'); + expect(getGovernmentRateSeries('GB')).toBe('GB'); + }); }); describe('getGovernmentRateCountryForCurrency', () => {