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
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8487,6 +8487,8 @@ const CONST = {
ORIGINAL_CURRENCY: 'originalCurrency',
UNIQUE_ID: 'uniqueID',
EXTERNAL_ID: 'externalID',
MAX_AMOUNT_NO_RECEIPT: 'maxAmountNoReceipt',
MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'maxAmountNoItemizedReceipt',
},

IMPORT_SPREADSHEET: {
Expand Down
16 changes: 14 additions & 2 deletions src/components/ImportColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {Str} from 'expensify-common';
import React, {useEffect, useRef} from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
Expand All @@ -14,7 +13,7 @@ import Text from './Text';
// cspell:disable
function findColumnName(header: string, columnRoles?: ColumnRole[]): string {
let attribute = '';
const formattedHeader = Str.removeSpaces(String(header).toLowerCase().trim());
const formattedHeader = String(header).toLowerCase().trim().replaceAll(' ', '');
switch (formattedHeader) {
case 'email':
case 'emailaddress':
Expand Down Expand Up @@ -142,6 +141,19 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string {
attribute = CONST.CSV_IMPORT_COLUMNS.ENABLED;
break;

case 'receiptsrequired':
case 'requirereceiptsover':
case 'maxamountnoreceipt':
attribute = CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_RECEIPT;
break;

case 'itemisedreceiptrequirement':

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.

const formattedHeader = Str.removeSpaces(String(header).toLowerCase().trim());

@MelvinBot Let’s fix formattedHeader in the findColumnName function, since Str.removeSpaces doesn’t replace all spaces. The current runtime value is itemisedreceipt requirement.

    const formattedHeader = String(header).toLowerCase().trim().replaceAll(' ', '');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — replaced Str.removeSpaces with .replaceAll(' ' , ' ' ) and removed the unused expensify-common import.

case 'itemizedreceiptrequirement':
case 'requireitemizedreceiptsover':
case 'maxamountnoitemizedreceipt':
attribute = CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_ITEMIZED_RECEIPT;
break;

default:
break;
}
Expand Down
9 changes: 8 additions & 1 deletion src/libs/actions/Policy/Category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,12 @@ function importPolicyCategories(policyID: string, categories: PolicyCategory[],
const existing = policyCategories[name];
if (!existing) {
acc.added++;
} else if (existing.enabled !== category.enabled || (existing['GL Code'] ?? '') !== (category['GL Code'] ?? '')) {
} else if (
existing.enabled !== category.enabled ||
(existing['GL Code'] ?? '') !== (category['GL Code'] ?? '') ||
('maxAmountNoReceipt' in category && existing.maxAmountNoReceipt !== category.maxAmountNoReceipt) ||
('maxAmountNoItemizedReceipt' in category && existing.maxAmountNoItemizedReceipt !== category.maxAmountNoItemizedReceipt)
) {
acc.updated++;
}

Expand All @@ -952,6 +957,8 @@ function importPolicyCategories(policyID: string, categories: PolicyCategory[],
enabled: category.enabled,
// eslint-disable-next-line @typescript-eslint/naming-convention
'GL Code': String(category['GL Code']),
...('maxAmountNoReceipt' in category && {maxAmountNoReceipt: category.maxAmountNoReceipt}),
...('maxAmountNoItemizedReceipt' in category && {maxAmountNoItemizedReceipt: category.maxAmountNoItemizedReceipt}),
})),
),
};
Expand Down
49 changes: 46 additions & 3 deletions src/pages/workspace/categories/ImportedCategoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,33 @@ import SCREENS from '@src/SCREENS';
import type {Errors} from '@src/types/onyx/OnyxCommon';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';

/**
* Parses a CSV cell value for receipt requirement columns.
* Mirrors the OD import logic: "required"/"always_required" → 0,
* "not_required" → DISABLED_MAX_EXPENSE_VALUE, numeric string → number.
* Returns undefined for unmapped columns, empty/default values, or invalid input.
*/
function parseCsvReceiptValue(raw: string | undefined): number | undefined {
if (raw === undefined) {
return undefined;
}
const trimmed = raw.trim().toLowerCase();

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.

❌ CONSISTENCY-2 (docs)

The string literals 'default', 'required', 'always_required', and 'not_required' are domain-specific CSV receipt requirement values used in parseCsvReceiptValue. These are not self-explanatory and should be extracted into named constants (e.g., in CONST.CSV_IMPORT_COLUMNS or a new CONST.CATEGORY_RECEIPT_VALUES) so their meaning is clear and they can be maintained in one place.

Suggested fix: Define named constants and reference them:

// In CONST
CATEGORY_RECEIPT_CSV_VALUES: {
    DEFAULT: 'default',
    REQUIRED: 'required',
    ALWAYS_REQUIRED: 'always_required',
    NOT_REQUIRED: 'not_required',
},

Then use CONST.CATEGORY_RECEIPT_CSV_VALUES.DEFAULT, etc. in the parsing function.


Reviewed at: 5be506e | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

if (!trimmed || trimmed === 'default') {
return undefined;
}
if (trimmed === 'required' || trimmed === 'always_required') {
return 0;

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.

❌ CONSISTENCY-2 (docs)

The magic number 0 is returned to represent "receipts always required" (when trimmed === 'required' || trimmed === 'always_required'). This same convention is used elsewhere in the codebase (e.g., CategoryUtils.ts, CategoryRequireReceiptsOverPage.tsx) but without a named constant. Consider defining a constant like CONST.ALWAYS_REQUIRE_RECEIPTS_VALUE (or similar) set to 0 to make the meaning explicit, matching the existing pattern of CONST.DISABLED_MAX_EXPENSE_VALUE for the opposite case.

Suggested fix:

// In CONST
ALWAYS_REQUIRE_RECEIPTS_VALUE: 0,

Then use CONST.ALWAYS_REQUIRE_RECEIPTS_VALUE instead of the bare 0.


Reviewed at: 5be506e | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

}
if (trimmed === 'not_required') {
return CONST.DISABLED_MAX_EXPENSE_VALUE;
}
const num = Number(trimmed);
if (Number.isFinite(num) && num >= 0) {
return num;
}
Comment on lines +46 to +49

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.

So this value should never be a number; I think if it is we should probably show an error? Or did we discuss this before haha.

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.

what do we do for other values if not in the expected format? Do we handle that on the backend, or first in App?

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.

We could maybe just treat anything but 0 as required?

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.

Currently, our BE still supports submitting values greater than 0, but if it’s not the MAX_AMOUNT_NO_RECEIPT value, it still returns the default value. If we don't have a feature to update the default value I think we can return 0 in this case

CleanShot.2026-05-07.at.08.32.46.1.mp4

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.

oh okay if we do that on the backend, it's not a huge deal. I think this is fine for now, we can always update later if it ends up being unexpected.

return undefined;
}

