/** * CSV/Bank Statement Import Module for FINA PWA * Handles file upload, parsing, duplicate detection, and category mapping */ class CSVImporter { constructor() { this.parsedTransactions = []; this.duplicates = []; this.categoryMapping = {}; this.userCategories = []; this.currentStep = 1; } /** * Initialize the importer */ async init() { await this.loadUserProfile(); await this.loadUserCategories(); this.renderImportUI(); this.setupEventListeners(); } /** * Load user profile to get currency */ async loadUserProfile() { try { const response = await window.apiCall('/api/settings/profile'); window.userCurrency = response.profile?.currency || 'USD'; } catch (error) { console.error('Failed to load user profile:', error); window.userCurrency = 'USD'; } } /** * Load user's categories from API */ async loadUserCategories() { try { const response = await window.apiCall('/api/expenses/categories'); this.userCategories = response.categories || []; } catch (error) { console.error('Failed to load categories:', error); this.userCategories = []; window.showToast(window.getTranslation('import.errorLoadingCategories', 'Failed to load categories'), 'error'); } } /** * Setup event listeners */ setupEventListeners() { // File input change const fileInput = document.getElementById('csvFileInput'); if (fileInput) { fileInput.addEventListener('change', (e) => this.handleFileSelect(e)); } // Drag and drop const dropZone = document.getElementById('csvDropZone'); if (dropZone) { dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('border-primary', 'bg-primary/5'); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-primary', 'bg-primary/5'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('border-primary', 'bg-primary/5'); const files = e.dataTransfer.files; if (files.length > 0) { this.handleFile(files[0]); } }); } } /** * Render the import UI */ renderImportUI() { const container = document.getElementById('importContainer'); if (!container) return; container.innerHTML = `
${this.renderStep(1, 'import.stepUpload', 'Upload CSV')} ${this.renderStep(2, 'import.stepReview', 'Review')} ${this.renderStep(3, 'import.stepMap', 'Map Categories')} ${this.renderStep(4, 'import.stepImport', 'Import')}
${this.renderCurrentStep()}
`; } /** * Render a progress step */ renderStep(stepNum, translationKey, fallback) { const isActive = this.currentStep === stepNum; const isComplete = this.currentStep > stepNum; return `
${isComplete ? 'check' : stepNum}
${window.getTranslation(translationKey, fallback)}
${stepNum < 4 ? '
' : ''}
`; } /** * Render content for current step */ renderCurrentStep() { switch (this.currentStep) { case 1: return this.renderUploadStep(); case 2: return this.renderReviewStep(); case 3: return this.renderMappingStep(); case 4: return this.renderImportStep(); default: return ''; } } /** * Render upload step */ renderUploadStep() { return `

${window.getTranslation('import.uploadTitle', 'Upload CSV File')}

${window.getTranslation('import.uploadDesc', 'Upload your bank statement or expense CSV file')}

cloud_upload

${window.getTranslation('import.dragDrop', 'Drag and drop your CSV file here')}

${window.getTranslation('import.orClick', 'or click to browse')}

info ${window.getTranslation('import.supportedFormats', 'Supported Formats')}

`; } /** * Handle file selection */ async handleFileSelect(event) { const file = event.target.files[0]; if (file) { await this.handleFile(file); } } /** * Handle file upload and parsing */ async handleFile(file) { // Validate file if (!file.name.toLowerCase().endsWith('.csv')) { window.showToast(window.getTranslation('import.errorInvalidFile', 'Please select a CSV file'), 'error'); return; } if (file.size > 10 * 1024 * 1024) { window.showToast(window.getTranslation('import.errorFileTooLarge', 'File too large. Maximum 10MB'), 'error'); return; } // Show loading const stepContent = document.getElementById('stepContent'); stepContent.innerHTML = `

${window.getTranslation('import.parsing', 'Parsing CSV file...')}

`; try { // Upload and parse const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/import/parse-csv', { method: 'POST', body: formData }); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to parse CSV'); } this.parsedTransactions = result.transactions; // Check for duplicates await this.checkDuplicates(); // Move to review step this.currentStep = 2; this.renderImportUI(); } catch (error) { console.error('Failed to parse CSV:', error); window.showToast(error.message || window.getTranslation('import.errorParsing', 'Failed to parse CSV file'), 'error'); this.currentStep = 1; this.renderImportUI(); } } /** * Check for duplicate transactions */ async checkDuplicates() { try { const response = await fetch('/api/import/detect-duplicates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transactions: this.parsedTransactions }) }); const result = await response.json(); if (result.success) { this.duplicates = result.duplicates || []; // Mark transactions as duplicates this.parsedTransactions.forEach((trans, idx) => { const isDuplicate = this.duplicates.some(d => d.transaction.date === trans.date && d.transaction.amount === trans.amount && d.transaction.description === trans.description ); this.parsedTransactions[idx].is_duplicate = isDuplicate; }); } } catch (error) { console.error('Failed to check duplicates:', error); } } /** * Render review step */ renderReviewStep() { const duplicateCount = this.parsedTransactions.filter(t => t.is_duplicate).length; const newCount = this.parsedTransactions.length - duplicateCount; return `

${window.getTranslation('import.reviewTitle', 'Review Transactions')}

${this.parsedTransactions.length}
${window.getTranslation('import.totalFound', 'Total Found')}
${newCount}
${window.getTranslation('import.newTransactions', 'New')}
${duplicateCount}
${window.getTranslation('import.duplicates', 'Duplicates')}
${this.parsedTransactions.map((trans, idx) => this.renderTransactionRow(trans, idx)).join('')}
`; } /** * Render a transaction row */ renderTransactionRow(trans, idx) { const isDuplicate = trans.is_duplicate; return `
${trans.description} ${isDuplicate ? '' + window.getTranslation('import.duplicate', 'Duplicate') + '' : ''}
${trans.date}
${window.formatCurrency(trans.amount, trans.currency || window.userCurrency || 'GBP')}
`; } /** * Check for missing categories and offer to create them */ async checkAndCreateCategories() { const selectedTransactions = this.parsedTransactions.filter(t => { const checkbox = document.getElementById(`trans_${this.parsedTransactions.indexOf(t)}`); return !checkbox || checkbox.checked; }); // Get unique bank categories (skip generic payment types) const paymentTypes = ['pot transfer', 'card payment', 'direct debit', 'monzo_paid', 'faster payment', 'bacs (direct credit)', 'bacs', 'standing order']; const bankCategories = new Set(); selectedTransactions.forEach(trans => { if (trans.bank_category && trans.bank_category.trim()) { const catLower = trans.bank_category.trim().toLowerCase(); // Skip if it's a generic payment type if (!paymentTypes.includes(catLower)) { bankCategories.add(trans.bank_category.trim()); } } }); if (bankCategories.size === 0) { return; // No bank categories to create } // Find which categories don't exist const existingCatNames = new Set(this.userCategories.map(c => c.name.toLowerCase())); const missingCategories = Array.from(bankCategories).filter( cat => !existingCatNames.has(cat.toLowerCase()) ); if (missingCategories.length > 0) { // Show confirmation dialog const confirmCreate = confirm( window.getTranslation( 'import.createMissingCategories', `Found ${missingCategories.length} new categories from your CSV:\n\n${missingCategories.join('\n')}\n\nWould you like to create these categories automatically?` ) ); if (confirmCreate) { try { const response = await window.apiCall('/api/import/create-categories', { method: 'POST', body: JSON.stringify({ bank_categories: missingCategories }) }); if (response.success) { window.showToast( window.getTranslation( 'import.categoriesCreated', `Created ${response.created.length} new categories` ), 'success' ); // Update category mapping with new categories Object.assign(this.categoryMapping, response.mapping); // Reload categories await this.loadUserCategories(); this.renderImportUI(); this.setupEventListeners(); } } catch (error) { console.error('Failed to create categories:', error); window.showToast( window.getTranslation('import.errorCreatingCategories', 'Failed to create categories'), 'error' ); } } } } /** * Toggle transaction selection */ toggleTransaction(idx) { const checkbox = document.getElementById(`trans_${idx}`); this.parsedTransactions[idx].selected = checkbox.checked; } /** * Render mapping step */ renderMappingStep() { const selectedTransactions = this.parsedTransactions.filter(t => { const checkbox = document.getElementById(`trans_${this.parsedTransactions.indexOf(t)}`); return !checkbox || checkbox.checked; }); // Get unique bank categories or descriptions for mapping (skip payment types) const paymentTypes = ['pot transfer', 'card payment', 'direct debit', 'monzo_paid', 'faster payment', 'bacs (direct credit)', 'bacs', 'standing order']; const needsMapping = new Set(); selectedTransactions.forEach(trans => { if (trans.bank_category) { const catLower = trans.bank_category.toLowerCase(); // Skip generic payment types if (!paymentTypes.includes(catLower)) { needsMapping.add(trans.bank_category); } } }); return `

${window.getTranslation('import.mapCategories', 'Map Categories')}

${window.getTranslation('import.mapCategoriesDesc', 'Assign categories to your transactions')}

${needsMapping.size > 0 ? `

${window.getTranslation('import.bankCategoryMapping', 'Bank Category Mapping')}

${Array.from(needsMapping).map(bankCat => this.renderCategoryMapping(bankCat)).join('')}
` : ''}

${window.getTranslation('import.defaultCategory', 'Default Category')}

${window.getTranslation('import.defaultCategoryDesc', 'Used for transactions without bank category')}

`; } /** * Render category mapping dropdown */ renderCategoryMapping(bankCategory) { return `
${bankCategory}
${window.getTranslation('import.bankCategory', 'Bank Category')}
`; } /** * Set category mapping */ setMapping(bankCategory, categoryId) { this.categoryMapping[bankCategory] = parseInt(categoryId); } /** * Start import process */ async startImport() { const selectedTransactions = this.parsedTransactions.filter((t, idx) => { const checkbox = document.getElementById(`trans_${idx}`); return !checkbox || checkbox.checked; }); if (selectedTransactions.length === 0) { window.showToast(window.getTranslation('import.noTransactionsSelected', 'No transactions selected'), 'error'); return; } // Show loading this.currentStep = 4; this.renderImportUI(); try { const response = await window.apiCall('/api/import/import', { method: 'POST', body: JSON.stringify({ transactions: selectedTransactions, category_mapping: this.categoryMapping, skip_duplicates: true }) }); if (response.success) { this.renderImportComplete(response); } else { throw new Error(response.error || 'Import failed'); } } catch (error) { console.error('Import failed:', error); window.showToast(error.message || window.getTranslation('import.errorImporting', 'Failed to import transactions'), 'error'); this.goToStep(3); } } /** * Render import complete step */ renderImportStep() { return `

${window.getTranslation('import.importing', 'Importing transactions...')}

`; } /** * Render import complete */ renderImportComplete(result) { const stepContent = document.getElementById('stepContent'); const hasErrors = result.errors && result.errors.length > 0; stepContent.innerHTML = `
check_circle

${window.getTranslation('import.importComplete', 'Import Complete!')}

${result.imported_count}
${window.getTranslation('import.imported', 'Imported')}
${result.skipped_count}
${window.getTranslation('import.skipped', 'Skipped')}
${result.error_count}
${window.getTranslation('import.errors', 'Errors')}
${hasErrors ? `
${window.getTranslation('import.viewErrors', 'View Error Details')} (${result.error_count})
${result.errors.slice(0, 20).map((err, idx) => `
${err.transaction?.description || 'Transaction ' + (idx + 1)}
${err.error}
`).join('')} ${result.errors.length > 20 ? `
... and ${result.errors.length - 20} more errors
` : ''}
` : ''}
`; } /** * Go to a specific step */ async goToStep(step) { this.currentStep = step; // If going to mapping step, check for missing categories if (step === 3) { await this.checkAndCreateCategories(); } this.renderImportUI(); this.setupEventListeners(); } /** * Reset importer */ reset() { this.parsedTransactions = []; this.duplicates = []; this.categoryMapping = {}; this.currentStep = 1; this.renderImportUI(); } } // Create global instance window.csvImporter = new CSVImporter(); // Initialize on import page if (window.location.pathname === '/import' || window.location.pathname.includes('import')) { document.addEventListener('DOMContentLoaded', () => { window.csvImporter.init(); }); }