Skip to content
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6042,6 +6042,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU
comment: 'Beschreibung',
category: 'Kategorie',
tag: 'Tag',
uniqueID: 'Eindeutige ID',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Bitte weisen Sie jeder der folgenden Eigenschaften eine Spalte zu: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6154,6 +6154,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ
category: 'Κατηγορία',
tag: 'Ετικέτα',
cardName: 'Όνομα κάρτας',
uniqueID: 'Μοναδικό αναγνωριστικό',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Παρακαλούμε αντιστοιχίστε μια στήλη σε καθεμία από τις ιδιότητες: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6127,6 +6127,7 @@ const translations = {
comment: 'Description',
category: 'Category',
tag: 'Tag',
uniqueID: 'Unique ID',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Please assign a column to each of the attributes: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5916,6 +5916,7 @@ ${amount} para ${merchant} - ${date}`,
comment: 'Descripción',
category: 'Categoría',
tag: 'Etiqueta',
uniqueID: 'ID única',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Por favor, asigna una columna a cada uno de los atributos: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6050,6 +6050,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST.
comment: 'Description',
category: 'Catégorie',
tag: 'Étiquette',
uniqueID: 'ID unique',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Veuillez attribuer une colonne à chacun des attributs : ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6007,6 +6007,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST.
comment: 'Descrizione',
category: 'Categoria',
tag: 'Tag',
uniqueID: 'ID univoco',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Assegna una colonna a ciascuno degli attributi: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5935,6 +5935,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO
comment: '説明',
category: 'カテゴリ',
tag: 'タグ',
uniqueID: '一意の ID',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `各属性に列を割り当ててください:${missingColumns}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5998,6 +5998,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_
comment: 'Omschrijving',
category: 'Categorie',
tag: 'Label',
uniqueID: 'Unieke ID',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Wijs een kolom toe aan elk van de attributen: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6023,6 +6023,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy
comment: 'Opis',
category: 'Kategoria',
tag: 'Tag',
uniqueID: 'Unikalny identyfikator',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Przypisz kolumnę do każdego z atrybutów: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5998,6 +5998,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS
comment: 'Descrição',
category: 'Categoria',
tag: 'Etiqueta',
uniqueID: 'ID exclusivo',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `Atribua uma coluna a cada um dos atributos: ${missingColumns}.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5795,6 +5795,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
comment: '描述',
category: '类别',
tag: '标签',
uniqueID: '唯一 ID',
},
csvErrors: {
requiredColumns: (missingColumns: string) => `请为以下每个属性分配一列:${missingColumns}`,
Expand Down
16 changes: 13 additions & 3 deletions src/libs/actions/CompanyCards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,14 @@ function buildOptimisticCompanyCardCSVTransactions(
const normalizedColumnMappings = [...columnMappings];
const csvDataWithGeneratedIDs = csvData.map((row) => [...row]);

normalizedColumnMappings.push(CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID);
const externalIDColumnIndex = normalizedColumnMappings.length - 1;
// The backend dedupes rows by their `externalID`, so a mapped Unique ID column makes re-uploading
// the same file idempotent. Without one, every row gets a fresh generated ID and always imports.
const mappedExternalIDColumnIndex = getColumnIndex(normalizedColumnMappings, CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID);
const isExternalIDColumnMapped = mappedExternalIDColumnIndex >= 0;
if (!isExternalIDColumnMapped) {
normalizedColumnMappings.push(CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID);
}
const externalIDColumnIndex = isExternalIDColumnMapped ? mappedExternalIDColumnIndex : normalizedColumnMappings.length - 1;

const cardNumberColumnIndex = getColumnIndex(normalizedColumnMappings, CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER);
const postedDateColumnIndex = getColumnIndex(normalizedColumnMappings, CONST.CSV_IMPORT_COLUMNS.POSTED_DATE);
Expand All @@ -125,7 +131,11 @@ function buildOptimisticCompanyCardCSVTransactions(
const transactions: OptimisticCompanyCardCSVTransaction[] = [];
for (const row of csvDataWithGeneratedIDs) {
const transactionID = rand64();
row[externalIDColumnIndex] = transactionID;

// Fills the synthetic column, and any row whose mapped Unique ID cell is blank.
Comment thread
tgolen marked this conversation as resolved.
if (!row.at(externalIDColumnIndex)?.trim()) {
row[externalIDColumnIndex] = transactionID;
}
Comment on lines +135 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report the number of transactions actually imported

When mapped IDs already exist during an exact or overlapping re-import, preserving them here lets the backend skip those rows, but every valid input row is still added to transactions and therefore included in transactionsCount. The success modal consequently says all input transactions “have been added” even when none, or only a subset, were inserted; use the backend's inserted count or avoid presenting the input-row count as the number added.

Useful? React with 👍 / 👎.

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.

Agreed that this is now inaccurate: before this PR dedup could never match, so every input row really was inserted and the count was right. With stable IDs a fully-deduped re-import will still report all rows as added.

Not fixing it here, for two reasons:

  1. The true inserted count only exists backend-side. The file is parsed asynchronously by the scraper well after ImportCSVCompanyCards returns, and this modal is built client-side from successData, so there is nothing accurate to substitute yet — it needs a backend count plumbed through.
  2. Rewording the modal so it does not claim a count (e.g. "Import started") is user-facing copy, which needs approval rather than a drive-by change in this PR.

Raised with the PR author to decide between softening the copy here or opening a follow-up for the backend count.

🤖 Posted by Claude (an AI agent) on behalf of the user.


const cardName = row.at(cardNumberColumnIndex)?.trim();
const rawPostedDate = row.at(postedDateColumnIndex)?.trim();
Expand Down
8 changes: 8 additions & 0 deletions src/libs/actions/ImportSpreadsheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ function applyCompanyCardSavedColumnMappings(spreadsheetData: string[][], savedC
if (role === CONST.CSV_IMPORT_COLUMNS.IGNORE || !validRoles.has(role)) {
continue;
}

// A saved externalID index is ambiguous: the company cards import appends a synthetic externalID column
Comment thread
tgolen marked this conversation as resolved.
// when the user maps no Unique ID, and that index is indistinguishable from a Unique ID the user mapped to
// the last column. Restoring it could point Unique ID at an unrelated column of the new file, whose repeated
// values would make the backend dedupe valid transactions, so leave it for the user to map.
if (role === CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID) {
continue;
}
const index = Number(indexValue);
if (!Number.isInteger(index) || index < 0 || index >= numColumns) {
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function getCompanyCardImportColumnRoles(translate: LocaleContextProps['translat
{text: translate('workspace.companyCards.addNewCard.csvColumns.comment'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT},
{text: translate('workspace.companyCards.addNewCard.csvColumns.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY},
{text: translate('workspace.companyCards.addNewCard.csvColumns.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG},
{text: translate('workspace.companyCards.addNewCard.csvColumns.uniqueID'), value: CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID},
];
}

Expand Down
91 changes: 91 additions & 0 deletions tests/actions/CompanyCardsImportTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {importCSVCompanyCards} from '@libs/actions/CompanyCards';
import type {ImportCSVCompanyCardsParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';

import CONST from '@src/CONST';
Expand All @@ -15,6 +16,8 @@ const POLICY_ID = 'POLICY_1';
const DOMAIN_ACCOUNT_ID = 777;
const CSV_FEED = CONST.COMPANY_CARD.FEED_BANK_NAME.CSV;

const isImportCSVCompanyCardsParams = (value: unknown): value is ImportCSVCompanyCardsParams => typeof value === 'object' && value !== null && 'settings' in value && 'csvData' in value;

OnyxUpdateManager();
describe('actions/CompanyCards importCSVCompanyCards', () => {
beforeAll(() => {
Expand Down Expand Up @@ -107,4 +110,92 @@ describe('actions/CompanyCards importCSVCompanyCards', () => {

apiWriteSpy.mockRestore();
});

describe('externalID', () => {
const sentImports: ImportCSVCompanyCardsParams[] = [];

beforeEach(() => {
sentImports.length = 0;
jest.spyOn(require('@libs/API'), 'write').mockImplementation((...args: unknown[]) => {
const parameters = args.at(1);
if (isImportCSVCompanyCardsParams(parameters)) {
sentImports.push(parameters);
}
return Promise.resolve();
});
});

afterEach(() => {
jest.restoreAllMocks();
});

it('appends a generated externalID column when no unique ID column is mapped', () => {
// Given an import that maps no unique ID column
// When the file is imported
importCSVCompanyCards({
policyID: POLICY_ID,
domainAccountID: DOMAIN_ACCOUNT_ID,
layoutName: 'My Layout',
layoutType: CSV_FEED,
columnMappings: ['cardNumber', 'postedDate', 'merchant', 'amount', 'currency'],
csvData: [
['Card', 'Date', 'Merchant', 'Amount', 'Currency'],
['1234', '01/15/2024', 'Coffee Shop', '-5.00', 'USD'],
],
workspaceCardFeeds: undefined,
});

// Then an externalID column is appended to the mappings and every row is filled with a generated ID
expect(sentImports.at(0)?.settings ?? '').toContain('"columnMappings":["cardNumber","postedDate","merchant","amount","currency","externalID"]');
expect(sentImports.at(0)?.csvData ?? '').toMatch(/,"USD","\d+"]/);
});

it('sends the mapped unique ID column values as externalID so re-imports can be deduped', () => {
// Given an import that maps a unique ID column
// When the file is imported
importCSVCompanyCards({
policyID: POLICY_ID,
domainAccountID: DOMAIN_ACCOUNT_ID,
layoutName: 'My Layout',
layoutType: CSV_FEED,
columnMappings: ['externalID', 'cardNumber', 'postedDate', 'merchant', 'amount', 'currency'],
csvData: [
['Unique ID', 'Card', 'Date', 'Merchant', 'Amount', 'Currency'],
['txn-abc-1', '1234', '01/15/2024', 'Coffee Shop', '-5.00', 'USD'],
['txn-abc-2', '1234', '01/16/2024', 'Book Store', '-10.00', 'USD'],
],
workspaceCardFeeds: undefined,
});

// Then no externalID column is appended and the mapped values are sent as-is, so the backend can dedupe a re-upload
expect(sentImports.at(0)?.settings ?? '').toContain('"columnMappings":["externalID","cardNumber","postedDate","merchant","amount","currency"]');
expect(sentImports.at(0)?.csvData ?? '').toBe(
JSON.stringify([
['Unique ID', 'Card', 'Date', 'Merchant', 'Amount', 'Currency'],
['txn-abc-1', '1234', '2024-01-15', 'Coffee Shop', '-5.00', 'USD'],
['txn-abc-2', '1234', '2024-01-16', 'Book Store', '-10.00', 'USD'],
]),
);
});

it('falls back to a generated externalID for rows with an empty unique ID value', () => {
// Given an import that maps a unique ID column, with a row missing its value
// When the file is imported
importCSVCompanyCards({
policyID: POLICY_ID,
domainAccountID: DOMAIN_ACCOUNT_ID,
layoutName: 'My Layout',
layoutType: CSV_FEED,
columnMappings: ['externalID', 'cardNumber', 'postedDate', 'merchant', 'amount', 'currency'],
csvData: [
['Unique ID', 'Card', 'Date', 'Merchant', 'Amount', 'Currency'],
[' ', '1234', '01/15/2024', 'Coffee Shop', '-5.00', 'USD'],
],
workspaceCardFeeds: undefined,
});

// Then that row is sent with a generated ID instead of the blank value
expect(sentImports.at(0)?.csvData ?? '').toMatch(/\["\d+","1234",/);
});
});
});
2 changes: 2 additions & 0 deletions tests/unit/CompanyCardsCSVImportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ describe('Company card CSV import column roles', () => {
CONST.CSV_IMPORT_COLUMNS.COMMENT,
CONST.CSV_IMPORT_COLUMNS.CATEGORY,
CONST.CSV_IMPORT_COLUMNS.TAG,
// The Unique ID column the backend dedupes re-uploads on:
CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID,
]),
);
});
Expand Down
58 changes: 57 additions & 1 deletion tests/unit/ImportSpreadsheetTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {setSpreadsheetData} from '@libs/actions/ImportSpreadsheet';
import {applyCompanyCardSavedColumnMappings, setSpreadsheetData} from '@libs/actions/ImportSpreadsheet';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -25,6 +25,10 @@ function isImportedSpreadsheet(value: unknown): value is ImportedSpreadsheet {
return typeof value === 'object' && !!value && 'data' in value;
}

function hasColumnMappings(value: unknown): value is {columns: Record<number, string>} {
return typeof value === 'object' && !!value && 'columns' in value;
}

/** Replays the column-major to row-major transpose the company cards import runs when Import is pressed. */
function transposeBackToRows(columns: string[][]): string[][] {
const rows: string[][] = [];
Expand Down Expand Up @@ -127,4 +131,56 @@ describe('ImportSpreadsheet', () => {
expect(storedSpreadsheet).toBeUndefined();
});
});

describe('applyCompanyCardSavedColumnMappings', () => {
let mergedColumns: Record<number, string> | undefined;

beforeEach(() => {
mergedColumns = undefined;
jest.spyOn(Onyx, 'merge').mockImplementation((key, value) => {
if (key === ONYXKEYS.IMPORTED_SPREADSHEET && hasColumnMappings(value)) {
mergedColumns = value.columns;
}
return Promise.resolve();
});
});

afterEach(() => {
jest.restoreAllMocks();
});

const spreadsheetColumns = [
['Card', '1234'],
['Date', '2024-01-15'],
['Merchant', 'Coffee Shop'],
['Amount', '-5.00'],
['Currency', 'USD'],
['Notes', 'Team offsite'],
];
const availableRoles = [
CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER,
CONST.CSV_IMPORT_COLUMNS.POSTED_DATE,
CONST.CSV_IMPORT_COLUMNS.MERCHANT,
CONST.CSV_IMPORT_COLUMNS.AMOUNT,
CONST.CSV_IMPORT_COLUMNS.CURRENCY,
CONST.CSV_IMPORT_COLUMNS.EXTERNAL_ID,
];

it('restores the saved mappings for regular roles', () => {
applyCompanyCardSavedColumnMappings(spreadsheetColumns, {cardNumber: '0', postedDate: '1', merchant: '2'}, availableRoles);

// Asserted as entries because integer-like object keys trip the naming-convention lint rule.
expect(Object.entries(mergedColumns ?? {})).toEqual([
['0', CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER],
['1', CONST.CSV_IMPORT_COLUMNS.POSTED_DATE],
['2', CONST.CSV_IMPORT_COLUMNS.MERCHANT],
]);
});

it('never restores a saved externalID mapping, which can point at the synthetic column rather than a real one', () => {
applyCompanyCardSavedColumnMappings(spreadsheetColumns, {cardNumber: '0', externalID: '5'}, availableRoles);

expect(Object.entries(mergedColumns ?? {})).toEqual([['0', CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER]]);
});
});
});
Loading