From 430038b3b9892c6004a0033274377d218491de76 Mon Sep 17 00:00:00 2001 From: Rishabh Mishra Date: Mon, 9 Feb 2026 21:39:37 +0530 Subject: [PATCH] Add transactions import feature (CSV/JSON) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add server- and client-side support for importing transactions from CSV or JSON files. - New controllers/importController.js: parses CSV (via exceljs) and JSON uploads, normalizes fields, validates each record with Joi, checks for duplicates (±60s window), and creates Transaction records. Returns import stats and errors. Supports common amount/date formats and falls back to user's preferred currency. - New public/js/import-modal.js and transactions.html updates: add an Import button, modal UI, client-side validation/preview, and upload flow that posts to /api/expenses/import. Shows success/error messages and triggers a transactions refresh. - routes/expenses.js: register POST /import route using existing upload middleware and requireAuth. - services/fileUploadService.js: add validateFile(file) to enforce allowed mimetypes and 10MB size limit; minor whitespace cleanup. - Add sample test_data.csv and test_data.json templates for testing/import templates. Notes: accepts .csv and .json, enforces file size/type limits, and reports skipped duplicates and validation errors in the response. --- controllers/importController.js | 146 +++++++++++++++++++++++++++ public/js/import-modal.js | 173 ++++++++++++++++++++++++++++++++ public/transactions.html | 44 +++++++- routes/expenses.js | 5 +- services/fileUploadService.js | 26 ++++- test_data.csv | 5 + test_data.json | 18 ++++ 7 files changed, 411 insertions(+), 6 deletions(-) create mode 100644 controllers/importController.js create mode 100644 public/js/import-modal.js create mode 100644 test_data.csv create mode 100644 test_data.json diff --git a/controllers/importController.js b/controllers/importController.js new file mode 100644 index 00000000..a8aacc41 --- /dev/null +++ b/controllers/importController.js @@ -0,0 +1,146 @@ +const Transaction = require('../models/Transaction'); +const User = require('../models/User'); +const xlsx = require('exceljs'); +const Joi = require('joi'); +const { Readable } = require('stream'); + +const transactionSchema = Joi.object({ + description: Joi.string().trim().max(100).required(), + amount: Joi.number().min(0.01).required(), + currency: Joi.string().uppercase().length(3).optional(), + category: Joi.string().valid('food', 'transport', 'entertainment', 'utilities', 'healthcare', 'shopping', 'other', 'salary', 'freelance', 'investment', 'transfer').required(), + type: Joi.string().valid('income', 'expense', 'transfer').required(), + merchant: Joi.string().trim().max(50).optional(), + date: Joi.date().optional() +}); + +exports.importTransactions = async (req, res) => { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + // req.file.buffer contains the data because middleware uses memoryStorage + const fileExtension = req.file.originalname.split('.').pop().toLowerCase(); + let transactions = []; + let errors = []; + let importedCount = 0; + let skippedCount = 0; + + try { + // Parse File + if (fileExtension === 'json') { + try { + const jsonString = req.file.buffer.toString('utf8'); + transactions = JSON.parse(jsonString); + } catch (e) { + return res.status(400).json({ error: 'Invalid JSON format' }); + } + } else if (fileExtension === 'csv') { + const workbook = new xlsx.Workbook(); + const stream = Readable.from(req.file.buffer); + await workbook.csv.read(stream); + + const worksheet = workbook.getWorksheet(1); + if (!worksheet) { + return res.status(400).json({ error: 'Invalid CSV file' }); + } + + // Assuming first row is header + const headers = []; + worksheet.getRow(1).eachCell((cell, colNumber) => { + headers[colNumber] = cell.value ? cell.value.toString().toLowerCase().trim() : ''; + }); + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; // Skip header + const transaction = {}; + row.eachCell((cell, colNumber) => { + const header = headers[colNumber]; + if (header) { + // cell.value can be an object in exceljs if it's a formula or rich text, but for CSV usually simple + transaction[header] = (cell.value && typeof cell.value === 'object' && cell.value.result) ? cell.value.result : cell.value; + } + }); + if (Object.keys(transaction).length > 0) { + transactions.push(transaction); + } + }); + } else { + return res.status(400).json({ error: 'Unsupported file format. Please upload CSV or JSON.' }); + } + + // Process Transactions + const user = await User.findById(req.user._id); + + for (const rawData of transactions) { + let amount = rawData.amount; + // Handle "1,000.00" string amounts + if (typeof amount === 'string') { + amount = parseFloat(amount.replace(/,/g, '')); + } + + let date = rawData.date ? new Date(rawData.date) : new Date(); + + const transactionData = { + description: rawData.description || 'Imported Transaction', + amount: amount, + currency: rawData.currency || user.preferredCurrency || 'INR', + category: rawData.category ? rawData.category.toLowerCase() : 'other', + type: rawData.type ? rawData.type.toLowerCase() : 'expense', + merchant: rawData.merchant || '', + date: date + }; + + // Validate + const { error, value } = transactionSchema.validate(transactionData); + if (error) { + errors.push({ + transaction: rawData, + error: error.details[0].message + }); + continue; + } + + // Check Duplicate + const startWindow = new Date(value.date); + startWindow.setSeconds(startWindow.getSeconds() - 60); + const endWindow = new Date(value.date); + endWindow.setSeconds(endWindow.getSeconds() + 60); + + const existing = await Transaction.findOne({ + user: req.user._id, + amount: value.amount, + description: value.description, + type: value.type, + date: { $gte: startWindow, $lte: endWindow } + }); + + if (existing) { + skippedCount++; + continue; + } + + // Create Transaction + await Transaction.create({ + ...value, + user: req.user._id, + originalAmount: value.amount, + originalCurrency: value.currency, + kind: value.type + }); + importedCount++; + } + + res.json({ + success: true, + imported: importedCount, + skipped: skippedCount, + errors: errors.length > 0 ? errors : undefined, + message: `Successfully imported ${importedCount} transactions. ${skippedCount} skipped as duplicates.` + }); + + } catch (error) { + console.error('Import error:', error); + res.status(500).json({ error: 'Failed to process import file: ' + error.message }); + } +}; diff --git a/public/js/import-modal.js b/public/js/import-modal.js new file mode 100644 index 00000000..50cf4540 --- /dev/null +++ b/public/js/import-modal.js @@ -0,0 +1,173 @@ +class ImportModalManager { + constructor() { + this.file = null; + this.init(); + } + + init() { + // We will bind events after the modal HTML is injected or we can rely on onclick attributes + } + + openModal() { + const modal = document.getElementById('importModal'); + if (modal) { + modal.style.display = 'block'; + this.resetForm(); + } + } + + closeModal() { + const modal = document.getElementById('importModal'); + if (modal) { + modal.style.display = 'none'; + this.resetForm(); + } + } + + resetForm() { + this.file = null; + const fileInput = document.getElementById('importFile'); + if (fileInput) fileInput.value = ''; + + const preview = document.getElementById('filePreview'); + if (preview) { + preview.innerHTML = ''; + preview.style.display = 'none'; + } + + const error = document.getElementById('importError'); + if (error) { + error.style.display = 'none'; + error.textContent = ''; + } + + const success = document.getElementById('importSuccess'); + if (success) { + success.style.display = 'none'; + success.textContent = ''; + } + } + + handleFileSelect(event) { + const file = event.target.files[0]; + if (!file) return; + + // Validate file type + const validTypes = ['.csv', '.json', 'application/json', 'text/csv', 'application/vnd.ms-excel']; + const fileExtension = '.' + file.name.split('.').pop().toLowerCase(); + + // Simple client-side validation + if (file.size > 10 * 1024 * 1024) { + this.showError('File size exceeds 10MB limit.'); + this.resetForm(); + return; + } + + this.file = file; + this.showPreview(file); + this.showError(''); // Clear error + } + + showPreview(file) { + const preview = document.getElementById('filePreview'); + if (preview) { + preview.innerHTML = ` +
+ + ${file.name} + (${(file.size / 1024).toFixed(2)} KB) +
+ `; + preview.style.display = 'block'; + } + } + + showError(message) { + const error = document.getElementById('importError'); + if (error) { + error.textContent = message; + error.style.display = message ? 'block' : 'none'; + } + } + + showSuccess(message) { + const success = document.getElementById('importSuccess'); + if (success) { + success.textContent = message; + success.style.display = message ? 'block' : 'none'; + } + } + + async uploadFile() { + if (!this.file) { + this.showError('Please select a file to upload.'); + return; + } + + const formData = new FormData(); + formData.append('receipt', this.file); // Using 'receipt' as field name to match uploadMiddleware config + + const submitBtn = document.getElementById('btnImportSubmit'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = ' Importing...'; + } + + try { + const response = await fetch('/api/expenses/import', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: formData + }); + + const data = await response.json(); + + if (response.ok) { + this.showSuccess(data.message); + this.showError(''); + + // Show imported/skipped stats + setTimeout(() => { + this.closeModal(); + if (window.transactionsManager) { + window.transactionsManager.loadTransactions(); + window.transactionsManager.showNotification(data.message, 'success'); + } else { + location.reload(); + } + }, 2000); // Wait 2s to show success message in modal + } else { + throw new Error(data.error || 'Upload failed'); + } + } catch (error) { + console.error('Upload Error:', error); + this.showError(error.message); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = ' Import Transactions'; + } + } + } +} + +const importModalManager = new ImportModalManager(); + +// Expose functions globally for onclick handlers +function openImportModal() { + importModalManager.openModal(); +} + +function closeImportModal() { + importModalManager.closeModal(); +} + +function handleImportFileSelect(event) { + importModalManager.handleFileSelect(event); +} + +function submitImport() { + importModalManager.uploadFile(); +} diff --git a/public/transactions.html b/public/transactions.html index 7f5e78ba..3641faae 100644 --- a/public/transactions.html +++ b/public/transactions.html @@ -34,6 +34,10 @@

💳 Transaction History

+ @@ -327,9 +331,43 @@

Bulk Categorize

- - - + + + + + \ No newline at end of file diff --git a/routes/expenses.js b/routes/expenses.js index e02e6dfd..6ce94b32 100644 --- a/routes/expenses.js +++ b/routes/expenses.js @@ -11,7 +11,7 @@ const { asyncHandler } = require('../middleware/errorMiddleware'); const { ExpenseSchemas, validateRequest, validateQuery } = require('../middleware/inputValidator'); const { expenseLimiter, exportLimiter } = require('../middleware/rateLimiter'); const { NotFoundError } = require('../utils/AppError'); -const {requireAuth,getUserId}=require('../middleware/clerkAuth'); +const { requireAuth, getUserId } = require('../middleware/clerkAuth'); const router = express.Router(); @@ -297,4 +297,7 @@ router.post('/report/preview', requireAuth, asyncHandler(async (req, res) => { return ResponseFactory.success(res, previewData); })); +// Import transactions +router.post('/import', requireAuth, require('../middleware/uploadMiddleware').upload, require('../controllers/importController').importTransactions); + module.exports = router; \ No newline at end of file diff --git a/services/fileUploadService.js b/services/fileUploadService.js index e2f5c293..e2336730 100644 --- a/services/fileUploadService.js +++ b/services/fileUploadService.js @@ -18,9 +18,9 @@ class FileUploadService { try { const filename = `${Date.now()}-${file.originalname}`; const filepath = path.join(this.uploadDir, filename); - + fs.writeFileSync(filepath, file.buffer); - + return { success: true, url: `/uploads/${filename}`, @@ -47,6 +47,28 @@ class FileUploadService { return { success: false, error: error.message }; } } + validateFile(file) { + const allowedTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'application/pdf', + 'text/csv', + 'application/json', + 'application/vnd.ms-excel' + ]; + + if (!allowedTypes.includes(file.mimetype)) { + throw new Error(`Invalid file type: ${file.mimetype}. Allowed types: Images, PDF, CSV, JSON.`); + } + + const maxSize = 10 * 1024 * 1024; // 10MB + if (file.size > maxSize) { + throw new Error('File size too large. Maximum size is 10MB.'); + } + + return true; + } } module.exports = new FileUploadService(); \ No newline at end of file diff --git a/test_data.csv b/test_data.csv new file mode 100644 index 00000000..4f2be523 --- /dev/null +++ b/test_data.csv @@ -0,0 +1,5 @@ +date,description,amount,category,type,merchant +2023-10-25,Grocery Shopping,1500,food,expense,SuperMart +2023-10-26,Uber Ride,350,transport,expense,Uber +2023-10-27,Salary,50000,salary,income,Employer +2023-10-28,Movie Night,800,entertainment,expense,Cinema diff --git a/test_data.json b/test_data.json new file mode 100644 index 00000000..c4c05459 --- /dev/null +++ b/test_data.json @@ -0,0 +1,18 @@ +[ + { + "date": "2023-10-29", + "description": "Online Course", + "amount": 499, + "category": "education", + "type": "expense", + "merchant": "Udemy" + }, + { + "date": "2023-10-30", + "description": "Freelance Project", + "amount": 15000, + "category": "freelance", + "type": "income", + "merchant": "Client A" + } +] \ No newline at end of file