From d101b5354de0e5a595d242f27d5253049999509b Mon Sep 17 00:00:00 2001 From: "Daniel Gale-Rosen (via MelvinBot)" Date: Mon, 27 Apr 2026 16:07:22 +0000 Subject: [PATCH 1/3] Add receipt requirement columns to category spreadsheet import Support "Require receipts over" and "Require itemized receipts over" columns in the category CSV import flow for Control policies. Values are parsed using the same logic as OD: default, required/always_required, not_required, or a numeric threshold. Normalization ensures consistent states (e.g. receipts not_required forces itemized receipts to not_required). Co-authored-by: Daniel Gale-Rosen --- src/CONST/index.ts | 2 + src/libs/actions/Policy/Category.ts | 9 ++- .../categories/ImportedCategoriesPage.tsx | 58 ++++++++++++++++++- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7c132553c645..1a86be1bc22f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8482,6 +8482,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: { diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 755d1c5d58ab..ee1ce02d9803 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -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++; } @@ -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}), })), ), }; diff --git a/src/pages/workspace/categories/ImportedCategoriesPage.tsx b/src/pages/workspace/categories/ImportedCategoriesPage.tsx index 92de5cf9ef0e..7eb107f54551 100644 --- a/src/pages/workspace/categories/ImportedCategoriesPage.tsx +++ b/src/pages/workspace/categories/ImportedCategoriesPage.tsx @@ -23,6 +23,32 @@ 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: "default" → null, "required"/"always_required" → 0, + * "not_required" → DISABLED_MAX_EXPENSE_VALUE, numeric string → number. + */ +function parseCsvReceiptValue(raw: string | undefined): number | null | undefined { + if (raw === undefined) { + return undefined; + } + const trimmed = raw.trim().toLowerCase(); + if (!trimmed || trimmed === 'default') { + return null; + } + if (trimmed === 'required' || trimmed === 'always_required') { + return 0; + } + if (trimmed === 'not_required') { + return CONST.DISABLED_MAX_EXPENSE_VALUE; + } + const num = Number(trimmed); + if (Number.isFinite(num) && num >= 0) { + return num; + } + return undefined; +} + type ImportedCategoriesPageProps = { route: RouteProp; }; @@ -52,7 +78,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; @@ -99,17 +129,39 @@ 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; + + // Apply normalization: if itemized receipts required but receipts not required, force both to required + let normalizedMaxAmountNoReceipt = parsedMaxAmountNoReceipt; + let normalizedMaxAmountNoItemizedReceipt = parsedMaxAmountNoItemizedReceipt; + if (normalizedMaxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE && normalizedMaxAmountNoItemizedReceipt !== undefined) { + normalizedMaxAmountNoItemizedReceipt = CONST.DISABLED_MAX_EXPENSE_VALUE; + } + if (normalizedMaxAmountNoItemizedReceipt === 0 && normalizedMaxAmountNoReceipt !== undefined) { + normalizedMaxAmountNoReceipt = 0; + } + return { name, - enabled: categoriesEnabledColumn !== -1 ? ['true', 'yes'].includes(categoriesEnabled?.[containsHeader ? index + 1 : index]?.toString().toLowerCase() ?? '') : true, + enabled: categoriesEnabledColumn !== -1 ? ['true', 'yes'].includes(categoriesEnabled?.[dataIndex]?.toString().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, + ...(normalizedMaxAmountNoReceipt !== undefined && {maxAmountNoReceipt: normalizedMaxAmountNoReceipt}), + ...(normalizedMaxAmountNoItemizedReceipt !== undefined && {maxAmountNoItemizedReceipt: normalizedMaxAmountNoItemizedReceipt}), }; }); From ed9ea6f6cc6aac1bbbbc734d8160c47402694826 Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Fri, 1 May 2026 10:10:22 +0000 Subject: [PATCH 2/3] Fix: replace Str.removeSpaces with replaceAll to handle all spaces in CSV headers Co-authored-by: suneox <11959869+suneox@users.noreply.github.com> Co-authored-by: Cong Pham --- src/components/ImportColumn.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index 100b9125f977..80aa440371c1 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -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'; @@ -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': From 00239953cf37389033fef4d30b03a04a1a95020c Mon Sep 17 00:00:00 2001 From: "Cong Pham (via MelvinBot)" Date: Sun, 3 May 2026 11:51:33 +0000 Subject: [PATCH 3/3] Remove client-side receipt normalization logic, let BE handle it The paired receipt rule normalization (forcing receipts/itemized receipts to match) is now handled by the backend. Also updated parseCsvReceiptValue to return undefined instead of null so parsed values can be submitted directly. Co-authored-by: Cong Pham --- .../categories/ImportedCategoriesPage.tsx | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/pages/workspace/categories/ImportedCategoriesPage.tsx b/src/pages/workspace/categories/ImportedCategoriesPage.tsx index ff2ede8b9b2a..3630b7b80328 100644 --- a/src/pages/workspace/categories/ImportedCategoriesPage.tsx +++ b/src/pages/workspace/categories/ImportedCategoriesPage.tsx @@ -25,16 +25,17 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; /** * Parses a CSV cell value for receipt requirement columns. - * Mirrors the OD import logic: "default" → null, "required"/"always_required" → 0, + * 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 | null | undefined { +function parseCsvReceiptValue(raw: string | undefined): number | undefined { if (raw === undefined) { return undefined; } const trimmed = raw.trim().toLowerCase(); if (!trimmed || trimmed === 'default') { - return null; + return undefined; } if (trimmed === 'required' || trimmed === 'always_required') { return 0; @@ -145,23 +146,13 @@ function ImportedCategoriesPage({route}: ImportedCategoriesPageProps) { const parsedMaxAmountNoItemizedReceipt = categoriesMaxAmountNoItemizedReceiptColumn !== -1 ? parseCsvReceiptValue(categoriesMaxAmountNoItemizedReceipt?.[dataIndex]?.toString()) : undefined; - // Apply normalization: if itemized receipts required but receipts not required, force both to required - let normalizedMaxAmountNoReceipt = parsedMaxAmountNoReceipt; - let normalizedMaxAmountNoItemizedReceipt = parsedMaxAmountNoItemizedReceipt; - if (normalizedMaxAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE && normalizedMaxAmountNoItemizedReceipt !== undefined) { - normalizedMaxAmountNoItemizedReceipt = CONST.DISABLED_MAX_EXPENSE_VALUE; - } - if (normalizedMaxAmountNoItemizedReceipt === 0 && normalizedMaxAmountNoReceipt !== undefined) { - normalizedMaxAmountNoReceipt = 0; - } - return { name, 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?.[dataIndex] ?? '') : existingGLCodeOrDefault, - ...(normalizedMaxAmountNoReceipt !== undefined && {maxAmountNoReceipt: normalizedMaxAmountNoReceipt}), - ...(normalizedMaxAmountNoItemizedReceipt !== undefined && {maxAmountNoItemizedReceipt: normalizedMaxAmountNoItemizedReceipt}), + ...(parsedMaxAmountNoReceipt !== undefined && {maxAmountNoReceipt: parsedMaxAmountNoReceipt}), + ...(parsedMaxAmountNoItemizedReceipt !== undefined && {maxAmountNoItemizedReceipt: parsedMaxAmountNoItemizedReceipt}), }; });