type ImportedCategoriesPageProps = {
route: RouteProp<SettingsNavigatorParamList, typeof SCREENS.WORKSPACE.DYNAMIC_CATEGORIES_IMPORTED | typeof SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_IMPORTED>;
};
Expand Down Expand Up @@ -52,7 +79,11 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) {
);

if (isControlPolicy(policy)) {
roles.push({text: translate('workspace.categories.glCode'), value: CONST.CSV_IMPORT_COLUMNS.GL_CODE});
roles.push(
{text: translate('workspace.categories.glCode'), value: CONST.CSV_IMPORT_COLUMNS.GL_CODE},
{text: translate('workspace.rules.categoryRules.requireReceiptsOver'), value: CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_RECEIPT},
{text: translate('workspace.rules.categoryRules.requireItemizedReceiptsOver'), value: CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_ITEMIZED_RECEIPT},
);
}

return roles;
Expand Down Expand Up @@ -99,17 +130,29 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) {
const categoriesNamesColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.NAME);
const categoriesGLCodeColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.GL_CODE);
const categoriesEnabledColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.ENABLED);
const categoriesMaxAmountNoReceiptColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_RECEIPT);
const categoriesMaxAmountNoItemizedReceiptColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MAX_AMOUNT_NO_ITEMIZED_RECEIPT);
const categoriesNames = spreadsheet?.data[categoriesNamesColumn].map((name) => name);
const categoriesEnabled = categoriesEnabledColumn !== -1 ? spreadsheet?.data[categoriesEnabledColumn].map((enabled) => enabled) : [];
const categoriesGLCode = categoriesGLCodeColumn !== -1 ? spreadsheet?.data[categoriesGLCodeColumn].map((glCode) => glCode) : [];
const categoriesMaxAmountNoReceipt = categoriesMaxAmountNoReceiptColumn !== -1 ? spreadsheet?.data[categoriesMaxAmountNoReceiptColumn] : [];
const categoriesMaxAmountNoItemizedReceipt = categoriesMaxAmountNoItemizedReceiptColumn !== -1 ? spreadsheet?.data[categoriesMaxAmountNoItemizedReceiptColumn] : [];
const categories = categoriesNames?.slice(containsHeader ? 1 : 0).map((name, index) => {
const categoryAlreadyExists = policyCategories?.[name];
const existingGLCodeOrDefault = categoryAlreadyExists?.['GL Code'] ?? '';
const dataIndex = containsHeader ? index + 1 : index;

const parsedMaxAmountNoReceipt = categoriesMaxAmountNoReceiptColumn !== -1 ? parseCsvReceiptValue(categoriesMaxAmountNoReceipt?.[dataIndex]?.toString()) : undefined;
const parsedMaxAmountNoItemizedReceipt =
categoriesMaxAmountNoItemizedReceiptColumn !== -1 ? parseCsvReceiptValue(categoriesMaxAmountNoItemizedReceipt?.[dataIndex]?.toString()) : undefined;

return {
name,
enabled: categoriesEnabledColumn !== -1 ? ['true', 'yes'].includes(categoriesEnabled?.[containsHeader ? index + 1 : index]?.toString().trim().toLowerCase() ?? '') : true,
enabled: categoriesEnabledColumn !== -1 ? ['true', 'yes'].includes(categoriesEnabled?.[dataIndex]?.toString().trim().toLowerCase() ?? '') : true,
// eslint-disable-next-line @typescript-eslint/naming-convention
'GL Code': categoriesGLCodeColumn !== -1 ? (categoriesGLCode?.[containsHeader ? index + 1 : index] ?? '') : existingGLCodeOrDefault,
'GL Code': categoriesGLCodeColumn !== -1 ? (categoriesGLCode?.[dataIndex] ?? '') : existingGLCodeOrDefault,
...(parsedMaxAmountNoReceipt !== undefined && {maxAmountNoReceipt: parsedMaxAmountNoReceipt}),
...(parsedMaxAmountNoItemizedReceipt !== undefined && {maxAmountNoItemizedReceipt: parsedMaxAmountNoItemizedReceipt}),
};
});

Expand Down
Loading