Skip to content
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 89 additions & 8 deletions src/libs/PolicyDistanceRatesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 "_<startDate>", e.g. "GB" for the
* standard UK rate or "GB_<fuelBand>" 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;
Expand All @@ -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. */
Expand Down Expand Up @@ -389,23 +414,28 @@ 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,
countryCode?: string,
previousAutoUpdateEnabled?: boolean,
previousCountryCode?: string,
): {optimisticRateIDs: Record<string, string>; onyxData: OnyxData<typeof ONYXKEYS.COLLECTION.POLICY>} {
const policyID = policy.id;
const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const;
const customUnit = getDistanceRateCustomUnit(policy);
const customUnitID = customUnit?.customUnitID;

const optimisticRates: Record<string, Rate> = {};
Expand All @@ -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<string, Array<{startDate: string; enabled: boolean}>>();
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Neil's AI agent, human reviewed)

I noticed the server and the App disagreed on when a copied rate inherits enabled. So I updated the issue description (and the backend spec) with a clearer rule. We're aligning the backend (Auth #25931) and the frontend on it.

The behavior we want:

  • A copied rate takes enabled from the previous rate in its series, in every country. An admin who disables a government rate opts out of it from then on. This part already works.
  • It only does so when that previous rate is still an unmodified government rate. A rate the admin edited is their own, so its enabled state shouldn't decide the next government rate. If the latest previous rate was edited, the new rate is enabled, even if an older period was disabled.
  • "Previous" goes by the government start date in the snapshot, not the rate's own dates, which the admin can edit.

Example for one band:

  • March: unmodified, disabled. June: edited by the admin. September: copied now, so it should be enabled.
  • If June were unmodified and disabled, September should be disabled.

Today, an admin who edits GB_PETROL_UP_TO_1400_2026-06-01 to their own amount and disables it gets the September band added disabled, even though they never opted out of the government rate.

Please also update the builder's doc comment ("matching the server's copy") and the note in the PR description.

}
}
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', {
Expand All @@ -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;
}

