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
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4447,6 +4447,9 @@ const CONST = {
DEFAULT_RATE: 'Default Rate',
NEW_RATE: 'New Rate',
RATE_DECIMALS: 3,
// Rate amounts are stored as `Number(value) * 100` cents, which can introduce tiny floating-point errors. Meaningful
// amounts differ by at least 0.01 cents, so this tolerance safely absorbs the float noise when matching government rates.
GOVERNMENT_RATE_MATCH_TOLERANCE: 0.001,
FAKE_P2P_ID: '_FAKE_P2P_ID_',
UNSET_DISTANCE_RATE_ID: '-1',
MILES_TO_KILOMETERS: 1.609344,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import Table from '@components/Table';
import type {TableData} from '@components/Table';
import {useTableContext} from '@components/Table/TableContext';
import TextWithTooltip from '@components/TextWithTooltip';
import Tooltip from '@components/Tooltip';

import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import {getRateStatus} from '@libs/PolicyDistanceRatesUtils';
import {getRateStatus, isGovernmentRateUnmodified} from '@libs/PolicyDistanceRatesUtils';

import variables from '@styles/variables';

Expand Down Expand Up @@ -73,7 +74,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']);
const Expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Bolt']);
const {processedData} = useTableContext<DistanceRateTableItemData>();

const {rate, formattedRate, pendingAction, errors} = item;
Expand All @@ -82,8 +83,24 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay
const status = getRateStatus(rate);
const statusColors = getRateStatusColors(status, theme, isSelected);
const dateLabelText = DistanceRequestUtils.getRateDateLabel({...rate, unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, translate);

const accessibilityLabel = [rate.name, statusLabels[status], formattedRate, dateLabelText].filter(Boolean).join(', ');
const isAutoGeneratedRate = isGovernmentRateUnmodified(rate);

const accessibilityLabel = [rate.name, statusLabels[status], formattedRate, dateLabelText, isAutoGeneratedRate ? translate('workspace.distanceRates.autoGeneratedRateTooltip') : '']
.filter(Boolean)
.join(', ');

const autoGeneratedIcon = isAutoGeneratedRate ? (
<Tooltip text={translate('workspace.distanceRates.autoGeneratedRateTooltip')}>
<View>
<Icon
src={Expensicons.Bolt}
width={variables.iconSizeSmall}
height={variables.iconSizeSmall}
fill={theme.icon}
/>
</View>
</Tooltip>
) : undefined;

return (
<Table.Row
Expand Down Expand Up @@ -121,6 +138,7 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay
numberOfLines={1}
style={[styles.textLabelSupporting, styles.lh16, styles.pre, styles.flexShrink1]}
/>
{autoGeneratedIcon}
</View>
</View>
)}
Expand All @@ -147,13 +165,14 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay
)}

