Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -47,6 +47,7 @@ type WorkspaceDistanceRatesTableRowProps = {
shouldUseNarrowTableLayout: boolean;
shouldShowDateColumns: boolean;
statusLabels: Record<string, string>;
unit?: Unit;
};

function formatDate(dateString: string | null | undefined, dateFnsLocale: DateFnsLocale | undefined): string {
Expand All @@ -73,7 +74,7 @@ function getRateStatusColors(status: string, theme: ReturnType<typeof useTheme>,
}
}

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();
Expand All @@ -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;

Expand Down
14 changes: 13 additions & 1 deletion src/components/Tables/WorkspaceDistanceRatesTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -42,7 +44,16 @@ const STATUS_ORDER: Record<string, number> = {
[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']);
Expand Down Expand Up @@ -144,6 +155,7 @@ function WorkspaceDistanceRatesTable({ratesData, policyID, selectionEnabled, sel
shouldUseNarrowTableLayout={shouldUseNarrowTableLayout}
shouldShowDateColumns={hasAnyDateBound}
statusLabels={statusLabels}
unit={unit}
/>
);

Expand Down
28 changes: 26 additions & 2 deletions src/libs/PolicyDistanceRatesUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above this function is supposed to be for isGovernmentRateUnmodified. Please move it.

if (!sourceRateID || !currentUnit) {
return governmentRateAmount;
}

const snapshotCountry = sourceRateID.split('_').at(0);
const countryToUnit: Partial<Record<string, Unit>> = 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;
Comment on lines +203 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB: The conversion looked backwards to me at first because I usually think of converting distances, but since we're converting rates it's correct. It might be helpful to add a comment about this.

// If a rate is expressed in cents / km, to convert to cents / mi we multiply by a conversion factor which cancels out the kilometers: cents / km * km / mi = cents / mi. Do the opposite for a rate in cents / mi.


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.
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -489,6 +490,7 @@ function PolicyDistanceRatesPage({
<WorkspaceDistanceRatesTable
policyID={policyID}
ratesData={ratesData}
unit={currentUnit}
selectedKeys={selectedDistanceRates}
selectionEnabled={canWriteDistanceRates}
onRowSelectionChange={setSelectedDistanceRates}
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/PolicyDistanceRatesUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ describe('PolicyDistanceRatesUtils', () => {
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);
Expand Down
Loading