Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d0b14ab
Add merchant rules spreadsheet import flow
NicolasBonet Jul 1, 2026
9560b29
Improve merchant rules spreadsheet import mapping and preview
NicolasBonet Jul 2, 2026
5b329a0
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 6, 2026
77ffc84
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 8, 2026
11424b5
Add import option for merchant rules via spreadsheet in multiple lang…
NicolasBonet Jul 8, 2026
5c260ac
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 8, 2026
227bfda
Remove 'importRulesViaSpreadsheet' translation key from multiple lang…
NicolasBonet Jul 8, 2026
960c2c2
Extract shared import merchant rules dropdown option and gate import …
NicolasBonet Jul 8, 2026
fe2524f
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 10, 2026
ae75020
Address review comments: drop useCallback, return null, scope NAME→TA…
NicolasBonet Jul 10, 2026
71b8305
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 11, 2026
e522097
Merge remote-tracking branch 'origin/main' into nicolas-import-mercha…
NicolasBonet Jul 13, 2026
e24d6d8
Address review feedback: RHP mapping, dedup on import, scoped descrip…
NicolasBonet Jul 13, 2026
74dc807
Use custom header text on merchant rules import column-mapping screen
MelvinBot Jul 14, 2026
5e22904
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 14, 2026
04f6938
Merge branch 'main' into nicolas-import-merchant-rules-spreadsheet
NicolasBonet Jul 14, 2026
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
6 changes: 6 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7836,6 +7836,11 @@ const CONST = {
EXTERNAL_ID: 'externalID',
MAX_AMOUNT_NO_RECEIPT: 'maxAmountNoReceipt',
MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'maxAmountNoItemizedReceipt',
MERCHANT_IS: 'merchantIs',
MERCHANT_CONTAINS: 'merchantContains',
UPDATED_MERCHANT: 'updatedMerchant',
REIMBURSABLE: 'reimbursable',
BILLABLE: 'billable',
},

