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 @@ -5533,6 +5533,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);
}
}
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
8 changes: 3 additions & 5 deletions src/libs/actions/Policy/DistanceRate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1124,8 +1124,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,
Expand All @@ -1134,8 +1133,7 @@ function setWorkspaceDistanceAutoUpdate(
previousCountryCode?: string,
) {
const {optimisticRateIDs, onyxData} = buildOnyxDataForGovernmentRateAutoUpdate(
policyID,
customUnit,
policy,
shouldAutoUpdateGovernmentDistanceRates,
governmentMileageRates,
outputCurrency,
Expand All @@ -1145,7 +1143,7 @@ function setWorkspaceDistanceAutoUpdate(
);

const parameters: SetWorkspaceDistanceAutoUpdateParams = {
policyID,
policyID: policy.id,
shouldAutoUpdateGovernmentDistanceRates,
...(Object.keys(optimisticRateIDs).length > 0 ? {optimisticRateIDs: JSON.stringify(optimisticRateIDs)} : {}),
...(countryCode ? {countryCode} : {}),
Expand Down
3 changes: 1 addition & 2 deletions src/libs/actions/Policy/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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 ?? [], policy.outputCurrency, selectedCountry, !!policy.shouldAutoUpdateGovernmentDistanceRates, currentCountry);
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
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag
}, []);

const toggleAutoUpdateGovernmentRate = (isOn: boolean) => {
if (!customUnit) {
if (!policy || !customUnit) {
return;
}

Expand All @@ -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,
);
};
Expand Down
11 changes: 2 additions & 9 deletions src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading