diff --git a/Mobile-Expensify b/Mobile-Expensify index 00ea0c661cfd..6d9ea25d509d 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 00ea0c661cfdddeb4d7b5d34d25298a0fa422bcf +Subproject commit 6d9ea25d509d12843bb9c49a7befb62309d5da5a diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2edd44ea9550..8e7f0c696566 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -800,6 +800,8 @@ const ONYXKEYS = { WORKSPACE_COMPANY_CARD_FEED_NAME_DRAFT: 'workspaceCompanyCardFeedNameDraft', EDIT_WORKSPACE_COMPANY_CARD_NAME_FORM: 'editCompanyCardName', EDIT_WORKSPACE_COMPANY_CARD_NAME_DRAFT_FORM: 'editCompanyCardNameDraft', + IMPORT_TRANSACTIONS_FORM: 'importTransactionsForm', + IMPORT_TRANSACTIONS_FORM_DRAFT: 'importTransactionsFormDraft', EDIT_PERSONAL_CARD_NAME_FORM: 'editPersonalCardName', EDIT_PERSONAL_CARD_NAME_FORM_DRAFT: 'editPersonalCardNameDraft', WORKSPACE_REPORT_FIELDS_FORM: 'workspaceReportFieldForm', @@ -1018,6 +1020,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; [ONYXKEYS.FORMS.WORKSPACE_COMPANY_CARD_FEED_NAME]: FormTypes.WorkspaceCompanyCardFeedName; [ONYXKEYS.FORMS.EDIT_WORKSPACE_COMPANY_CARD_NAME_FORM]: FormTypes.WorkspaceCompanyCardEditName; + [ONYXKEYS.FORMS.IMPORT_TRANSACTIONS_FORM]: FormTypes.ImportTransactionsForm; [ONYXKEYS.FORMS.EDIT_PERSONAL_CARD_NAME_FORM]: FormTypes.EditPersonalCardNameForm; [ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM]: FormTypes.WorkspaceReportFieldForm; [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM]: FormTypes.WorkspaceCategoryDescriptionHintForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 070e28a21958..ebb0766c6484 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -393,6 +393,27 @@ const ROUTES = { }, SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', + SETTINGS_WALLET_IMPORT_TRANSACTIONS: 'settings/wallet/import-transactions', + SETTINGS_WALLET_IMPORT_TRANSACTIONS_CARD_NAME: 'settings/wallet/import-transactions/card-name', + SETTINGS_WALLET_IMPORT_TRANSACTIONS_CURRENCY: 'settings/wallet/import-transactions/currency', + SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET: { + route: 'settings/wallet/import-transactions-spreadsheet/:cardID?', + getRoute: (cardID?: number) => { + if (cardID) { + return `settings/wallet/import-transactions-spreadsheet/${cardID}` as const; + } + return 'settings/wallet/import-transactions-spreadsheet' as const; + }, + }, + SETTINGS_WALLET_TRANSACTIONS_IMPORTED: { + route: 'settings/wallet/transactions-imported/:cardID?', + getRoute: (cardID?: number) => { + if (cardID) { + return `settings/wallet/transactions-imported/${cardID}` as const; + } + return 'settings/wallet/transactions-imported' as const; + }, + }, SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { route: 'settings/wallet/card/:cardID/report-card-lost-or-damaged', getRoute: (cardID: string) => `settings/wallet/card/${cardID}/report-card-lost-or-damaged` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2b3fd09c3008..1871a47bc9c1 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -184,6 +184,11 @@ const SCREENS = { CARD_MISSING_DETAILS_CONFIRM_MAGIC_CODE: 'Settings_Wallet_Card_MissingDetails_ConfirmMagicCode', TRANSFER_BALANCE: 'Settings_Wallet_Transfer_Balance', CHOOSE_TRANSFER_ACCOUNT: 'Settings_Wallet_Choose_Transfer_Account', + IMPORT_TRANSACTIONS: 'Settings_Wallet_Import_Transactions', + IMPORT_TRANSACTIONS_CARD_NAME: 'Settings_Wallet_Import_Transactions_Card_Name', + IMPORT_TRANSACTIONS_CURRENCY: 'Settings_Wallet_Import_Transactions_Currency', + IMPORT_TRANSACTIONS_SPREADSHEET: 'Settings_Wallet_Import_Transactions_Spreadsheet', + TRANSACTIONS_IMPORTED: 'Settings_Wallet_Transactions_Imported', ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', diff --git a/src/components/ImportSpreadsheet.tsx b/src/components/ImportSpreadsheet.tsx index 084e08ef47a4..b9206f72eb5a 100644 --- a/src/components/ImportSpreadsheet.tsx +++ b/src/components/ImportSpreadsheet.tsx @@ -4,6 +4,7 @@ import RNFetchBlob from 'react-native-blob-util'; import type {TupleToUnion} from 'type-fest'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {setSpreadsheetData} from '@libs/actions/ImportSpreadsheet'; @@ -13,6 +14,7 @@ import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Route as Routes} from '@src/ROUTES'; import type {FileObject} from '@src/types/utils/Attachment'; import Button from './Button'; @@ -38,6 +40,7 @@ type ImportSpreadsheetProps = { }; function ImportSpreadsheet({backTo, goTo, isImportingMultiLevelTags}: ImportSpreadsheetProps) { + const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET, {canBeMissing: true}); const icons = useMemoizedLazyExpensifyIcons(['SpreadsheetComputer']); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -104,20 +107,33 @@ function ImportSpreadsheet({backTo, goTo, isImportingMultiLevelTags}: ImportSpre .then((data) => { return data.text(); }) - .then((text) => XLSX.read(text, {type: 'string'})); + .then((text) => XLSX.read(text, {type: 'string', raw: true})); } return fetch(fileURI) .then((data) => { return data.arrayBuffer(); }) - .then((arrayBuffer) => XLSX.read(new Uint8Array(arrayBuffer), {type: 'buffer'})); + .then((arrayBuffer) => XLSX.read(new Uint8Array(arrayBuffer), {type: 'buffer', raw: true})); }; readWorkbook() .then((workbook) => { const worksheet = workbook.Sheets[workbook.SheetNames[0]]; - const data = XLSX.utils.sheet_to_json(worksheet, {header: 1, blankrows: false}) as string[][] | unknown[][]; - const formattedSpreadsheetData = data.map((row) => row.map((cell) => String(cell))); - setSpreadsheetData(formattedSpreadsheetData, fileURI, file.type, file.name, isImportingMultiLevelTags ?? false) + // Use raw: true to preserve original string values from CSV (especially dates) + const data = XLSX.utils.sheet_to_json(worksheet, {header: 1, blankrows: false, raw: true}) as string[][] | unknown[][]; + const formattedSpreadsheetData = data.map((row) => + row.map((cell) => { + if (cell == null) { + return ''; + } + // Handle primitives (string, number, boolean) directly + if (typeof cell === 'string' || typeof cell === 'number' || typeof cell === 'boolean') { + return String(cell); + } + // For objects (Date, arrays, etc.), serialize to JSON + return JSON.stringify(cell); + }), + ); + setSpreadsheetData(formattedSpreadsheetData, fileURI, file.type, file.name, isImportingMultiLevelTags ?? false, importedSpreadsheet?.importTransactionSettings) .then(() => { Navigation.navigate(goTo); }) diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 701e604c9dfd..515879d47305 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -430,14 +430,7 @@ function BasePopoverMenu({ if (!headerText || enteredSubMenuIndexes.length !== 0) { return; } - return ( - - {headerText} - - ); + return {headerText}; }; useKeyboardShortcut( diff --git a/src/languages/de.ts b/src/languages/de.ts index 8da341287e34..931d672eef17 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1057,6 +1057,8 @@ const translations: TranslationDeepObject = { other: (count: number) => `Bitte bestätige die folgenden Details für die ${count} neuen Workspace-Mitglieder, die im Rahmen dieses Uploads hinzugefügt werden. Bestehende Mitglieder erhalten keine Rollenaktualisierungen oder Einladung Nachrichten.`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions} Transaktionen wurden importiert.` : '1 Transaktion wurde importiert.', }, receipt: { upload: 'Beleg hochladen', @@ -4963,6 +4965,15 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU editStartDateDescription: 'Wählen Sie ein neues Startdatum für Transaktionen. Wir synchronisieren alle Transaktionen ab diesem Datum, ausgenommen diejenigen, die wir bereits importiert haben.', unassignCardFailedError: 'Aufhebung der Kartenzuweisung fehlgeschlagen.', + importTransactions: { + title: 'Transaktionen aus Datei importieren', + description: 'Bitte passen Sie die Einstellungen für Ihre Datei an, die beim Import angewendet werden.', + cardDisplayName: 'Name der Karte', + currency: 'Währung', + transactionsAreReimbursable: 'Transaktionen sind erstattungsfähig', + flipAmountSign: 'Vorzeichen des Betrags umkehren', + importButton: 'Transaktionen importieren', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'Kartenfeeds konnten nicht geladen werden', workspaceFeedsCouldNotBeLoadedMessage: diff --git a/src/languages/en.ts b/src/languages/en.ts index 62a48239b88b..e637bf914204 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1073,6 +1073,8 @@ 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.'), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions} transactions have been imported.` : '1 transaction has been imported.', importFailedTitle: 'Import failed', importFailedDescription: 'Please ensure all fields are filled out correctly and try again. If the problem persists, please reach out to Concierge.', importDescription: 'Choose which fields to map from your spreadsheet by clicking the dropdown next to each imported column below.', @@ -4921,6 +4923,15 @@ const translations = { assignCardFailedError: 'Card assignment failed.', unassignCardFailedError: 'Card unassignment failed.', cardAlreadyAssignedError: 'This card is already assigned to a user in another workspace.', + importTransactions: { + title: 'Import transactions from file', + description: 'Please adjust the settings for your file that will be applied on import.', + cardDisplayName: 'Card display name', + currency: 'Currency', + transactionsAreReimbursable: 'Transactions are reimbursable', + flipAmountSign: 'Flip amount sign', + importButton: 'Import transactions', + }, }, expensifyCard: { issueAndManageCards: 'Issue and manage your Expensify Cards', diff --git a/src/languages/es.ts b/src/languages/es.ts index 550115b5b902..a04778b2b85b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -820,6 +820,7 @@ const translations: TranslationDeepObject = { 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.'), + importTransactionsSuccessfulDescription: ({transactions}) => (transactions > 1 ? `Se han importado ${transactions} transacciones.` : 'Se ha importado 1 transacción.'), importSuccessfulTitle: 'Importar categorías', importDescription: 'Elige qué campos mapear desde tu hoja de cálculo haciendo clic en el menú desplegable junto a cada columna importada a continuación.', sizeNotMet: 'El archivo adjunto debe ser más grande que 0 bytes.', @@ -4672,6 +4673,15 @@ ${amount} para ${merchant} - ${date}`, assignCardFailedError: 'Error al asignar la tarjeta.', unassignCardFailedError: 'Error al desasignar la tarjeta.', cardAlreadyAssignedError: 'Esta tarjeta ya está asignada a un usuario en otro espacio de trabajo.', + importTransactions: { + title: 'Importar transacciones desde archivo', + description: 'Por favor, ajusta la configuración de archivo que se aplicará al importar.', + cardDisplayName: 'Nombre de la tarjeta', + currency: 'Moneda', + transactionsAreReimbursable: 'Las transacciones son reembolsables', + flipAmountSign: 'Invertir el signo del monto', + importButton: 'Importar transacciones', + }, }, expensifyCard: { issueAndManageCards: 'Emitir y gestionar Tarjetas Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 726c88e995a4..09c6b7a548ce 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1059,6 +1059,8 @@ const translations: TranslationDeepObject = { other: (count: number) => `Veuillez confirmer les détails ci-dessous pour les ${count} nouveaux membres d’espace de travail qui seront ajoutés dans le cadre de cet import. Les membres existants ne recevront aucune mise à jour de rôle ni message d’invitation.`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions} transactions ont été importées.` : '1 transaction a été importée.', }, receipt: { upload: 'Téléverser un reçu', @@ -4970,6 +4972,15 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. editStartDateDescription: 'Choisissez une nouvelle date de début des transactions. Nous synchroniserons toutes les transactions à partir de cette date, en excluant celles que nous avons déjà importées.', unassignCardFailedError: 'Échec de la désaffectation de la carte.', + importTransactions: { + title: 'Importer des transactions depuis un fichier', + description: 'Veuillez ajuster les paramètres de votre fichier qui seront appliqués lors de l’importation.', + cardDisplayName: 'Nom d’affichage de la carte', + currency: 'Devise', + transactionsAreReimbursable: 'Les transactions sont remboursables', + flipAmountSign: 'Inverser le signe du montant', + importButton: 'Importer des transactions', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'Impossible de charger les flux de carte', workspaceFeedsCouldNotBeLoadedMessage: diff --git a/src/languages/it.ts b/src/languages/it.ts index 33c0428bfdf4..b01a9a9bcef1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1055,6 +1055,8 @@ const translations: TranslationDeepObject = { other: (count: number) => `Conferma i dettagli qui sotto per i ${count} nuovi membri dello spazio di lavoro che verranno aggiunti come parte di questo caricamento. I membri esistenti non riceveranno aggiornamenti del ruolo né messaggi di invito.`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions} transazioni sono state importate.` : '1 transazione è stata importata.', }, receipt: { upload: 'Carica ricevuta', @@ -4948,6 +4950,15 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. cardAlreadyAssignedError: 'This card is already assigned to a user in another workspace.', editStartDateDescription: 'Scegli una nuova data di inizio per le transazioni. Sincronizzeremo tutte le transazioni da quella data in poi, escludendo quelle già importate.', unassignCardFailedError: 'Rimozione della carta non riuscita.', + importTransactions: { + title: 'Importa transazioni da file', + description: 'Regola le impostazioni per il tuo file che verranno applicate all’importazione.', + cardDisplayName: 'Nome visualizzato carta', + currency: 'Valuta', + transactionsAreReimbursable: 'Le transazioni sono rimborsabili', + flipAmountSign: 'Inverti segno importo', + importButton: 'Importa transazioni', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'Impossibile caricare i feed delle carte', workspaceFeedsCouldNotBeLoadedMessage: diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 761da2729ec5..bd9cd07e34b8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1053,6 +1053,8 @@ const translations: TranslationDeepObject = { other: (count: number) => `このアップロードで追加される新しいワークスペースメンバー${count}人分の詳細を、以下で確認してください。既存のメンバーには、ロールの更新や招待メッセージは送信されません。`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions}件の取引がインポートされました。` : '1件の取引がインポートされました。', }, receipt: { upload: '領収書をアップロード', @@ -4919,6 +4921,15 @@ _より詳しい手順については、[ヘルプサイトをご覧ください cardAlreadyAssignedError: 'This card is already assigned to a user in another workspace.', editStartDateDescription: '新しい取引の開始日を選択してください。その日以降のすべての取引を、すでに取り込んだものを除いて同期します。', unassignCardFailedError: 'カードの割り当て解除に失敗しました。', + importTransactions: { + title: 'ファイルから取引をインポート', + description: 'インポート時に適用されるファイルの設定を調整してください。', + cardDisplayName: 'カード表示名', + currency: '通貨', + transactionsAreReimbursable: '取扱明細は精算対象です', + flipAmountSign: '金額の符号を反転', + importButton: '取引をインポート', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'カードフィードを読み込めませんでした', workspaceFeedsCouldNotBeLoadedMessage: 'ワークスペースのカードフィードを読み込む際にエラーが発生しました。もう一度お試しいただくか、管理者に連絡してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f4f2720e3872..f61d9a624a28 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1055,6 +1055,8 @@ const translations: TranslationDeepObject = { other: (count: number) => `Bevestig hieronder de details voor de ${count} nieuwe werkruimteleden die als onderdeel van deze upload zullen worden toegevoegd. Bestaande leden ontvangen geen rolupdates of uitnodigingsberichten.`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions} transacties zijn geïmporteerd.` : '1 transactie is geïmporteerd.', }, receipt: { upload: 'Bon uploaden', @@ -4945,6 +4947,15 @@ _Voor gedetailleerdere instructies, [bezoek onze helpsite](${CONST.NETSUITE_IMPO editStartDateDescription: 'Kies een nieuwe startdatum voor transacties. We synchroniseren alle transacties vanaf die datum, met uitzondering van de transacties die we al hebben geïmporteerd.', unassignCardFailedError: 'Kaartontkoppeling mislukt.', + importTransactions: { + title: 'Transacties importeren uit bestand', + description: 'Pas de instellingen voor je bestand aan die bij het importeren worden toegepast.', + cardDisplayName: 'Kaartweergavenaam', + currency: 'Valuta', + transactionsAreReimbursable: 'Transacties zijn terugbetaalbaar', + flipAmountSign: 'Teken van bedrag omdraaien', + importButton: 'Transacties importeren', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'Kan kaartfeeds niet laden', workspaceFeedsCouldNotBeLoadedMessage: diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7c790e3888ce..fd02dd603cd0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1056,6 +1056,7 @@ const translations: TranslationDeepObject = { other: (count: number) => `Potwierdź poniższe szczegóły dla ${count} nowych członków przestrzeni roboczej, którzy zostaną dodani w ramach tego przesyłania. Istniejący członkowie nie otrzymają żadnych aktualizacji ról ani wiadomości z zaproszeniem.`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `Zaimportowano ${transactions} transakcji.` : 'Zaimportowano 1 transakcję.'), }, receipt: { upload: 'Prześlij paragon', @@ -4934,6 +4935,15 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy cardAlreadyAssignedError: 'This card is already assigned to a user in another workspace.', editStartDateDescription: 'Wybierz nową datę początkową transakcji. Zsynchronizujemy wszystkie transakcje od tej daty, z wyłączeniem tych, które już zostały zaimportowane.', unassignCardFailedError: 'Nie udało się odłączyć karty.', + importTransactions: { + title: 'Importuj transakcje z pliku', + description: 'Dostosuj ustawienia dla swojego pliku, które zostaną zastosowane podczas importu.', + cardDisplayName: 'Wyświetlana nazwa karty', + currency: 'Waluta', + transactionsAreReimbursable: 'Transakcje podlegają zwrotowi', + flipAmountSign: 'Odwróć znak kwoty', + importButton: 'Importuj transakcje', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'Nie można było wczytać kanałów kart', workspaceFeedsCouldNotBeLoadedMessage: 'Wystąpił błąd podczas ładowania kanałów kart w przestrzeni roboczej. Spróbuj ponownie lub skontaktuj się z administratorem.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 84ec54156619..57aacb48ca59 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1054,6 +1054,8 @@ const translations: TranslationDeepObject = { other: (count: number) => `Confirme os detalhes abaixo para os ${count} novos membros do workspace que serão adicionados como parte deste upload. Membros existentes não receberão nenhuma atualização de função nem mensagens de convite.`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => + transactions > 1 ? `${transactions} transações foram importadas.` : '1 transação foi importada.', }, receipt: { upload: 'Enviar recibo', @@ -4933,6 +4935,15 @@ _Para instruções mais detalhadas, [visite nosso site de ajuda](${CONST.NETSUIT cardAlreadyAssignedError: 'This card is already assigned to a user in another workspace.', editStartDateDescription: 'Escolha uma nova data de início das transações. Vamos sincronizar todas as transações a partir dessa data, excluindo aquelas que já importamos.', unassignCardFailedError: 'Falha ao desatribuir o cartão.', + importTransactions: { + title: 'Importar transações do arquivo', + description: 'Ajuste as configurações do seu arquivo que serão aplicadas na importação.', + cardDisplayName: 'Nome exibido no cartão', + currency: 'Moeda', + transactionsAreReimbursable: 'As transações são reembolsáveis', + flipAmountSign: 'Inverter o sinal do valor', + importButton: 'Importar transações', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: 'Não foi possível carregar os feeds do cartão', workspaceFeedsCouldNotBeLoadedMessage: 'Ocorreu um erro ao carregar os feeds de cartões do workspace. Tente novamente ou entre em contato com o administrador.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7630d11c09fb..5b6ba2ed8f7b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1039,6 +1039,7 @@ const translations: TranslationDeepObject = { one: `请确认以下将通过本次上传添加的新工作区成员的详细信息。现有成员不会收到任何角色更新或邀请消息。`, other: (count: number) => `请确认以下有关将在此次上传中添加的 ${count} 位新的工作区成员的详细信息。现有成员不会收到任何角色更新或邀请消息。`, }), + importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `已导入 ${transactions} 笔交易。` : '已导入 1 笔交易。'), }, receipt: { upload: '上传收据', @@ -4840,6 +4841,15 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM cardAlreadyAssignedError: 'This card is already assigned to a user in another workspace.', editStartDateDescription: '选择一个新的交易起始日期。我们将从该日期起同步所有交易,但不包括已经导入的交易。', unassignCardFailedError: '卡片取消分配失败。', + importTransactions: { + title: '从文件导入交易', + description: '请调整文件的设置,这些设置将在导入时应用。', + cardDisplayName: '卡片显示名称', + currency: '货币', + transactionsAreReimbursable: '交易可报销', + flipAmountSign: '翻转金额符号', + importButton: '导入交易', + }, error: { workspaceFeedsCouldNotBeLoadedTitle: '无法加载卡片信息流', workspaceFeedsCouldNotBeLoadedMessage: '加载工作区卡片动态时出错。请重试或联系您的管理员。', diff --git a/src/libs/API/parameters/AddCSVTransactionsParams.ts b/src/libs/API/parameters/AddCSVTransactionsParams.ts new file mode 100644 index 000000000000..62947a824243 --- /dev/null +++ b/src/libs/API/parameters/AddCSVTransactionsParams.ts @@ -0,0 +1,18 @@ +type ImportCSVTransactionsParams = { + /** JSON string of the transaction list */ + transactionList: string; + + /** Card ID (0 for new card) */ + cardID: number; + + /** Display name for the card */ + cardName: string; + + /** Currency code (e.g., 'USD') */ + currency: string; + + /** Whether transactions are reimbursable */ + reimbursable: boolean; +}; + +export default ImportCSVTransactionsParams; diff --git a/src/libs/API/parameters/ImportCSVTransactionsParams.ts b/src/libs/API/parameters/ImportCSVTransactionsParams.ts new file mode 100644 index 000000000000..62947a824243 --- /dev/null +++ b/src/libs/API/parameters/ImportCSVTransactionsParams.ts @@ -0,0 +1,18 @@ +type ImportCSVTransactionsParams = { + /** JSON string of the transaction list */ + transactionList: string; + + /** Card ID (0 for new card) */ + cardID: number; + + /** Display name for the card */ + cardName: string; + + /** Currency code (e.g., 'USD') */ + currency: string; + + /** Whether transactions are reimbursable */ + reimbursable: boolean; +}; + +export default ImportCSVTransactionsParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ce301711406c..5cdbc9202dc4 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -1,4 +1,5 @@ export type {default as GetTransactionsForMergingParams} from './GetTransactionsForMergingParams'; +export type {default as ImportCSVTransactionsParams} from './ImportCSVTransactionsParams'; export type {default as ImportMultiLevelTagsParams} from './ImportMultiLevelTagsParams'; export type {default as CleanPolicyTagsParams} from './CleanPolicyTagsParams'; export type {default as ActivatePhysicalExpensifyCardParams} from './ActivatePhysicalExpensifyCardParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f32a18795889..4bc6c6027aae 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -11,6 +11,7 @@ import type UpdateBeneficialOwnersForBankAccountParams from './parameters/Update type ApiRequestType = ValueOf; const WRITE_COMMANDS = { + IMPORT_CSV_TRANSACTIONS: 'ImportCSVTransactions', CLEAN_POLICY_TAGS: 'ClearPolicyTags', IMPORT_MULTI_LEVEL_TAGS: 'ImportMultiLevelTags', SET_WORKSPACE_AUTO_HARVESTING: 'SetWorkspaceAutoHarvesting', @@ -551,6 +552,7 @@ const WRITE_COMMANDS = { type WriteCommand = ValueOf; type WriteCommandParameters = { + [WRITE_COMMANDS.IMPORT_CSV_TRANSACTIONS]: Parameters.ImportCSVTransactionsParams; [WRITE_COMMANDS.CLEAN_POLICY_TAGS]: Parameters.CleanPolicyTagsParams; [WRITE_COMMANDS.IMPORT_MULTI_LEVEL_TAGS]: Parameters.ImportMultiLevelTagsParams; [WRITE_COMMANDS.DISMISS_REFERRAL_BANNER]: Parameters.DismissReferralBannerParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 64e153733290..f452722fa372 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -887,6 +887,16 @@ function isCardAlreadyAssigned(cardNumberToCheck: string, workspaceCardFeeds: On }); } +/** + * Generate a random cardID up to 53 bits aka 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). + * There were approximately 24,000,000 reports with sequential IDs generated before we started using this approach, those make up roughly 0.25 billionth of the space for these numbers, + * so we live with the 1 in 4 billion chance of a collision with an older ID until we can switch to 64-bit IDs. + * + */ +function generateCardID(): number { + return Math.floor(Math.random() * 2 ** 21) * 2 ** 32 + Math.floor(Math.random() * 2 ** 32); +} + /** * Check if there are any assigned cards that should be displayed in the wallet page. * This includes active Expensify cards, company cards (domain), and personal cards. @@ -971,6 +981,7 @@ export { COMPANY_CARD_BANK_ICON_NAMES, splitMaskedCardNumber, isCardAlreadyAssigned, + generateCardID, hasDisplayableAssignedCards, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 96863596ff1e..9ed54065bb21 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -396,6 +396,11 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: () => require('../../../../pages/settings/Wallet/TransferBalancePage').default, [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ChooseTransferAccountPage').default, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: () => require('../../../../pages/settings/Wallet/ImportTransactionsPage').default, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CARD_NAME]: () => require('../../../../pages/settings/Wallet/ImportTransactionsCardNamePage').default, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CURRENCY]: () => require('../../../../pages/settings/Wallet/ImportTransactionsCurrencyPage').default, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_SPREADSHEET]: () => require('../../../../pages/settings/Wallet/ImportTransactionsSpreadsheetPage').default, + [SCREENS.SETTINGS.WALLET.TRANSACTIONS_IMPORTED]: () => require('../../../../pages/settings/Wallet/TransactionsImportedPage').default, [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePayments').default, [SCREENS.SETTINGS.WALLET.UNSHARE_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount').default, [SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS]: () => require('../../../../pages/settings/Wallet/EnableGlobalReimbursements').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 5c679d406e89..e50234509d29 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -40,6 +40,11 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT, exact: true, }, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: { + path: ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS, + exact: true, + }, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CARD_NAME]: { + path: ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_CARD_NAME, + exact: true, + }, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CURRENCY]: { + path: ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_CURRENCY, + exact: true, + }, + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_SPREADSHEET]: { + path: ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET.TRANSACTIONS_IMPORTED]: { + path: ROUTES.SETTINGS_WALLET_TRANSACTIONS_IMPORTED.route, + exact: true, + }, [SCREENS.SETTINGS.REPORT_CARD_LOST_OR_DAMAGED]: { path: ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d4ae33a8b318..68f0d2de9fb9 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -215,6 +215,11 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: undefined; [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CARD_NAME]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_CURRENCY]: undefined; + [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS_SPREADSHEET]: {cardID?: string}; + [SCREENS.SETTINGS.WALLET.TRANSACTIONS_IMPORTED]: {cardID?: string}; [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: undefined; [SCREENS.SETTINGS.WALLET.UNSHARE_BANK_ACCOUNT]: { bankAccountID: string; diff --git a/src/libs/actions/ImportSpreadsheet.ts b/src/libs/actions/ImportSpreadsheet.ts index 980030b60690..3b2b71c4b1e2 100644 --- a/src/libs/actions/ImportSpreadsheet.ts +++ b/src/libs/actions/ImportSpreadsheet.ts @@ -1,19 +1,60 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ImportTransactionSettings} from '@src/types/onyx/ImportedSpreadsheet'; -function setSpreadsheetData(data: string[][], fileURI: string, fileType: string, fileName: string, isImportingMultiLevelTags: boolean): Promise { - if (!Array.isArray(data) || !Array.isArray(data.at(0))) { - return Promise.reject(new Error('Invalid data format')); +function setSpreadsheetData( + data: string[][], + fileURI: string, + fileType: string, + fileName: string, + isImportingMultiLevelTags: boolean, + importTransactionSettings?: ImportTransactionSettings, +): Promise { + // Validate that data is a non-empty array + if (!Array.isArray(data) || data.length === 0) { + return Promise.reject(new Error('Invalid data format: data is empty or not an array')); } - const transposedData = data.at(0)?.map((_, colIndex) => data.map((row) => row.at(colIndex) ?? '')); - const columnNames: Record = - data.at(0)?.reduce((acc: Record, _, colIndex) => { - acc[colIndex] = CONST.CSV_IMPORT_COLUMNS.IGNORE; - return acc; - }, {}) ?? {}; - return Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET, {data: transposedData, columns: columnNames, fileURI, fileType, fileName, isImportingMultiLevelTags}); + // Validate that we have at least one row with data + const firstRow = data.at(0); + if (!Array.isArray(firstRow) || firstRow.length === 0) { + return Promise.reject(new Error('Invalid data format: first row is empty or not an array')); + } + + // Require at least 2 rows (header + data) for most imports + if (data.length < 2) { + return Promise.reject(new Error('Invalid data format: file must contain at least 2 rows')); + } + + const numColumns = firstRow.length; + + // Transpose data from row-major to column-major format + const transposedData: string[][] = firstRow.map((_, colIndex) => data.map((row) => String(row.at(colIndex) ?? ''))); + + const columnNames: Record = {}; + for (let colIndex = 0; colIndex < numColumns; colIndex++) { + columnNames[colIndex] = CONST.CSV_IMPORT_COLUMNS.IGNORE; + } + + // Use Onyx.set to replace the entire object (avoiding stale column data from previous files) + // but include the preserved settings passed from the caller + return Onyx.set(ONYXKEYS.IMPORTED_SPREADSHEET, { + data: transposedData, + columns: columnNames, + fileURI, + fileType, + fileName, + isImportingMultiLevelTags, + // Preserve transaction import settings that were configured before file upload + importTransactionSettings, + // Reset modal state for new import + shouldFinalModalBeOpened: false, + importFinalModal: undefined, + containsHeader: true, + isImportingIndependentMultiLevelTags: false, + isGLAdjacent: false, + }); } function setColumnName(columnIndex: number, columnName: string): Promise { @@ -25,7 +66,41 @@ function setContainsHeader(containsHeader: boolean): Promise { } function closeImportPage(): Promise { - return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {data: null, columns: null, shouldFinalModalBeOpened: false, importFinalModal: null}); + return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, { + data: null, + columns: null, + shouldFinalModalBeOpened: false, + importFinalModal: null, + // Clear the import settings so the next import starts fresh + importTransactionSettings: null, + }); +} + +function setImportTransactionCardName(cardDisplayName: string): Promise { + return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, { + importTransactionSettings: { + cardDisplayName, + }, + }); +} + +function setImportTransactionCurrency(currency: string): Promise { + return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, { + importTransactionSettings: { + currency, + }, + }); +} + +function setImportTransactionSettings(cardDisplayName: string, currency: string, isReimbursable: boolean, flipAmountSign: boolean): Promise { + return Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, { + importTransactionSettings: { + cardDisplayName, + currency, + isReimbursable, + flipAmountSign, + }, + }); } -export {setSpreadsheetData, setColumnName, closeImportPage, setContainsHeader}; +export {setSpreadsheetData, setColumnName, closeImportPage, setContainsHeader, setImportTransactionCardName, setImportTransactionCurrency, setImportTransactionSettings}; diff --git a/src/libs/actions/ImportTransactions.ts b/src/libs/actions/ImportTransactions.ts new file mode 100644 index 000000000000..007542451407 --- /dev/null +++ b/src/libs/actions/ImportTransactions.ts @@ -0,0 +1,343 @@ +import {addDays, format, isValid, parse} from 'date-fns'; +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {ImportCSVTransactionsParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import {generateCardID} from '@libs/CardUtils'; +import DateUtils from '@libs/DateUtils'; +import {rand64} from '@libs/NumberUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Card, CardList} from '@src/types/onyx'; +import type ImportedSpreadsheet from '@src/types/onyx/ImportedSpreadsheet'; +import type {ImportTransactionSettings} from '@src/types/onyx/ImportedSpreadsheet'; +import type Transaction from '@src/types/onyx/Transaction'; + +type TransactionFromCSV = { + transactionID: string; + created: string; + merchant: string; + amount: number; + category?: string; +}; + +// Common date formats to try when parsing CSV dates +// Order matters - more specific/common formats first +const CSV_DATE_FORMATS = [ + 'yyyy-MM-dd', // ISO format: 2025-11-02 + 'MM/dd/yyyy', // US format: 11/02/2025 + 'dd/MM/yyyy', // European format: 02/11/2025 + 'M/d/yyyy', // US short: 1/2/2025 + 'd/M/yyyy', // European short: 2/1/2025 + 'MM-dd-yyyy', // US with dashes: 11-02-2025 + 'dd-MM-yyyy', // European with dashes: 02-11-2025 + 'yyyy/MM/dd', // Alternative ISO: 2025/11/02 + 'MMM d, yyyy', // Month name: Nov 2, 2025 + 'MMMM d, yyyy', // Full month: November 2, 2025 + 'd MMM yyyy', // European with month name: 2 Nov 2025 + 'dd MMM yyyy', // European with month name: 02 Nov 2025 + 'yyyyMMdd', // Compact: 20251102 +]; + +/** + * Parses a date string from various formats and returns it in yyyy-MM-dd format + */ +function parseCSVDate(input: string): string | null { + if (!input || typeof input !== 'string') { + return null; + } + + const trimmedInput = input.trim(); + + // Try native Date parsing first (handles ISO and some other formats) + let date = new Date(trimmedInput); + if (isValid(date) && !Number.isNaN(date.getTime())) { + return format(date, CONST.DATE.FNS_FORMAT_STRING); + } + + // Try parsing with common date formats using date-fns + for (const dateFormat of CSV_DATE_FORMATS) { + const parsedDate = parse(trimmedInput, dateFormat, new Date()); + if (isValid(parsedDate)) { + return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING); + } + } + + // If the date didn't parse, try taking just the first 10 characters + if (trimmedInput.length > 10) { + const shortInput = trimmedInput.substring(0, 10); + date = new Date(shortInput); + if (isValid(date) && !Number.isNaN(date.getTime())) { + return format(date, CONST.DATE.FNS_FORMAT_STRING); + } + // Also try format parsing on the shortened input + for (const dateFormat of CSV_DATE_FORMATS) { + const parsedDate = parse(shortInput, dateFormat, new Date()); + if (isValid(parsedDate)) { + return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING); + } + } + } + + // If it didn't parse, maybe it's an Excel date number + // Excel stores dates serialized from January 1st, 1900 (with 1/1/1900 being 1) + // Excel thinks that 1900 was a leap year and adds an extra day to account for that + if (/^\d+$/.test(trimmedInput)) { + const inputInt = parseInt(trimmedInput, 10); + if (inputInt > 0 && inputInt < 100000) { + const excelEpoch = new Date(1900, 0, 1); // January 1, 1900 + const parsedDate = addDays(excelEpoch, inputInt - 2); + if (isValid(parsedDate)) { + return format(parsedDate, CONST.DATE.FNS_FORMAT_STRING); + } + } + } + + return null; +} + +/** + * Converts spreadsheet data to transaction objects based on column mapping + */ +function buildTransactionListFromSpreadsheet(spreadsheet: ImportedSpreadsheet, settings: ImportTransactionSettings): TransactionFromCSV[] { + const {data, columns, containsHeader = true} = spreadsheet; + const {flipAmountSign = false} = settings; + + // Find the column indexes for each field + let dateColumnIndex = -1; + let merchantColumnIndex = -1; + let amountColumnIndex = -1; + let categoryColumnIndex = -1; + + if (columns) { + for (const [indexStr, role] of Object.entries(columns)) { + const index = Number(indexStr); + switch (role) { + case 'date': + dateColumnIndex = index; + break; + case 'merchant': + merchantColumnIndex = index; + break; + case 'amount': + amountColumnIndex = index; + break; + case 'category': + categoryColumnIndex = index; + break; + default: + break; + } + } + } + + const transactions: TransactionFromCSV[] = []; + const startIndex = containsHeader ? 1 : 0; + + // Data is in column-major format, so we need to iterate through columns + if (!data || data.length === 0) { + return transactions; + } + + // Get the number of rows (length of first column) + const numRows = data.at(0)?.length ?? 0; + + for (let rowIndex = startIndex; rowIndex < numRows; rowIndex++) { + const dateValue = dateColumnIndex >= 0 ? data.at(dateColumnIndex)?.at(rowIndex) : undefined; + const merchantValue = merchantColumnIndex >= 0 ? data.at(merchantColumnIndex)?.at(rowIndex) : undefined; + const amountValue = amountColumnIndex >= 0 ? data.at(amountColumnIndex)?.at(rowIndex) : undefined; + const categoryValue = categoryColumnIndex >= 0 ? data.at(categoryColumnIndex)?.at(rowIndex) : undefined; + + // Skip rows with missing required fields + if (!dateValue || !amountValue) { + continue; + } + + // Parse the date using our multi-format parser + const parsedDate = parseCSVDate(dateValue); + + // Skip rows with invalid dates + if (!parsedDate) { + continue; + } + + // Parse amount - remove non-numeric characters except decimal and minus + let parsedAmount = Math.round(Number(String(amountValue).replaceAll(/[^\d.-]/g, '')) * 100); + + // Flip sign if needed + if (flipAmountSign) { + parsedAmount *= -1; + } + + const transaction: TransactionFromCSV = { + transactionID: rand64(), + created: parsedDate, + merchant: merchantValue ?? '', + amount: parsedAmount, + }; + + if (categoryValue) { + transaction.category = categoryValue; + } + + transactions.push(transaction); + } + + return transactions; +} + +/** + * Creates an optimistic card object for the imported transactions + */ +function buildOptimisticCard(cardDisplayName: string): {card: Card; cardID: number} { + const cardID = generateCardID(); + return { + cardID, + card: { + cardID, + state: CONST.EXPENSIFY_CARD.STATE.OPEN, + // Use the CSV bank name constant so the card shows up in the Assigned Cards section + bank: CONST.PERSONAL_CARD.BANK_NAME.CSV, + domainName: '', + lastFourPAN: '', + availableSpend: 0, + scrapeMinDate: '', + fraud: CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE, + lastUpdated: DateUtils.getDBTime(), + nameValuePairs: { + cardTitle: cardDisplayName, + } as Card['nameValuePairs'], + }, + }; +} + +/** + * Creates optimistic transaction objects from the CSV data + */ +function buildOptimisticTransactions(transactionList: TransactionFromCSV[], cardID: number, currency: string, isReimbursable: boolean): Transaction[] { + return transactionList.map( + (csvTransaction) => + ({ + transactionID: csvTransaction.transactionID, + cardID, + created: csvTransaction.created, + merchant: csvTransaction.merchant, + amount: csvTransaction.amount, + currency, + category: csvTransaction.category ?? '', + reimbursable: isReimbursable, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + comment: { + comment: '', + }, + reportID: '0', + }) as Transaction, + ); +} + +/** + * Import transactions from a CSV spreadsheet + */ +function importTransactionsFromCSV(spreadsheet: ImportedSpreadsheet) { + const settings = spreadsheet.importTransactionSettings ?? {}; + const {cardDisplayName = 'Imported Card', currency = CONST.CURRENCY.USD, isReimbursable = true} = settings; + + // Build transaction list from spreadsheet + const transactionList = buildTransactionListFromSpreadsheet(spreadsheet, settings); + + if (transactionList.length === 0) { + Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, { + shouldFinalModalBeOpened: true, + importFinalModal: { + titleKey: 'spreadsheet.importFailedTitle' as const, + promptKey: 'spreadsheet.invalidFileMessage' as const, + promptKeyParams: undefined, + }, + }); + return; + } + + // Create optimistic card + const {card: optimisticCard, cardID} = buildOptimisticCard(cardDisplayName); + + // Create optimistic transactions + const optimisticTransactions = buildOptimisticTransactions(transactionList, cardID, currency, isReimbursable); + + // Build optimistic card list update + const optimisticCardList: CardList = { + [cardID]: optimisticCard, + }; + + const params: ImportCSVTransactionsParams = { + transactionList: JSON.stringify(transactionList), + cardID, + cardName: cardDisplayName, + currency, + reimbursable: isReimbursable, + }; + + const optimisticData = [] as OnyxUpdate[]; + const failureData = [] as OnyxUpdate[]; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: optimisticCardList, + }); + + for (const transaction of optimisticTransactions) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: null, + }); + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: { + titleKey: 'spreadsheet.importSuccessfulTitle' as const, + promptKey: 'spreadsheet.importTransactionsSuccessfulDescription' as const, + promptKeyParams: {transactions: transactionList.length}, + }, + }, + }); + + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: { + titleKey: 'spreadsheet.importFailedTitle' as const, + promptKey: 'spreadsheet.importFailedDescription' as const, + promptKeyParams: undefined, + }, + }, + }, + ); + + API.write(WRITE_COMMANDS.IMPORT_CSV_TRANSACTIONS, params, { + optimisticData, + failureData, + }); +} + +export default importTransactionsFromCSV; diff --git a/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx b/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx new file mode 100644 index 000000000000..496e9a1984e9 --- /dev/null +++ b/src/pages/settings/Wallet/ImportTransactionsCardNamePage.tsx @@ -0,0 +1,79 @@ +import React, {useCallback} from 'react'; +import {Keyboard} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setImportTransactionCardName} from '@libs/actions/ImportSpreadsheet'; +import {addErrorMessage} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/ImportTransactionsForm'; + +function ImportTransactionsCardNamePage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET, {canBeMissing: true}); + + const submit = useCallback((values: FormOnyxValues) => { + setImportTransactionCardName(values.cardDisplayName.trim()); + Keyboard.dismiss(); + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack()); + }, []); + + const validate = useCallback( + (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const name = values.cardDisplayName.trim(); + + if ([...name].length > CONST.TITLE_CHARACTER_LIMIT) { + addErrorMessage(errors, 'cardDisplayName', translate('common.error.characterLimitExceedCounter', [...name].length, CONST.TITLE_CHARACTER_LIMIT)); + } + + return errors; + }, + [translate], + ); + + return ( + + Navigation.goBack()} + /> + + + + + ); +} + +export default ImportTransactionsCardNamePage; diff --git a/src/pages/settings/Wallet/ImportTransactionsCurrencyPage.tsx b/src/pages/settings/Wallet/ImportTransactionsCurrencyPage.tsx new file mode 100644 index 000000000000..fc914cdfc28f --- /dev/null +++ b/src/pages/settings/Wallet/ImportTransactionsCurrencyPage.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import CurrencySelectionList from '@components/CurrencySelectionList'; +import type {CurrencyListItem} from '@components/CurrencySelectionList/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {setImportTransactionCurrency} from '@libs/actions/ImportSpreadsheet'; +import Navigation from '@libs/Navigation/Navigation'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function ImportTransactionsCurrencyPage() { + const {translate} = useLocalize(); + const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET, {canBeMissing: true}); + + const onSelectCurrency = (item: CurrencyListItem) => { + setImportTransactionCurrency(item.currencyCode); + Navigation.setNavigationActionToMicrotaskQueue(Navigation.goBack); + }; + + return ( + + Navigation.goBack()} + /> + + + + ); +} + +export default ImportTransactionsCurrencyPage; diff --git a/src/pages/settings/Wallet/ImportTransactionsPage.tsx b/src/pages/settings/Wallet/ImportTransactionsPage.tsx new file mode 100644 index 000000000000..a3c77260a1a4 --- /dev/null +++ b/src/pages/settings/Wallet/ImportTransactionsPage.tsx @@ -0,0 +1,107 @@ +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setImportTransactionSettings} from '@libs/actions/ImportSpreadsheet'; +import Navigation from '@libs/Navigation/Navigation'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +function ImportTransactionsPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [importedSpreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET, {canBeMissing: true}); + + const cardDisplayName = importedSpreadsheet?.importTransactionSettings?.cardDisplayName ?? ''; + const currency = importedSpreadsheet?.importTransactionSettings?.currency ?? 'USD'; + const [isReimbursable, setIsReimbursable] = useState(importedSpreadsheet?.importTransactionSettings?.isReimbursable ?? true); + const [flipAmountSign, setFlipAmountSign] = useState(importedSpreadsheet?.importTransactionSettings?.flipAmountSign ?? false); + const [shouldShowError, setShouldShowError] = useState(false); + + const navigateToCardNameSelection = useCallback(() => { + Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_CARD_NAME); + }, []); + + const navigateToCurrencySelection = useCallback(() => { + Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_CURRENCY); + }, []); + + const handleNext = useCallback(() => { + if (!cardDisplayName) { + setShouldShowError(true); + return; + } + // Store import settings in Onyx so they're available when importing + setImportTransactionSettings(cardDisplayName, currency, isReimbursable, flipAmountSign); + // No cardID = creating a new card + Navigation.navigate(ROUTES.SETTINGS_WALLET_IMPORT_TRANSACTIONS_SPREADSHEET.getRoute()); + }, [cardDisplayName, currency, isReimbursable, flipAmountSign]); + + return ( + + + + + {translate('workspace.companyCards.importTransactions.description')} + + + + + + + + + + +