Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
112 changes: 97 additions & 15 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,24 +414,27 @@ 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
* (ordered by the snapshot's start date) when that rate is an unmodified government rate, and is enabled otherwise.
*
* The previous enabled state and country describe the policy before the change, so a failed request restores exactly
* what the server still has.
* A failed request restores the policy's current auto-update flag and country, which is 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 currentUnit = customUnit?.attributes?.unit;

const optimisticRates: Record<string, Rate> = {};
const clearedRatePendingActions: Record<string, NullishDeep<Rate>> = {};
Expand All @@ -415,8 +443,45 @@ 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. Rates are ordered
// by the snapshot's start date, since the admin can edit the rate's own dates.
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) {
// A rate the admin edited is their own, so its enabled state doesn't decide the next government rate
addSeriesRate(sourceRateID, rate.attributes?.governmentRate?.startDate, isGovernmentRateUnmodified(rate, currentUnit) ? rate.enabled : true);
}
}
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,31 @@ function buildOnyxDataForGovernmentRateAutoUpdate(
continue;
}

copiedSourceRateIDs.add(governmentMileageRate.sourceRateID);

const enabled = getPreviousSeriesRateEnabled(governmentMileageRate.sourceRateID, governmentMileageRate.startDate) ?? 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 All @@ -463,12 +544,11 @@ function buildOnyxDataForGovernmentRateAutoUpdate(
}
}

const currentUnit = customUnit?.attributes?.unit;
const expectedUnit = countryCode ? getExpectedUnitForCountry(countryCode) : getExpectedUnitForCurrency(outputCurrency);
const shouldCorrectUnit = shouldAutoUpdateGovernmentDistanceRates && !!expectedUnit && !!currentUnit && currentUnit !== expectedUnit;

// The failure data restores the state the server still has, so the previous values decide what to write back
const failureAutoUpdateValue = previousAutoUpdateEnabled ? true : null;
const failureAutoUpdateValue = policy.shouldAutoUpdateGovernmentDistanceRates ? true : null;

const optimisticCustomUnit: NullishDeep<CustomUnit> = {
...(Object.keys(optimisticRates).length > 0 ? {rates: optimisticRates} : {}),
Expand Down Expand Up @@ -520,7 +600,7 @@ function buildOnyxDataForGovernmentRateAutoUpdate(
key: policyKey,
value: {
shouldAutoUpdateGovernmentDistanceRates: failureAutoUpdateValue,
...(countryCode ? {autoUpdateGovernmentRateCountry: previousCountryCode ?? null} : {}),
...(countryCode ? {autoUpdateGovernmentRateCountry: policy.autoUpdateGovernmentRateCountry ?? null} : {}),
pendingFields: {
shouldAutoUpdateGovernmentDistanceRates: null,
...(countryCode ? {autoUpdateGovernmentRateCountry: null} : {}),
Expand Down Expand Up @@ -595,6 +675,8 @@ export {
isMapOrGPSRequired,
getDistanceExpenseTypeForPolicy,
isGovernmentRateUnmodified,
getGovernmentRateSeries,
getTaxClaimableDisplayAmount,
getDistanceRateNameError,
getDistanceRateNameErrorMessage,
getDistanceRateValueError,
Expand Down
20 changes: 4 additions & 16 deletions src/libs/actions/Policy/DistanceRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,29 +1156,17 @@ 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,
);

const parameters: SetWorkspaceDistanceAutoUpdateParams = {
policyID,
policyID: policy.id,
shouldAutoUpdateGovernmentDistanceRates,
...(Object.keys(optimisticRateIDs).length > 0 ? {optimisticRateIDs: JSON.stringify(optimisticRateIDs)} : {}),
...(countryCode ? {countryCode} : {}),
Expand Down
30 changes: 11 additions & 19 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,19 +2278,13 @@ 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
? buildOnyxDataForGovernmentRateAutoUpdate(
policy.id,
distanceUnit,
true,
governmentMileageRates,
currency,
governmentRateCountry,
!!policy.shouldAutoUpdateGovernmentDistanceRates,
policy.autoUpdateGovernmentRateCountry,
)
// 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, true, governmentMileageRates, currency, governmentRateCountry)
: undefined;

const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.POLICY>> = [
Expand Down Expand Up @@ -2390,11 +2384,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
Loading
Loading