{!shouldUseNarrowTableLayout && (
<View style={[styles.flex1]}>
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter, styles.gap2]}>
<TextWithTooltip
shouldShowTooltip
numberOfLines={1}
text={formattedRate}
style={[styles.lh16, styles.optionDisplayName, styles.pre]}
style={[styles.lh16, styles.optionDisplayName, styles.pre, styles.flexShrink1]}
/>
{autoGeneratedIcon}
</View>
)}

Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7043,6 +7043,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`,
amountPerUnit: (unit: string) => `Betrag pro ${unit}`,
startDate: 'Startdatum',
endDate: 'Enddatum',
autoGeneratedRateTooltip: 'Dieser Satz wurde automatisch erzeugt.',
},
editor: {
descriptionInputLabel: 'Beschreibung',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7223,6 +7223,7 @@ const translations = {
amountPerUnit: (unit: string) => `Amount per ${unit}`,
startDate: 'Start date',
endDate: 'End date',
autoGeneratedRateTooltip: 'This rate is auto-generated.',
errors: {
rateNameRequired: 'Rate name is required',
existingRateName: 'A distance rate with this name already exists',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6957,6 +6957,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
amountPerUnit: (unit: string) => `Importe por ${unit}`,
startDate: 'Fecha de inicio',
endDate: 'Fecha de fin',
autoGeneratedRateTooltip: 'Esta tasa se genera automáticamente.',
},
editor: {
nameInputLabel: 'Nombre',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7071,6 +7071,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`,
amountPerUnit: (unit: string) => `Montant par ${unit}`,
startDate: 'Date de début',
endDate: 'Date de fin',
autoGeneratedRateTooltip: 'Ce taux est généré automatiquement.',
},
editor: {
descriptionInputLabel: 'Description',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7026,6 +7026,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`,
amountPerUnit: (unit: string) => `Importo per ${unit}`,
startDate: 'Data di inizio',
endDate: 'Data di fine',
autoGeneratedRateTooltip: 'Questa tariffa è generata automaticamente.',
},
editor: {
descriptionInputLabel: 'Descrizione',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6944,6 +6944,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から
amountPerUnit: (unit: string) => `${unit}あたりの金額`,
startDate: '開始日',
endDate: '終了日',
autoGeneratedRateTooltip: 'このレートは自動生成されています。',
},
editor: {
descriptionInputLabel: '説明',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7010,6 +7010,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`,
amountPerUnit: (unit: string) => `Bedrag per ${unit}`,
startDate: 'Startdatum',
endDate: 'Einddatum',
autoGeneratedRateTooltip: 'Dit tarief is automatisch gegenereerd.',
},
editor: {
descriptionInputLabel: 'Beschrijving',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6990,6 +6990,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`,
amountPerUnit: (unit: string) => `Kwota za ${unit}`,
startDate: 'Data rozpoczęcia',
endDate: 'Data zakończenia',
autoGeneratedRateTooltip: 'Ta stawka jest generowana automatycznie.',
},
editor: {
descriptionInputLabel: 'Opis',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7002,6 +7002,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`,
amountPerUnit: (unit: string) => `Valor por ${unit}`,
startDate: 'Data de início',
endDate: 'Data de término',
autoGeneratedRateTooltip: 'Essa tarifa é gerada automaticamente.',
},
editor: {
descriptionInputLabel: 'Descrição',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6807,6 +6807,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
amountPerUnit: (unit: string) => `每${unit}金额`,
startDate: '开始日期',
endDate: '结束日期',
autoGeneratedRateTooltip: '此费率为自动生成。',
},
editor: {
descriptionInputLabel: '描述',
Expand Down
27 changes: 24 additions & 3 deletions src/libs/PolicyDistanceRatesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {CustomUnit, Rate, TaxRateAttributes} from '@src/types/onyx/Policy';
import type {CustomUnit, Rate, RateAttributes} from '@src/types/onyx/Policy';
import type {OnyxData} from '@src/types/onyx/Request';

import type {NullishDeep, OnyxUpdate} from 'react-native-onyx';
Expand Down Expand Up @@ -77,7 +77,7 @@ function validateCreateDistanceRateForm(
return errors;
}

type PolicyDistanceRateUpdateField = keyof Pick<Rate, 'name' | 'rate' | 'startDate' | 'endDate'> | keyof TaxRateAttributes;
type PolicyDistanceRateUpdateField = keyof Pick<Rate, 'name' | 'rate' | 'startDate' | 'endDate'> | keyof RateAttributes;

/**
* Builds optimistic, success, and failure Onyx data for policy distance rate updates
Expand Down Expand Up @@ -182,4 +182,25 @@ function getRateStatus(rate: Rate): string {
return CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE;
}

export {validateRateValue, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates, getRateStatus};
/**
* 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.
*/
function isGovernmentRateUnmodified(rate: Rate): boolean {
const governmentRate = rate.attributes?.governmentRate;
// A snapshot without a rate amount (e.g. malformed data) can never be matched, otherwise `undefined === undefined` would
// incorrectly report an unset rate as unmodified.
if (!governmentRate || governmentRate.rate === undefined || rate.rate === undefined) {
return false;
}

// The submit path stores the amount as `Number(value) * 100`, which can introduce tiny floating-point errors (e.g. restoring
// 0.29 yields 28.999999999999996), so compare amounts within a tolerance rather than requiring strict equality.
const isRateAmountMatching = Math.abs(rate.rate - governmentRate.rate) < CONST.CUSTOM_UNITS.GOVERNMENT_RATE_MATCH_TOLERANCE;

return isRateAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate;
}

export {validateRateValue, validateTaxClaimableValue, validateCreateDistanceRateForm, buildOnyxDataForPolicyDistanceRateUpdates, getRateStatus, isGovernmentRateUnmodified};
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {Report, Transaction} from '@src/types/onyx';
import type {Rate, TaxRateAttributes} from '@src/types/onyx/Policy';
import type {Rate, RateAttributes} from '@src/types/onyx/Policy';

import type {OnyxCollection} from 'react-native-onyx';

Expand Down Expand Up @@ -159,7 +159,7 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro
const taxClaimableValueToDisplay = taxClaimablePercentage && rate.rate ? convertAmountToDisplayString(taxClaimablePercentage * rate.rate, currency) : '';
const unitToDisplay = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`);

const clearErrorFields = (fieldName: keyof Rate | keyof TaxRateAttributes) => {
const clearErrorFields = (fieldName: keyof Rate | keyof RateAttributes) => {
clearPolicyDistanceRateErrorFields(policyID, customUnit.customUnitID, rateID, {...errorFields, [fieldName]: null});
};

Expand Down
31 changes: 25 additions & 6 deletions src/types/onyx/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,31 @@ import type {WorkspaceTravelSettings} from './TravelSettings';
/** Distance units */
type Unit = 'mi' | 'km';

/** Tax rate attributes of the policy distance rate */
type TaxRateAttributes = {
/** Snapshot of the government-published values at the time a government rate was copied onto the policy */
type GovernmentRateSnapshot = {
/** Deterministic ID of the source government rate, derived from country and start date (e.g. "US_2026-01-01") */
sourceRateID?: string;

/** Government-published rate amount at the time the rate was copied */
rate?: number;

/** Government-published start date (ISO 8601) at the time the rate was copied, omitted if the source rate has none */
startDate?: string;

/** Government-published end date (ISO 8601) at the time the rate was copied, omitted if the source rate has none */
endDate?: string;
};

/** General-purpose optional attributes of the policy distance rate */
type RateAttributes = {
/** Percentage of the tax that can be reclaimable */
taxClaimablePercentage?: number;

/** External ID associated to this tax rate */
taxRateExternalID?: string;

/** Snapshot of the government-published rate this rate was copied from. Only set on rates copied from the government rate table */
governmentRate?: GovernmentRateSnapshot;
};

/** Model of policy subrate */
Expand Down Expand Up @@ -60,8 +78,8 @@ type Rate = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Form fields that triggered the errors */
errorFields?: OnyxCommon.ErrorFields;

/** Tax rate attributes of the policy */
attributes?: TaxRateAttributes;
/** General-purpose optional attributes of the rate, such as VAT reclaim fields and government-rate metadata */
attributes?: RateAttributes;

/** Subrates of the given rate */
subRates?: Subrate[];
Expand All @@ -75,7 +93,7 @@ type Rate = OnyxCommon.OnyxValueWithOfflineFeedback<
/** ISO 8601 date string for when this rate expires */
endDate?: string | null;
},
keyof TaxRateAttributes
keyof RateAttributes
>;

/** Custom unit attributes */
Expand Down Expand Up @@ -2826,7 +2844,8 @@ export type {
CustomUnit,
Attributes,
Rate,
TaxRateAttributes,
RateAttributes,
GovernmentRateSnapshot,
TaxRate,
TaxRates,
TaxRatesWithDefault,
Expand Down
78 changes: 77 additions & 1 deletion tests/unit/PolicyDistanceRatesUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {validateTaxClaimableValue} from '@libs/PolicyDistanceRatesUtils';
import {isGovernmentRateUnmodified, validateTaxClaimableValue} from '@libs/PolicyDistanceRatesUtils';

import type {GovernmentRateSnapshot, Rate} from '@src/types/onyx/Policy';

import {translateLocal} from '../utils/TestHelper';

Expand All @@ -23,4 +25,78 @@ describe('PolicyDistanceRatesUtils', () => {
expect(validate3.taxClaimableValue).toBeUndefined();
});
});

describe('isGovernmentRateUnmodified', () => {
const baseGovernmentRate = {
sourceRateID: 'US_2026-01-01',
rate: 72.5,
startDate: '2026-01-01',
endDate: '2026-12-31',
};

function buildRate(overrides: Partial<Rate> = {}, governmentRate: GovernmentRateSnapshot = baseGovernmentRate): Rate {
return {
customUnitRateID: 'rate1',
rate: 72.5,
startDate: '2026-01-01',
endDate: '2026-12-31',
attributes: {governmentRate},
...overrides,
};
}

it('should return false when the rate has no government rate snapshot', () => {
expect(isGovernmentRateUnmodified({customUnitRateID: 'rate1', rate: 72.5})).toBe(false);
expect(isGovernmentRateUnmodified({customUnitRateID: 'rate1', rate: 72.5, attributes: {}})).toBe(false);
});

it('should return true when the rate amount, start date, and end date all match the snapshot', () => {
expect(isGovernmentRateUnmodified(buildRate())).toBe(true);
});

it('should return false when the rate amount does not match the snapshot', () => {
expect(isGovernmentRateUnmodified(buildRate({rate: 70}))).toBe(false);
});

it('should return false when the start date does not match the snapshot', () => {
expect(isGovernmentRateUnmodified(buildRate({startDate: '2026-02-01'}))).toBe(false);
});

it('should return false when the end date does not match the snapshot', () => {
expect(isGovernmentRateUnmodified(buildRate({endDate: '2026-11-30'}))).toBe(false);
});

it('should return true when the end date is omitted on both the rate and the snapshot', () => {
const governmentRate = {sourceRateID: 'GB_2011-04-06', rate: 45, startDate: '2011-04-06'};
expect(isGovernmentRateUnmodified(buildRate({rate: 45, startDate: '2011-04-06', endDate: undefined}, governmentRate))).toBe(true);
expect(isGovernmentRateUnmodified(buildRate({rate: 45, startDate: '2011-04-06', endDate: null}, governmentRate))).toBe(true);
});

it('should return false when the end date is omitted on only one side', () => {
// Snapshot has no end date but the rate does
const openEndedGovernmentRate = {sourceRateID: 'GB_2011-04-06', rate: 45, startDate: '2011-04-06'};
expect(isGovernmentRateUnmodified(buildRate({rate: 45, startDate: '2011-04-06', endDate: '2026-12-31'}, openEndedGovernmentRate))).toBe(false);

// Snapshot has an end date but the rate does not
expect(isGovernmentRateUnmodified(buildRate({endDate: null}))).toBe(false);
expect(isGovernmentRateUnmodified(buildRate({endDate: undefined}))).toBe(false);
});

it('should return true when edited values are restored to match the snapshot', () => {
const editedRate = buildRate({rate: 99});
expect(isGovernmentRateUnmodified(editedRate)).toBe(false);
expect(isGovernmentRateUnmodified({...editedRate, rate: baseGovernmentRate.rate})).toBe(true);
});

it('should return true when the restored amount differs only by floating-point noise from the cents conversion', () => {
// Restoring 0.29 through the submit path stores `Number('0.29') * 100`, which yields 28.999999999999996 rather than 29.
const governmentRate = {sourceRateID: 'US_2026-01-01', rate: 29, startDate: '2026-01-01', endDate: '2026-12-31'};
expect(isGovernmentRateUnmodified(buildRate({rate: Number('0.29') * 100}, governmentRate))).toBe(true);
});

it('should return false when the snapshot is malformed and has no rate amount', () => {
// 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);
});
});
});
Loading