diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8b6c6c0e4b65..bbd7187ccca1 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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, diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index 251b62ca0751..81d3a7521240 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -5,6 +5,7 @@ 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'; @@ -12,7 +13,7 @@ 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'; @@ -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(); const {rate, formattedRate, pendingAction, errors} = item; @@ -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 ? ( + + + + + + ) : undefined; return ( + {autoGeneratedIcon} )} @@ -147,13 +165,14 @@ function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLay )} {!shouldUseNarrowTableLayout && ( - + + {autoGeneratedIcon} )} diff --git a/src/languages/de.ts b/src/languages/de.ts index 95a23c719dfd..dfb072845aac 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -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', diff --git a/src/languages/en.ts b/src/languages/en.ts index 057d87f4e356..fac93be127ac 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9750a85de185..6cfdbeba3796 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -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', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1b75c445f283..fcbcdf74119f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -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', diff --git a/src/languages/it.ts b/src/languages/it.ts index dfcdf2ee5181..430add7d2519 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -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', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0ffc19ec41b5..1b38d8695416 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6944,6 +6944,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から amountPerUnit: (unit: string) => `${unit}あたりの金額`, startDate: '開始日', endDate: '終了日', + autoGeneratedRateTooltip: 'このレートは自動生成されています。', }, editor: { descriptionInputLabel: '説明', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d34f8495295a..ae591f59d411 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -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', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5171d9d5e5d5..8417803f7b7d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -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', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d4c1672a2cb5..59fef366457d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -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', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5547a6a118a9..5ffb84d17fe5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6807,6 +6807,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM amountPerUnit: (unit: string) => `每${unit}金额`, startDate: '开始日期', endDate: '结束日期', + autoGeneratedRateTooltip: '此费率为自动生成。', }, editor: { descriptionInputLabel: '描述', diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 26a18faf5147..04f258d96780 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -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'; @@ -77,7 +77,7 @@ function validateCreateDistanceRateForm( return errors; } -type PolicyDistanceRateUpdateField = keyof Pick | keyof TaxRateAttributes; +type PolicyDistanceRateUpdateField = keyof Pick | keyof RateAttributes; /** * Builds optimistic, success, and failure Onyx data for policy distance rate updates @@ -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}; diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index 89b3a7b38052..ec3249518021 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -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'; @@ -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}); }; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index a5602c2c6c46..919f91ce01da 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -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 */ @@ -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[]; @@ -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 */ @@ -2826,7 +2844,8 @@ export type { CustomUnit, Attributes, Rate, - TaxRateAttributes, + RateAttributes, + GovernmentRateSnapshot, TaxRate, TaxRates, TaxRatesWithDefault, diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index 9e1339e9c0c0..e417b0d190fa 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -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'; @@ -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 = {}, 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); + }); + }); });