import { index as indexBalances, store as storeBalance, } from '@/actions/App/Http/Controllers/AccountBalanceController'; import AlertError from '@/components/alert-error'; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from '@/components/ui/drawer'; import { Progress } from '@/components/ui/progress'; import { importKey } from '@/lib/crypto'; import { getCsrfToken } from '@/lib/csrf'; import { autoDetectColumns, calculateBalancesFromTransactions, collectBalancesToImport, convertRowsToTransactions, parseFile, } from '@/lib/file-parser'; import { loadImportConfig, saveImportConfig, } from '@/lib/import-config-storage'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; import { transactionSyncService } from '@/services/transaction-sync'; import { type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { DateFormat, ImportStep, type ColumnMapping, type ImportState, } from '@/types/import'; import { type UUID } from '@/types/uuid'; import { __ } from '@/utils/i18n'; import { router, usePage } from '@inertiajs/react'; import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { ImportStepAccount } from './import-step-account'; import { ImportStepMapping } from './import-step-mapping'; import { ImportStepPreview } from './import-step-preview'; import { ImportStepUpload } from './import-step-upload'; interface ImportTransactionsDrawerProps { accounts?: Account[]; categories?: Category[]; banks?: Bank[]; automationRules?: AutomationRule[]; open: boolean; onOpenChange: (open: boolean) => void; onImportComplete?: () => void; autoSelectSingleAccount?: boolean; } interface ImportError { rowNumber: number; transaction: { date: string; description: string; amount: string; }; error: string; } export function ImportTransactionsDrawer({ accounts = [], categories = [], banks = [], automationRules = [], open, onOpenChange, onImportComplete, autoSelectSingleAccount = false, }: ImportTransactionsDrawerProps) { const { locale, features } = usePage().props; const [isImporting, setIsImporting] = useState(false); const [importProgress, setImportProgress] = useState(0); const [importTotal, setImportTotal] = useState(0); const [importErrors, setImportErrors] = useState([]); const [error, setError] = useState(null); const [wasSingleAccountAutoSelected, setWasSingleAccountAutoSelected] = useState(false); const [selectedAccount, setSelectedAccount] = useState( null, ); const [state, setState] = useState({ step: ImportStep.SelectAccount, selectedAccountId: null, file: null, parsedData: [], columnHeaders: [], columnOptions: [], columnMapping: { transaction_date: null, description: null, amount: null, balance: null, creditor_name: null, debtor_name: null, }, dateFormat: DateFormat.YearMonthDay, dateFormatDetected: false, transactions: [], calculateBalances: false, referenceBalance: null, referenceBalanceDate: null, referenceBalancePrefilled: false, }); useEffect(() => { if (state.selectedAccountId) { const account = accounts.find( (a) => a.id === state.selectedAccountId, ); if (account) { setSelectedAccount(account); } } }, [state.selectedAccountId, accounts]); useEffect(() => { if (!open) { setState({ step: ImportStep.SelectAccount, selectedAccountId: null, file: null, parsedData: [], columnHeaders: [], columnOptions: [], columnMapping: { transaction_date: null, description: null, amount: null, balance: null, creditor_name: null, debtor_name: null, }, dateFormat: DateFormat.YearMonthDay, dateFormatDetected: false, transactions: [], calculateBalances: false, referenceBalance: null, referenceBalanceDate: null, referenceBalancePrefilled: false, }); setIsImporting(false); setError(null); setWasSingleAccountAutoSelected(false); setSelectedAccount(null); } }, [open]); const handleAccountSelect = (accountId: UUID) => { setState((prev) => ({ ...prev, selectedAccountId: accountId })); }; const handleSingleAccountAutoSelect = (accountId: UUID) => { setWasSingleAccountAutoSelected(true); handleAccountSelect(accountId); }; const handleFileSelect = async (file: File) => { if (!file) { setState((prev) => ({ ...prev, file: null, parsedData: [], columnHeaders: [], columnOptions: [], })); return; } try { const { headers, data, columns, headerRowIndex } = await parseFile(file); const autoMapping = autoDetectColumns(headers); const columnOptions = headers.map((header, index) => { const columnData = columns[index] || []; const middleIndex = Math.floor(columnData.length / 2); const examples = columnData .slice( Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3, ) .filter( (cell) => cell !== null && cell !== undefined && String(cell).trim() !== '', ) .map((cell) => String(cell)) .slice(0, 3); return { value: header, label: header, examples, }; }); let detectedFormat = DateFormat.YearMonthDay; let formatDetected = false; let formatAmbiguous = false; if (autoMapping.transaction_date) { const { detectDateFormat } = await import('@/lib/file-parser'); const detected = detectDateFormat( data, autoMapping.transaction_date, locale, ); if (detected) { detectedFormat = detected.format; formatAmbiguous = detected.ambiguous; formatDetected = !detected.ambiguous; } } // Show the parsed file immediately with auto-detected columns; the // saved per-account config is fetched off the critical path so a // slow or hanging network never blocks the preview or Next button. setState((prev) => ({ ...prev, file, parsedData: data, columnHeaders: headers, columnOptions, columnMapping: autoMapping, dateFormat: detectedFormat, dateFormatDetected: formatDetected, })); const accountId = state.selectedAccountId; if (accountId) { const savedConfig = await loadImportConfig(accountId); if (savedConfig) { const isValidMapping = ( mapping: ColumnMapping, ): boolean => { const values = Object.values(mapping).filter( (v) => v !== null, ); return values.every((value) => { if (Array.isArray(value)) { return value.every((v) => headers.includes(v as string), ); } return headers.includes(value as string); }); }; if (isValidMapping(savedConfig.columnMapping)) { // Only apply if this file is still the selected one, so a // slow load can't clobber a file picked afterwards. Keep // the saved format as the default, but still show the // selector when the dates are ambiguous so a // previously-saved wrong format can be corrected. setState((prev) => prev.file === file ? { ...prev, columnMapping: savedConfig.columnMapping, dateFormat: savedConfig.dateFormat, dateFormatDetected: !formatAmbiguous, } : prev, ); } } } } catch (err) { setError( err instanceof Error ? err.message : 'Failed to parse file', ); } }; const handleMappingChange = ( field: keyof ColumnMapping, value: string | string[], ) => { setState((prev) => { const next = { ...prev, columnMapping: { ...prev.columnMapping, [field]: value, }, }; // Setting a balance column disables the calculate-balances option if (field === 'balance' && value) { next.calculateBalances = false; next.referenceBalance = null; next.referenceBalanceDate = null; next.referenceBalancePrefilled = false; } return next; }); }; const handleCalculateBalancesChange = (enabled: boolean) => { setState((prev) => ({ ...prev, calculateBalances: enabled, referenceBalance: enabled ? prev.referenceBalance : null, referenceBalancePrefilled: enabled ? prev.referenceBalancePrefilled : false, })); }; const handleReferenceBalanceChange = (balanceInCents: number) => { setState((prev) => ({ ...prev, referenceBalance: balanceInCents, referenceBalancePrefilled: false, })); }; const handleLatestDateChange = useCallback((date: string | null) => { setState((prev) => { if (prev.referenceBalanceDate === date) { return prev; } return { ...prev, referenceBalanceDate: date, referenceBalance: null, referenceBalancePrefilled: false, }; }); }, []); // Try to pre-fill the reference balance from an existing balance record // on that date. If found, no need to ask the user. useEffect(() => { if ( !state.calculateBalances || !state.referenceBalanceDate || !state.selectedAccountId || state.referenceBalance !== null ) { return; } let cancelled = false; (async () => { try { const response = await fetch( indexBalances.url(state.selectedAccountId as string, { query: { page: '1' }, }), { headers: { Accept: 'application/json' } }, ); if (!response.ok) { return; } const json = (await response.json()) as { data: { balance_date: string; balance: number }[]; }; if (cancelled) { return; } const match = json.data.find( (b) => b.balance_date === state.referenceBalanceDate, ); if (match) { setState((prev) => { if ( prev.referenceBalanceDate !== state.referenceBalanceDate || prev.referenceBalance !== null ) { return prev; } return { ...prev, referenceBalance: match.balance, referenceBalancePrefilled: true, }; }); } } catch (err) { console.error('Failed to load reference balance:', err); } })(); return () => { cancelled = true; }; }, [ state.calculateBalances, state.referenceBalanceDate, state.selectedAccountId, state.referenceBalance, ]); const handleDateFormatChange = (format: DateFormat) => { setState((prev) => ({ ...prev, dateFormat: format })); }; const handlePreviewTransactions = async () => { try { const parsedTransactions = convertRowsToTransactions( state.parsedData, state.columnMapping, state.dateFormat, ); const account = accounts.find( (a) => a.id === state.selectedAccountId, ); if (!account) { setError('Selected account not found'); return; } const duplicateFlags = await transactionSyncService.checkDuplicates( account.id, parsedTransactions, ); let transactionsWithDuplicateCheck = parsedTransactions.map( (transaction, index) => ({ ...transaction, isDuplicate: duplicateFlags[index], selected: !duplicateFlags[index], }), ); // When calculate-balances is enabled and no balance column is // mapped, derive balances from the reference balance for every // distinct transaction date. const shouldCalculate = state.calculateBalances && !state.columnMapping.balance && state.referenceBalanceDate !== null && state.referenceBalance !== null; if (shouldCalculate) { const calculatedBalances = calculateBalancesFromTransactions( transactionsWithDuplicateCheck, state.referenceBalanceDate as string, state.referenceBalance as number, ); transactionsWithDuplicateCheck = transactionsWithDuplicateCheck.map((transaction) => ({ ...transaction, balance: calculatedBalances.get( transaction.transaction_date, ) ?? transaction.balance ?? null, })); } if (state.selectedAccountId) { void saveImportConfig(state.selectedAccountId, { columnMapping: state.columnMapping, dateFormat: state.dateFormat, }); } setState((prev) => ({ ...prev, transactions: transactionsWithDuplicateCheck, step: ImportStep.Preview, })); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to process transactions', ); } }; const handleConfirmImport = async () => { setIsImporting(true); setError(null); setImportErrors([]); const newTransactions = state.transactions.filter((t) => t.selected); const total = newTransactions.length; setImportTotal(total); setImportProgress(0); if (!selectedAccount) { setError('Selected account not found'); setIsImporting(false); return; } const createdTransactions: unknown[] = []; const errors: ImportError[] = []; const keyString = getStoredKey(); const key = keyString ? await importKey(keyString) : null; const rules = key ? automationRules : []; const BATCH_SIZE = 20; let processedCount = 0; let uncategorizedCount = 0; for (let i = 0; i < newTransactions.length; i += BATCH_SIZE) { const batch = newTransactions.slice(i, i + BATCH_SIZE); const batchResults = await Promise.allSettled( batch.map(async (transaction, batchIndex) => { const rowNumber = i + batchIndex + 1; const encrypted: string = transaction.description; const iv: string | null = null; let categoryId: string | null = null; let notes: string | null = null; let notesIv: string | null = null; let labelIds: string[] = []; if (key && rules.length > 0) { const ruleMatch = await evaluateRulesForNewTransaction( { description: transaction.description, amount: transaction.amount / 100, transaction_date: transaction.transaction_date, account_id: selectedAccount.id, creditor_name: transaction.creditor_name, debtor_name: transaction.debtor_name, }, rules, categories, accounts, banks, key, ); if (ruleMatch) { if (ruleMatch.categoryId) { categoryId = ruleMatch.categoryId; } if (ruleMatch.note && ruleMatch.noteIv) { const { decrypt } = await import('@/lib/crypto'); notes = await decrypt( ruleMatch.note, key, ruleMatch.noteIv, ); notesIv = null; } if ( ruleMatch.labelIds && ruleMatch.labelIds.length > 0 ) { labelIds = ruleMatch.labelIds; } } } const transactionData = { user_id: (selectedAccount as Account & { user_id?: string }) .user_id || '00000000-0000-0000-0000-000000000000', account_id: selectedAccount.id, category_id: categoryId, description: encrypted, description_iv: iv, transaction_date: transaction.transaction_date, amount: transaction.amount, currency_code: selectedAccount.currency_code, notes: notes, notes_iv: notesIv, creditor_name: transaction.creditor_name ?? null, debtor_name: transaction.debtor_name ?? null, source: 'imported' as const, label_ids: labelIds.length > 0 ? labelIds : undefined, }; const createdTransaction = await transactionSyncService.create(transactionData); return { success: true, transaction: createdTransaction, rowNumber, hasCategory: categoryId !== null, }; }), ); batchResults.forEach((result, batchIndex) => { const transaction = batch[batchIndex]; const rowNumber = i + batchIndex + 1; if (result.status === 'fulfilled') { createdTransactions.push(result.value.transaction); if (!result.value.hasCategory) { uncategorizedCount++; } } else { const errorMessage = result.reason instanceof Error ? result.reason.message : __('Unknown error'); console.error(`Transaction ${rowNumber} failed:`, { transaction, error: result.reason, errorMessage, stack: result.reason instanceof Error ? result.reason.stack : undefined, }); errors.push({ rowNumber, transaction: { date: transaction.transaction_date, description: transaction.description, amount: transaction.amount.toString(), }, error: errorMessage, }); } }); processedCount += batch.length; setImportProgress(processedCount); } const balancesToImport = collectBalancesToImport(newTransactions); if (balancesToImport.size > 0) { try { const xsrfToken = getCsrfToken(); const balanceRecords = Array.from(balancesToImport.entries()); for (const [date, balance] of balanceRecords) { await fetch(storeBalance.url(selectedAccount.id), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken, Accept: 'application/json', }, body: JSON.stringify({ balance_date: date, balance, }), }); } } catch (err) { console.error('Failed to import balances:', err); } } setImportErrors(errors); setIsImporting(false); const successCount = createdTransactions.length; const errorCount = errors.length; console.log('Import complete:', { successCount, errorCount, total }); if (errorCount === 0 && successCount > 0) { const message = uncategorizedCount > 0 ? `${successCount} transaction${successCount !== 1 ? 's' : ''} imported (${uncategorizedCount} uncategorized)` : `${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`; toast.success(message, { action: uncategorizedCount > 0 ? { label: 'Categorize', onClick: () => router.visit('/transactions/categorize'), } : undefined, }); onOpenChange(false); } else if (successCount > 0 && errorCount > 0) { const message = uncategorizedCount > 0 ? `${successCount} transaction${successCount !== 1 ? 's' : ''} imported (${uncategorizedCount} uncategorized), ${errorCount} failed` : `${successCount} transaction${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`; toast.warning(message, { action: uncategorizedCount > 0 ? { label: 'Categorize', onClick: () => router.visit('/transactions/categorize'), } : undefined, }); } else if (successCount > 0) { const message = uncategorizedCount > 0 ? `${successCount} transaction${successCount !== 1 ? 's' : ''} imported (${uncategorizedCount} uncategorized)` : `${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`; toast.success(message, { action: uncategorizedCount > 0 ? { label: 'Categorize', onClick: () => router.visit('/transactions/categorize'), } : undefined, }); onOpenChange(false); } else { toast.error(__('All transactions failed to import')); } onImportComplete?.(); }; const handleSelectionChange = (index: number, selected: boolean) => { setState((prev) => ({ ...prev, transactions: prev.transactions.map((t, i) => i === index ? { ...t, selected } : t, ), })); }; const handleSelectAll = (selected: boolean) => { setState((prev) => ({ ...prev, transactions: prev.transactions.map((t) => t.isDuplicate ? t : { ...t, selected }, ), })); }; const moveToStep = (step: ImportStep) => { setState((prev) => ({ ...prev, step })); }; const getStepInfo = () => { switch (state.step) { case ImportStep.SelectAccount: return { title: __('Select Account'), description: __( 'Choose the account where transactions will be imported', ), }; case ImportStep.UploadFile: return { title: __('Upload File'), description: __( 'Drop your CSV or Excel file here, or click to browse', ), }; case ImportStep.MapColumns: return { title: __('Map Columns'), description: __( 'Match your file columns to transaction fields', ), }; case ImportStep.Preview: return { title: __('Preview Transactions'), description: __('Review transactions before importing'), }; default: if (isImporting) { return { title: __('Importing Transactions'), description: __( 'Please wait while we import your transactions', ), }; } return { title: __('Import Transactions'), description: __( 'Import transactions from CSV or Excel files', ), }; } }; const renderStep = () => { switch (state.step) { case ImportStep.SelectAccount: return ( { moveToStep(ImportStep.UploadFile); }} autoSelectSingleAccount={autoSelectSingleAccount} onAutoSelect={handleSingleAccountAutoSelect} /> ); case ImportStep.UploadFile: return ( { moveToStep(ImportStep.MapColumns); }} onBack={() => moveToStep(ImportStep.SelectAccount)} showBackButton={!wasSingleAccountAutoSelected} /> ); case ImportStep.MapColumns: return ( moveToStep(ImportStep.UploadFile)} /> ); case ImportStep.Preview: return ( moveToStep(ImportStep.MapColumns)} onSelectionChange={handleSelectionChange} onSelectAll={handleSelectAll} isImporting={isImporting} /> ); default: return null; } }; const renderImportProgress = () => { const percentage = importTotal > 0 ? (importProgress / importTotal) * 100 : 0; return (
{importProgress} of {importTotal}{' '} {__('transactions imported')} {Math.round(percentage)}%
{importErrors.length > 0 && (

{__('Errors (')} {importErrors.length})

{importErrors.map((error, index) => ( ))}
{__('Row')} {__('Date')} {__('Description')} {__('Amount')} {__('Error')}
{error.rowNumber} {error.transaction.date} {error.transaction.description} {error.transaction.amount} {error.error}
)}
); }; const stepInfo = getStepInfo(); return (
{stepInfo.title} {stepInfo.description} {error && (
)}
{isImporting ? renderImportProgress() : renderStep()}
); }