diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx index eaa8cc29284b..e805120fedd7 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/WorkspaceDistanceRatesTableRow.tsx @@ -20,7 +20,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; -import type {Rate} from '@src/types/onyx/Policy'; +import type {Rate, Unit} from '@src/types/onyx/Policy'; import type {Locale as DateFnsLocale} from 'date-fns'; @@ -47,6 +47,7 @@ type WorkspaceDistanceRatesTableRowProps = { shouldUseNarrowTableLayout: boolean; shouldShowDateColumns: boolean; statusLabels: Record; + unit?: Unit; }; function formatDate(dateString: string | null | undefined, dateFnsLocale: DateFnsLocale | undefined): string { @@ -73,7 +74,7 @@ function getRateStatusColors(status: string, theme: ReturnType, } } -function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowDateColumns, statusLabels}: WorkspaceDistanceRatesTableRowProps) { +function WorkspaceDistanceRatesTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowDateColumns, statusLabels, unit}: WorkspaceDistanceRatesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); @@ -87,7 +88,7 @@ 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, dateFnsLocale); - const isAutoGeneratedRate = isGovernmentRateUnmodified(rate); + const isAutoGeneratedRate = isGovernmentRateUnmodified(rate, unit); // Keep the icon aligned with the text height: the narrow layout uses the smaller supporting-label font, the wide layout uses the larger display font. const boltIconSize = shouldUseNarrowTableLayout ? variables.iconSizeExtraSmall : variables.iconSizeSmall; diff --git a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx index 8ce5dc30427f..481850c8aa32 100644 --- a/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx +++ b/src/components/Tables/WorkspaceDistanceRatesTable/index.tsx @@ -14,6 +14,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {Unit} from '@src/types/onyx/Policy'; import type {ListRenderItemInfo} from '@shopify/flash-list'; @@ -28,6 +29,7 @@ type DistanceRatesTableColumnKey = 'status' | 'name' | 'rate' | 'startDate' | 'e type WorkspaceDistanceRatesTableProps = { ratesData: DistanceRateTableItemData[]; policyID: string; + unit?: Unit; selectionEnabled: boolean; selectedKeys: string[]; canWriteDistanceRates: boolean; @@ -42,7 +44,16 @@ const STATUS_ORDER: Record = { [CONST.CUSTOM_UNITS.RATE_STATUS.INACTIVE]: 3, }; -function WorkspaceDistanceRatesTable({ratesData, policyID, selectionEnabled, selectedKeys, canWriteDistanceRates, onRowSelectionChange, headerComponent}: WorkspaceDistanceRatesTableProps) { +function WorkspaceDistanceRatesTable({ + ratesData, + policyID, + unit, + selectionEnabled, + selectedKeys, + canWriteDistanceRates, + onRowSelectionChange, + headerComponent, +}: WorkspaceDistanceRatesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Plus']); @@ -144,6 +155,7 @@ function WorkspaceDistanceRatesTable({ratesData, policyID, selectionEnabled, sel shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} shouldShowDateColumns={hasAnyDateBound} statusLabels={statusLabels} + unit={unit} /> ); diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 6320b19a541e..7908e509ffa8 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -185,13 +185,36 @@ function getRateStatus(rate: Rate): string { return CONST.CUSTOM_UNITS.RATE_STATUS.ACTIVE; } +function getGovernmentRateAmountForUnit(governmentRateAmount: number, sourceRateID: string | undefined, currentUnit: Unit | undefined): number { + if (!sourceRateID || !currentUnit) { + return governmentRateAmount; + } + + const snapshotCountry = sourceRateID.split('_').at(0); + const countryToUnit: Partial> = CONST.CUSTOM_UNITS.GOVERNMENT_RATE_COUNTRY_TO_UNIT; + const snapshotUnit = snapshotCountry ? countryToUnit[snapshotCountry] : undefined; + + if (!snapshotUnit || snapshotUnit === currentUnit) { + return governmentRateAmount; + } + + // If a rate is expressed in cents / km, converting to cents / mi means multiplying by a factor that cancels the kilometers: + // cents / km * km / mi = cents / mi. Do the opposite for a rate expressed in cents / mi. + const convertedAmount = + snapshotUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS + ? governmentRateAmount * CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS + : governmentRateAmount / CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS; + + return Math.round(convertedAmount * 100) / 100; +} + /** * 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 { +function isGovernmentRateUnmodified(rate: Rate, currentUnit?: Unit): 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. @@ -201,7 +224,8 @@ function isGovernmentRateUnmodified(rate: Rate): boolean { // 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; + const governmentRateAmount = getGovernmentRateAmountForUnit(governmentRate.rate, governmentRate.sourceRateID, currentUnit); + const isRateAmountMatching = Math.abs(rate.rate - governmentRateAmount) < CONST.CUSTOM_UNITS.GOVERNMENT_RATE_MATCH_TOLERANCE; return isRateAmountMatching && (rate.startDate ?? undefined) === governmentRate.startDate && (rate.endDate ?? undefined) === governmentRate.endDate; } diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index ef8cf9cda621..de8c2c657abd 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -215,6 +215,7 @@ function PolicyDistanceRatesPage({ ); const unitTranslation = translate(`common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`); + const currentUnit = customUnit?.attributes?.unit; const addRate = () => { Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(policyID)); @@ -489,6 +490,7 @@ function PolicyDistanceRatesPage({ { expect(isGovernmentRateUnmodified(buildRate({rate: Number('0.29') * 100}, governmentRate))).toBe(true); }); + it('should return true when a kilometer-based government snapshot matches a mile-based stored rate', () => { + const governmentRate = {sourceRateID: 'CA_2026-01-01', rate: 73, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 117.48}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(true); + }); + + it('should return true when a mile-based government snapshot matches a kilometer-based stored rate', () => { + const governmentRate = {sourceRateID: 'US_2026-01-01', rate: 76, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 47.22}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS)).toBe(true); + }); + + it('should return false when a converted government rate has been edited', () => { + const governmentRate = {sourceRateID: 'CA_2026-01-01', rate: 73, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 117.49}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(false); + }); + + it('should fall back to the same-unit comparison when the source country is unknown', () => { + const governmentRate = {sourceRateID: 'NZ_2026-01-01', rate: 73, startDate: '2026-01-01', endDate: '2026-12-31'}; + expect(isGovernmentRateUnmodified(buildRate({rate: 73}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(true); + expect(isGovernmentRateUnmodified(buildRate({rate: 117.48}, governmentRate), CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(false); + }); + 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);