/**
* 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')}
info
${window.getTranslation('import.supportedFormats', 'Supported Formats')}
- • ${window.getTranslation('import.formatRequirement1', 'CSV files with Date, Description, and Amount columns')}
- • ${window.getTranslation('import.formatRequirement2', 'Supports comma, semicolon, or tab delimiters')}
- • ${window.getTranslation('import.formatRequirement3', 'Date formats: DD/MM/YYYY, YYYY-MM-DD, etc.')}
- • ${window.getTranslation('import.formatRequirement4', 'Maximum file size: 10MB')}
`;
}
/**
* 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 `
${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();
});
}