IMPORT_SPREADSHEET: {
Expand Down Expand Up @@ -8764,6 +8769,7 @@ const CONST = {
MERCHANT_TYPE_RULE_SAVE: 'WorkspaceRules-MerchantTypeRuleSave',
MERCHANT_TYPE_RULE_CATEGORY: 'WorkspaceRules-MerchantTypeRuleCategory',
ADD_MERCHANT_RULE: 'WorkspaceRules-AddMerchantRule',
IMPORT_MERCHANT_RULES: 'WorkspaceRules-ImportMerchantRules',
MERCHANT_RULE_SECTION_ITEM: 'WorkspaceRules-MerchantRuleSectionItem',
MERCHANT_RULE_SAVE: 'WorkspaceRules-MerchantRuleSave',
MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches',
Expand Down
8 changes: 8 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3282,6 +3282,14 @@ const ROUTES = {
route: 'workspaces/:policyID/rules/merchant-rules/new',
getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const,
},
RULES_MERCHANT_IMPORT: {
route: 'workspaces/:policyID/rules/merchant-rules/import',
getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/import` as const,
},
RULES_MERCHANT_IMPORTED: {
route: 'workspaces/:policyID/rules/merchant-rules/imported',
getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/imported` as const,
},
RULES_SPEND_NEW: {
route: 'workspaces/:policyID/rules/spend-rules/new',
getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new` as const,
Expand Down
2 changes: 2 additions & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,8 @@ const SCREENS = {
RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default',
RULES_NEW: 'Rules_New',
RULES_MERCHANT_NEW: 'Rules_Merchant_New',
RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import',
RULES_MERCHANT_IMPORTED: 'Rules_Merchant_Imported',
RULES_SPEND_NEW: 'Rules_Spend_New',
RULES_REQUIRE_FIELDS_RULE_NEW: 'Rules_Require_Fields_Rule_New',
RULES_REQUIRE_FIELDS_RULE_EDIT: 'Rules_Require_Fields_Rule_Edit',
Expand Down
58 changes: 57 additions & 1 deletion src/components/ImportColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string {

case 'category':
case 'categories':
case 'updatedcategory':
attribute = CONST.CSV_IMPORT_COLUMNS.CATEGORY;
break;

case 'updateddescription':
attribute = CONST.CSV_IMPORT_COLUMNS.COMMENT;
break;

case 'glcode':
case 'gl':
attribute = CONST.CSV_IMPORT_COLUMNS.GL_CODE;
Expand Down Expand Up @@ -137,6 +142,39 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string {
attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT;
break;

case 'merchantis':
attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS;
break;

case 'merchantcontains':
attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS;
break;

case 'updatedmerchant':
case 'newmerchant':
attribute = CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT;
break;

case 'updatedtag':
case 'newtag':
attribute = CONST.CSV_IMPORT_COLUMNS.TAG;
break;

case 'reimbursable':
case 'reimburseable':
attribute = CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE;
break;

case 'preferredmerchantname':
case 'preferredmerchant(vendor)name':
case 'preferredvendorname':
attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS;
break;

case 'billable':
attribute = CONST.CSV_IMPORT_COLUMNS.BILLABLE;
break;

case 'rateid':
attribute = CONST.CSV_IMPORT_COLUMNS.RATE_ID;
break;
Expand All @@ -163,6 +201,13 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string {
break;
}

// A bare "Description" header is ambiguous across import flows (e.g. bank CSVs use it for the
// transaction descriptor), so it only auto-maps to the updated-description action in the merchant
// rules import, which is the only flow offering the MERCHANT_IS column role.
if (!attribute && formattedHeader === 'description' && columnRoles?.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS)) {
attribute = CONST.CSV_IMPORT_COLUMNS.COMMENT;
}

// If the detected attribute isn't available in the current context but a semantic equivalent is,
// remap to it. This handles e.g. "Date" headers in company card imports where DATE is not a
// valid column role but POSTED_DATE is.
Expand All @@ -172,6 +217,14 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string {
if (attribute === CONST.CSV_IMPORT_COLUMNS.DATE && columnRoles.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.POSTED_DATE)) {
return CONST.CSV_IMPORT_COLUMNS.POSTED_DATE;
}
if (attribute === CONST.CSV_IMPORT_COLUMNS.MERCHANT && columnRoles.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT)) {
return CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT;
}
// Only tag-like headers remap from NAME to TAG, so headers like "Name" or "Customer" stay
// unmapped in contexts without a NAME role instead of silently becoming a tag column.
if (attribute === CONST.CSV_IMPORT_COLUMNS.NAME && ['tag', 'tags'].includes(formattedHeader) && columnRoles.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.TAG)) {
return CONST.CSV_IMPORT_COLUMNS.TAG;
}
return '';
}
}
Expand Down Expand Up @@ -225,7 +278,10 @@ function ImportColumn({column, columnName, columnRoles, columnIndex, shouldShowD
isSelected: spreadsheet?.columns?.[columnIndex] === item.value,
}));

const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', ');
const columnValuesString = column
.slice(containsHeader ? 1 : 0)
.filter((value) => String(value).trim() !== '')
.join(', ');

const currentColumnValue = spreadsheet?.columns?.[columnIndex];
// Treat 'ignore' as unmapped so auto-detection can still run
Expand Down
16 changes: 16 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,14 @@ const translations: TranslationDeepObject<typeof en> = {
importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} Tags wurden hinzugefügt.` : '1 Tag wurde hinzugefügt.'),
importMultiLevelTagsSuccessfulDescription: 'Mehrstufige Tags wurden hinzugefügt.',
importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} Tagessätze wurden hinzugefügt.` : '1 Tagegeldsatz wurde hinzugefügt.'),
importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => {
if (rules === 0) {
return 'Es wurden keine Händlerregeln hinzugefügt, da sie alle bereits existieren.';
}
return rules > 1 ? `${rules} Händlerregeln wurden hinzugefügt.` : '1 Händlerregel wurde hinzugefügt.';
},
importMerchantRulesRequiredColumns:
'Ups! Du musst mindestens eine Spalte („Händler ist“ oder „Händler enthält“) zuordnen sowie mindestens ein zu aktualisierendes Feld. Bitte überprüfe es und versuche es erneut.',
importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
transactions > 1 ? `${transactions} Buchungen wurden importiert.` : '1 Transaktion wurde importiert.',
importFailedTitle: 'Import fehlgeschlagen',
Expand Down Expand Up @@ -7466,6 +7474,14 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und
addRule: 'Händlerregel hinzufügen',
addRuleTitle: 'Regel hinzufügen',
editRuleTitle: 'Regel bearbeiten',
importRulesTitle: 'Händlerregeln importieren',
importRulesSupportingText: 'Ordne jede Spalte in deiner Tabelle einem Feld der Händlerregel zu. Wenn alles gut aussieht, klicke unten, um deine Regeln zu importieren.',
importColumnMerchantIs: 'Händler ist',
importColumnMerchantContains: 'Händler enthält',
importColumnUpdatedMerchant: 'Aktualisierter Händler',
importColumnUpdatedCategory: 'Aktualisierte Kategorie',
importColumnUpdatedTag: 'Aktualisiertes Tag',
importColumnUpdatedDescription: 'Aktualisierte Beschreibung',
expensesWith: 'Für Ausgaben mit:',
expensesExactlyMatching: 'Für Ausgaben mit genau folgender Übereinstimmung:',
applyUpdates: 'Diese Aktualisierungen anwenden:',
Expand Down
15 changes: 15 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,13 @@ const translations = {
importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} tags have been added.` : '1 tag has been added.'),
importMultiLevelTagsSuccessfulDescription: 'Multi-level tags have been added.',
importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} per diem rates have been added.` : '1 per diem rate has been added.'),
importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => {
if (rules === 0) {
return 'No merchant rules have been added, since they all already exist.';
}
return rules > 1 ? `${rules} merchant rules have been added.` : '1 merchant rule has been added.';
},
importMerchantRulesRequiredColumns: 'Oops! You must map at least one "Merchant is" or "Merchant contains" column, plus at least one field to update. Please review and try again.',
importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
transactions > 1 ? `${transactions} transactions have been imported.` : '1 transaction has been imported.',
importFailedTitle: 'Import failed',
Expand Down Expand Up @@ -7704,6 +7711,14 @@ const translations = {
findRule: 'Find merchant rule',
addRuleTitle: 'Add rule',
editRuleTitle: 'Edit rule',
importRulesTitle: 'Import merchant rules',
importRulesSupportingText: 'Map each column in your spreadsheet to a merchant rule field. Once everything looks good, click below to import your rules.',
importColumnMerchantIs: 'Merchant is',
importColumnMerchantContains: 'Merchant contains',
importColumnUpdatedMerchant: 'Updated merchant',
importColumnUpdatedCategory: 'Updated category',
importColumnUpdatedTag: 'Updated tag',
importColumnUpdatedDescription: 'Updated description',
expensesWith: 'For expenses with:',
expensesExactlyMatching: 'For expenses exactly matching:',
applyUpdates: 'Apply these updates:',
Expand Down
17 changes: 17 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,14 @@ const translations: TranslationDeepObject<typeof en> = {
importTagsSuccessfulDescription: ({tags}) => (tags > 1 ? `Se han agregado ${tags} etiquetas.` : 'Se ha agregado 1 etiqueta.'),
importMultiLevelTagsSuccessfulDescription: 'Etiquetas de nivel múltiple han sido agregadas.',
importPerDiemRatesSuccessfulDescription: ({rates}) => (rates > 1 ? `Se han añadido ${rates} tasas de per diem.` : 'Se ha añadido 1 tasa de per diem.'),
importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => {
if (rules === 0) {
return 'No se han añadido reglas de comerciante, ya que todas ya existen.';
}
return rules > 1 ? `Se han añadido ${rules} reglas de comerciante.` : 'Se ha añadido 1 regla de comerciante.';
},
importMerchantRulesRequiredColumns:
'¡Ups! Debes asignar al menos una columna "El comerciante es" o "El comerciante contiene", además de al menos un campo para actualizar. Por favor, revisa e inténtalo de nuevo.',
importTransactionsSuccessfulDescription: ({transactions}) => (transactions > 1 ? `Se han importado ${transactions} transacciones.` : 'Se ha importado 1 transacción.'),
importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
transactions > 1 ? `Se han importado ${transactions} transacciones.` : 'Se ha importado 1 transacción.',
Expand Down Expand Up @@ -7398,6 +7406,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
findRule: 'Buscar regla de comerciante',
addRuleTitle: 'Añadir regla',
editRuleTitle: 'Editar regla',
importRulesTitle: 'Importar reglas de comerciante',
importRulesSupportingText:
'Asigna cada columna de tu hoja de cálculo a un campo de la regla de comerciante. Cuando todo esté correcto, haz clic abajo para importar tus reglas.',
importColumnMerchantIs: 'El comerciante es',
importColumnMerchantContains: 'El comerciante contiene',
importColumnUpdatedMerchant: 'Comerciante actualizado',
importColumnUpdatedCategory: 'Categoría actualizada',
importColumnUpdatedTag: 'Etiqueta actualizada',
importColumnUpdatedDescription: 'Descripción actualizada',
expensesWith: 'Para gastos con:',
expensesExactlyMatching: 'Para gastos que coincidan exactamente con:',
applyUpdates: 'Aplicar estas actualizaciones:',
Expand Down
17 changes: 17 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,14 @@ const translations: TranslationDeepObject<typeof en> = {
importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} tags ont été ajoutés.` : '1 tag a été ajouté.'),
importMultiLevelTagsSuccessfulDescription: 'Des tags à plusieurs niveaux ont été ajoutés.',
importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} indemnités journalières ont été ajoutées.` : '1 taux de per diem a été ajouté.'),
importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => {
if (rules === 0) {
return "Aucune règle de marchand n'a été ajoutée, car elles existent toutes déjà.";
}
return rules > 1 ? `${rules} règles de marchand ont été ajoutées.` : '1 règle de marchand a été ajoutée.';
},
importMerchantRulesRequiredColumns:
'Oups ! Vous devez associer au moins une colonne « Le marchand est » ou « Le marchand contient », ainsi qu’au moins un champ à mettre à jour. Veuillez examiner et réessayer.',
importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
transactions > 1 ? `${transactions} transactions ont été importées.` : '1 transaction a été importée.',
importFailedTitle: 'Échec de l’importation',
Expand Down Expand Up @@ -7494,6 +7502,15 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip
addRule: 'Ajouter une règle de marchand',
addRuleTitle: 'Ajouter une règle',
editRuleTitle: 'Modifier la règle',
importRulesTitle: 'Importer des règles de marchand',
importRulesSupportingText:
'Faites correspondre chaque colonne de votre feuille de calcul à un champ de règle de marchand. Si tout semble correct, cliquez ci-dessous pour importer vos règles.',
importColumnMerchantIs: 'Le marchand est',
importColumnMerchantContains: 'Le marchand contient',
importColumnUpdatedMerchant: 'Marchand mis à jour',
importColumnUpdatedCategory: 'Catégorie mise à jour',
importColumnUpdatedTag: 'Tag mis à jour',
importColumnUpdatedDescription: 'Description mise à jour',
expensesWith: 'Pour les dépenses avec :',
expensesExactlyMatching: 'Pour les dépenses correspondant exactement :',
applyUpdates: 'Appliquer ces mises à jour :',
Expand Down
16 changes: 16 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,13 @@ const translations: TranslationDeepObject<typeof en> = {
importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `Sono stati aggiunti ${tags} tag.` : 'È stato aggiunto 1 tag.'),
importMultiLevelTagsSuccessfulDescription: 'Sono state aggiunte etichette multilivello.',
importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `Sono state aggiunte le diarie giornaliere ${rates}.` : 'È stata aggiunta 1 diaria.'),
importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => {
if (rules === 0) {
return 'Nessuna regola esercente è stata aggiunta, poiché esistono già tutte.';
}
return rules > 1 ? `Sono state aggiunte ${rules} regole esercente.` : 'È stata aggiunta 1 regola esercente.';
},
importMerchantRulesRequiredColumns: 'Ops! Devi mappare almeno una colonna “L’esercente è” o “L’esercente contiene”, oltre ad almeno un campo da aggiornare. Controlla e riprova.',
importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) =>
transactions > 1 ? `${transactions} transazioni sono state importate.` : '1 transazione è stata importata.',
importFailedTitle: 'Importazione non riuscita',
Expand Down Expand Up @@ -7448,6 +7455,15 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo
addRule: 'Aggiungi regola esercente',
addRuleTitle: 'Aggiungi regola',
editRuleTitle: 'Modifica regola',
importRulesTitle: 'Importa regole esercente',
importRulesSupportingText:
'Mappa ogni colonna del tuo foglio di calcolo a un campo della regola esercente. Se ti sembra tutto corretto, fai clic qui sotto per importare le tue regole.',
importColumnMerchantIs: 'L’esercente è',
importColumnMerchantContains: 'L’esercente contiene',
importColumnUpdatedMerchant: 'Esercente aggiornato',
importColumnUpdatedCategory: 'Categoria aggiornata',
importColumnUpdatedTag: 'Tag aggiornato',
importColumnUpdatedDescription: 'Descrizione aggiornata',
expensesWith: 'Per le spese con:',
expensesExactlyMatching: 'Per le spese che corrispondono esattamente a:',
applyUpdates: 'Applica questi aggiornamenti:',
Expand Down
Loading
Loading