Expand All @@ -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,
Expand Down Expand Up @@ -595,6 +674,8 @@ export {
isMapOrGPSRequired,
getDistanceExpenseTypeForPolicy,
isGovernmentRateUnmodified,
getGovernmentRateSeries,
getTaxClaimableDisplayAmount,
getDistanceRateNameError,
getDistanceRateNameErrorMessage,
getDistanceRateValueError,
Expand Down
22 changes: 6 additions & 16 deletions src/libs/actions/Policy/DistanceRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,29 +1156,19 @@ function clearPolicyRequireMapOrGPSErrors(policyID: string) {
* Turns government rate auto-update on or off for a workspace. The rate copying, unit correction and missing `customUnit`
* behavior are documented on buildOnyxDataForGovernmentRateAutoUpdate, which provides the Onyx data used here.
*/
function setWorkspaceDistanceAutoUpdate(
policyID: string,
customUnit: CustomUnit | undefined,
shouldAutoUpdateGovernmentDistanceRates: boolean,
governmentMileageRates: GovernmentMileageRate[],
outputCurrency: string | undefined,
countryCode?: string,
previousAutoUpdateEnabled?: boolean,
previousCountryCode?: string,
) {
function setWorkspaceDistanceAutoUpdate(policy: Policy, shouldAutoUpdateGovernmentDistanceRates: boolean, governmentMileageRates: GovernmentMileageRate[], countryCode?: string) {
const {optimisticRateIDs, onyxData} = buildOnyxDataForGovernmentRateAutoUpdate(
policyID,
customUnit,
policy,
shouldAutoUpdateGovernmentDistanceRates,
governmentMileageRates,
outputCurrency,
policy.outputCurrency,
countryCode,
previousAutoUpdateEnabled,
previousCountryCode,
!!policy.shouldAutoUpdateGovernmentDistanceRates,
policy.autoUpdateGovernmentRateCountry,
Comment on lines 1160 to +1167

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: I have the same non-blocking comment now on this function instead of set workspace distance auto update. If we can get the parameter from the policy object directly, there's no need to pass it separately. You can fix this in a follow-up.

);

const parameters: SetWorkspaceDistanceAutoUpdateParams = {
policyID,
policyID: policy.id,
shouldAutoUpdateGovernmentDistanceRates,
...(Object.keys(optimisticRateIDs).length > 0 ? {optimisticRateIDs: JSON.stringify(optimisticRateIDs)} : {}),
...(countryCode ? {countryCode} : {}),
Expand Down
22 changes: 11 additions & 11 deletions src/libs/actions/Policy/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ import {rand64} from '@libs/NumberUtils';
import {isTrackOnboardingChoice} from '@libs/OnboardingUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PhoneNumber from '@libs/PhoneNumber';
import {buildOnyxDataForGovernmentRateAutoUpdate} from '@libs/PolicyDistanceRatesUtils';
import {buildOnyxDataForGovernmentRateAutoUpdate, getGovernmentRateCountryForCurrency} from '@libs/PolicyDistanceRatesUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import {
getCustomUnitsForDuplication,
Expand Down Expand Up @@ -2278,12 +2278,14 @@ function updateGeneralSettings(
}
}

// Enabling government rate auto-update and copying the chosen country's reference rates rides on this request, so the
// whole currency change stays a single API call. The server applies the same copy when it saves the currency.
const governmentRateAutoUpdateData = governmentRateCountry
// Enabling government rate auto-update and copying the new country's reference rates rides on this request, so the
// whole currency change stays a single API call. The server applies the same copy when it saves the currency. EUR needs
// the chosen country, and every other supported currency maps to one country, so the rates are filtered by currency.
const shouldCopyGovernmentRates =
!!governmentRateCountry || (!!policy.shouldAutoUpdateGovernmentDistanceRates && currencyPendingAction !== undefined && !!getGovernmentRateCountryForCurrency(currency));
const governmentRateAutoUpdateData = shouldCopyGovernmentRates
? buildOnyxDataForGovernmentRateAutoUpdate(
policy.id,
distanceUnit,
policy,
true,
governmentMileageRates,
currency,
Expand Down Expand Up @@ -2390,11 +2392,9 @@ function updateGeneralSettings(
completedTaskReportActionID: reviewWorkspaceSettingsTaskData.completedTaskReportActionID,
// The server mirrors this copy through its own SetWorkspaceDistanceAutoUpdate call, so send the client-side
// optimistic rate IDs to keep the persisted rates consistent with the optimistic Onyx state.
...(governmentRateCountry && {
governmentRateCountry,
...(Object.keys(governmentRateAutoUpdateData?.optimisticRateIDs ?? {}).length > 0 && {
optimisticRateIDs: JSON.stringify(governmentRateAutoUpdateData?.optimisticRateIDs),
}),
...(governmentRateCountry && {governmentRateCountry}),
...(Object.keys(governmentRateAutoUpdateData?.optimisticRateIDs ?? {}).length > 0 && {
optimisticRateIDs: JSON.stringify(governmentRateAutoUpdateData?.optimisticRateIDs),
}),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [], selectedCountry);
goBackToSettings();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT>;
Expand All @@ -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<typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM>) => {
if (values.taxClaimableValue === currentTaxReclaimableOnValue) {
Expand All @@ -65,10 +64,7 @@ function PolicyDistanceRateTaxReclaimableEditPage({route, policy}: PolicyDistanc
Navigation.goBack();
};

const validate = useCallback(
(values: FormOnyxValues<typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM>) => validateTaxClaimableValue(values, rate, translate),
[rate, translate],
);
const validate = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.POLICY_DISTANCE_RATE_TAX_RECLAIMABLE_ON_EDIT_FORM>) => validateTaxClaimableValue(values, rate, translate);

return (
<AccessOrNotFoundWrapper
Expand Down
Loading
Loading