refactor: Move inline JavaScript to a separate file
- Move all JavaScript code from index.html to app.js. - Update index.html to include a script tag for app.js.
This commit is contained in:
parent
0ae3bda8be
commit
bc8998d57e
@ -1,10 +1,9 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./ledger_db:/usr/src/app/data
|
- ./simpleledger.db:/usr/src/app/data/simpleledger.db
|
||||||
|
|
||||||
volumes:
|
|
||||||
ledger_db:
|
|
||||||
|
|||||||
1180
index.html
1180
index.html
File diff suppressed because it is too large
Load Diff
321
server.js
321
server.js
@ -552,140 +552,157 @@ app.get('/api/dashboard-summary', authenticateToken, (req, res) => {
|
|||||||
app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', maxCount: 1 }, { name: 'salesFile', maxCount: 1 }]), async (req, res) => {
|
app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', maxCount: 1 }, { name: 'salesFile', maxCount: 1 }]), async (req, res) => {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
const rules = await new Promise((resolve, reject) => {
|
try {
|
||||||
db.all('SELECT * FROM rules WHERE userId = ?', [userId], (err, rows) => {
|
const [rules, existingTransactions] = await Promise.all([
|
||||||
if (err) reject(err);
|
new Promise((resolve, reject) => {
|
||||||
resolve(rows);
|
db.all('SELECT * FROM rules WHERE userId = ?', [userId], (err, rows) => {
|
||||||
});
|
if (err) reject(err);
|
||||||
});
|
resolve(rows);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT date, description, amount FROM transactions WHERE userId = ?', [userId], (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(rows);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
let transactionsToInsert = []; // This will hold { tx, split } objects
|
const existingTxKeys = new Set(existingTransactions.map(tx => `${new Date(tx.date).toISOString().slice(0, 10)}:${tx.description.trim()}:${tx.amount.toFixed(2)}`));
|
||||||
|
let transactionsToInsert = [];
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
// --- NEW: High-confidence "smart guess" logic ---
|
const smartGuessCategory = (description) => {
|
||||||
const smartGuessCategory = (description) => {
|
const desc = description.toUpperCase();
|
||||||
const desc = description.toUpperCase();
|
const guesses = {
|
||||||
const guesses = {
|
'Expense - Payroll': ['PAYROLL', 'ADP '],
|
||||||
'Expense - Payroll': ['PAYROLL', 'ADP '],
|
'Expense - Rent/Lease': ['RENT', 'COURTYARD OF ACH'],
|
||||||
'Expense - Rent/Lease': ['RENT', 'COURTYARD OF ACH'],
|
'Expense - Utilities': ['UINET', 'COMCAST', 'VERIZON', 'ELECTRIC', 'POWER', 'GAS '],
|
||||||
'Expense - Utilities': ['UINET', 'COMCAST', 'VERIZON', 'ELECTRIC', 'POWER', 'GAS '],
|
'Expense - Vehicle': ['CARWASH', 'AUTOZONE', 'PEP BOYS', 'GASOLINE', 'EXXON', 'MOBIL', 'RSCARWASH'],
|
||||||
'Expense - Vehicle': ['CARWASH', 'AUTOZONE', 'PEP BOYS', 'GASOLINE', 'EXXON', 'MOBIL', 'RSCARWASH'],
|
'Expense - Office Supplies': ['STAPLES', 'OFFICE DEPOT', 'OFFICEMAX', 'WAL MART', 'MAUI INC'],
|
||||||
'Expense - Office Supplies': ['STAPLES', 'OFFICE DEPOT', 'OFFICEMAX', 'WAL MART', 'MAUI INC'],
|
'Expense - Software/Subscriptions': ['OPENAI', 'CHATGPT', 'GOOGLE *', 'MSFT *', 'ADOBE'],
|
||||||
'Expense - Software/Subscriptions': ['OPENAI', 'CHATGPT', 'GOOGLE *', 'MSFT *', 'ADOBE'],
|
'Expense - Meals & Entertainment': ['RESTAURANT', 'DOORDASH', 'UBER EATS', 'GRUBHUB'],
|
||||||
'Expense - Meals & Entertainment': ['RESTAURANT', 'DOORDASH', 'UBER EATS', 'GRUBHUB'],
|
'Expense - Bank Fees': ['MAINTENANCE FEE', 'SQUARE PAYMENT PROCESSING'],
|
||||||
'Expense - Bank Fees': ['MAINTENANCE FEE', 'SQUARE PAYMENT PROCESSING'],
|
'Liability - Owner\'s Draw': ['PERSONAL', 'TRANSFER TO SELF']
|
||||||
'Liability - Owner\'s Draw': ['PERSONAL', 'TRANSFER TO SELF'] // Added for owner's draw
|
};
|
||||||
};
|
|
||||||
|
|
||||||
for (const [category, keywords] of Object.entries(guesses)) {
|
for (const [category, keywords] of Object.entries(guesses)) {
|
||||||
for (const keyword of keywords) {
|
for (const keyword of keywords) {
|
||||||
if (desc.includes(keyword)) {
|
if (desc.includes(keyword)) {
|
||||||
return category; // Found a confident match
|
return category;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
return null; // No confident guess
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// --- MODIFIED: This function now tries smart guess first, then user rules ---
|
|
||||||
const applyCategorization = (tx, split) => {
|
|
||||||
const desc = tx.description.toUpperCase();
|
|
||||||
|
|
||||||
// 1. Try smart guess first
|
const applyCategorization = (tx, split) => {
|
||||||
const guess = smartGuessCategory(tx.description);
|
const desc = tx.description.toUpperCase();
|
||||||
if (guess) {
|
|
||||||
split.category = guess;
|
|
||||||
tx.reconciliation_status = 'categorized';
|
|
||||||
return; // Found a confident match
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. If no guess, apply user's rules
|
|
||||||
for (const rule of rules) {
|
|
||||||
if (desc.includes(rule.keyword.toUpperCase())) {
|
|
||||||
split.category = rule.category;
|
|
||||||
tx.reconciliation_status = 'categorized'; // Mark parent as categorized
|
|
||||||
return; // Stop after first match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no guess and no rule, it remains 'Expense - Uncategorized'
|
|
||||||
};
|
|
||||||
|
|
||||||
const processBankCSV = (data) => {
|
|
||||||
data.forEach(row => {
|
|
||||||
const date = new Date(row.Date);
|
|
||||||
const debit = parseFloat(row.Debit) || 0;
|
|
||||||
const credit = parseFloat(row.Credit) || 0;
|
|
||||||
const amount = credit - debit;
|
|
||||||
const description = row.Description ? row.Description.trim() : '';
|
|
||||||
|
|
||||||
if (!isNaN(date) && amount !== 0) {
|
const guess = smartGuessCategory(tx.description);
|
||||||
let tx = {
|
if (guess) {
|
||||||
date: date.toISOString(),
|
split.category = guess;
|
||||||
description: description,
|
tx.reconciliation_status = 'categorized';
|
||||||
amount: amount,
|
return;
|
||||||
source: 'bank_csv',
|
|
||||||
reconciliation_status: 'uncategorized',
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
let split = {
|
|
||||||
category: 'Expense - Uncategorized',
|
|
||||||
description: description,
|
|
||||||
amount: amount
|
|
||||||
};
|
|
||||||
// --- MODIFIED: Call the new function ---
|
|
||||||
applyCategorization(tx, split);
|
|
||||||
transactionsToInsert.push({ tx, split });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const processSalesCSV = (data, fileName) => {
|
|
||||||
let salesData = {};
|
|
||||||
const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/);
|
|
||||||
const txDate = dateMatch ? new Date(dateMatch[1]) : new Date();
|
|
||||||
|
|
||||||
const cleanCurrency = (val) => {
|
for (const rule of rules) {
|
||||||
if (!val) return 0;
|
if (desc.includes(rule.keyword.toUpperCase())) {
|
||||||
val = String(val).replace(/[$",]/g, '');
|
split.category = rule.category;
|
||||||
if (val.startsWith('(') && val.endsWith(')')) {
|
tx.reconciliation_status = 'categorized';
|
||||||
val = '-' + val.substring(1, val.length - 1);
|
return;
|
||||||
}
|
}
|
||||||
return parseFloat(val) || 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
data.forEach(row => {
|
|
||||||
const key = (row[0] || '').replace(/"/g, '').trim();
|
|
||||||
const value = cleanCurrency(row[1]);
|
|
||||||
salesData[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const addTx = (desc, cat, amount) => {
|
|
||||||
if (amount !== 0) {
|
|
||||||
let tx = {
|
|
||||||
date: txDate.toISOString(),
|
|
||||||
description: desc,
|
|
||||||
amount: amount,
|
|
||||||
source: 'sales_summary_csv',
|
|
||||||
reconciliation_status: 'categorized', // Sales data is pre-categorized
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
let split = {
|
|
||||||
category: cat,
|
|
||||||
description: desc,
|
|
||||||
amount: amount
|
|
||||||
};
|
|
||||||
transactionsToInsert.push({ tx, split });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addTx('Gross Sales', 'Income - Gross Sales', salesData['Gross sales'] || 0);
|
|
||||||
addTx('Returns', 'Income - Returns', salesData['Returns'] || 0);
|
|
||||||
addTx('Discounts & Comps', 'Expense - Discounts & Comps', salesData['Discounts & comps'] || 0);
|
|
||||||
addTx('Taxes Collected', 'Liability - Sales Tax', salesData['Taxes'] || 0);
|
|
||||||
addTx('Square Payment Processing Fees', 'Expense - Bank Fees', salesData['Square payment processing fees'] || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Parse files
|
const processBankCSV = (data) => {
|
||||||
try {
|
data.forEach(row => {
|
||||||
|
const date = new Date(row.Date);
|
||||||
|
const debit = parseFloat(row.Debit) || 0;
|
||||||
|
const credit = parseFloat(row.Credit) || 0;
|
||||||
|
const amount = credit - debit;
|
||||||
|
const description = row.Description ? row.Description.trim() : '';
|
||||||
|
const key = `${date.toISOString().slice(0, 10)}:${description}:${amount.toFixed(2)}`;
|
||||||
|
|
||||||
|
if (!isNaN(date) && amount !== 0) {
|
||||||
|
if (existingTxKeys.has(key)) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existingTxKeys.add(key);
|
||||||
|
|
||||||
|
let tx = {
|
||||||
|
date: date.toISOString(),
|
||||||
|
description: description,
|
||||||
|
amount: amount,
|
||||||
|
source: 'bank_csv',
|
||||||
|
reconciliation_status: 'uncategorized',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
let split = {
|
||||||
|
category: 'Expense - Uncategorized',
|
||||||
|
description: description,
|
||||||
|
amount: amount
|
||||||
|
};
|
||||||
|
applyCategorization(tx, split);
|
||||||
|
transactionsToInsert.push({ tx, split });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const processSalesCSV = (data, fileName) => {
|
||||||
|
let salesData = {};
|
||||||
|
const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/);
|
||||||
|
const txDate = dateMatch ? new Date(dateMatch[1]) : new Date();
|
||||||
|
|
||||||
|
const cleanCurrency = (val) => {
|
||||||
|
if (!val) return 0;
|
||||||
|
val = String(val).replace(/[$",]/g, '');
|
||||||
|
if (val.startsWith('(') && val.endsWith(')')) {
|
||||||
|
val = '-' + val.substring(1, val.length - 1);
|
||||||
|
}
|
||||||
|
return parseFloat(val) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
data.forEach(row => {
|
||||||
|
const key = (row[0] || '').replace(/"/g, '').trim();
|
||||||
|
const value = cleanCurrency(row[1]);
|
||||||
|
salesData[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const addTx = (desc, cat, amount) => {
|
||||||
|
if (amount !== 0) {
|
||||||
|
const key = `${txDate.toISOString().slice(0, 10)}:${desc}:${amount.toFixed(2)}`;
|
||||||
|
if (existingTxKeys.has(key)) {
|
||||||
|
skippedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existingTxKeys.add(key);
|
||||||
|
|
||||||
|
let tx = {
|
||||||
|
date: txDate.toISOString(),
|
||||||
|
description: desc,
|
||||||
|
amount: amount,
|
||||||
|
source: 'sales_summary_csv',
|
||||||
|
reconciliation_status: 'categorized',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
let split = {
|
||||||
|
category: cat,
|
||||||
|
description: desc,
|
||||||
|
amount: amount
|
||||||
|
};
|
||||||
|
transactionsToInsert.push({ tx, split });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addTx('Gross Sales', 'Income - Gross Sales', salesData['Gross sales'] || 0);
|
||||||
|
addTx('Returns', 'Income - Returns', salesData['Returns'] || 0);
|
||||||
|
addTx('Discounts & Comps', 'Expense - Discounts & Comps', salesData['Discounts & comps'] || 0);
|
||||||
|
addTx('Taxes Collected', 'Liability - Sales Tax', salesData['Taxes'] || 0);
|
||||||
|
addTx('Square Payment Processing Fees', 'Expense - Bank Fees', salesData['Square payment processing fees'] || 0);
|
||||||
|
};
|
||||||
|
|
||||||
if (req.files.bankFile) {
|
if (req.files.bankFile) {
|
||||||
const file = req.files.bankFile[0];
|
const file = req.files.bankFile[0];
|
||||||
const content = fs.readFileSync(file.path, 'utf8');
|
const content = fs.readFileSync(file.path, 'utf8');
|
||||||
@ -701,44 +718,44 @@ app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', ma
|
|||||||
processSalesCSV(results.data, file.originalname);
|
processSalesCSV(results.data, file.originalname);
|
||||||
fs.unlinkSync(file.path);
|
fs.unlinkSync(file.path);
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
|
||||||
console.error("File parse error:", parseError);
|
|
||||||
return res.status(500).send('Error parsing files.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Insert transactions and their default splits
|
if (transactionsToInsert.length === 0) {
|
||||||
if (transactionsToInsert.length === 0) {
|
return res.status(200).send(`No new transactions to insert. Skipped ${skippedCount} duplicate transactions.`);
|
||||||
return res.status(400).send('No valid transactions found to insert.');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const txStmt = db.prepare('INSERT INTO transactions (userId, date, description, amount, source, createdAt, reconciliation_status) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
const txStmt = db.prepare('INSERT INTO transactions (userId, date, description, amount, source, createdAt, reconciliation_status) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||||
const splitStmt = db.prepare('INSERT INTO transaction_splits (userId, transactionId, category, description, amount) VALUES (?, ?, ?, ?, ?)');
|
const splitStmt = db.prepare('INSERT INTO transaction_splits (userId, transactionId, category, description, amount) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run('BEGIN TRANSACTION');
|
|
||||||
|
|
||||||
transactionsToInsert.forEach(item => {
|
db.serialize(() => {
|
||||||
const { tx, split } = item;
|
db.run('BEGIN TRANSACTION');
|
||||||
txStmt.run(userId, tx.date, tx.description, tx.amount, tx.source, tx.createdAt, tx.reconciliation_status, function(err) {
|
|
||||||
|
transactionsToInsert.forEach(item => {
|
||||||
|
const { tx, split } = item;
|
||||||
|
txStmt.run(userId, tx.date, tx.description, tx.amount, tx.source, tx.createdAt, tx.reconciliation_status, function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Error inserting transaction:", err);
|
||||||
|
} else {
|
||||||
|
const transactionId = this.lastID;
|
||||||
|
splitStmt.run(userId, transactionId, split.category, split.description, split.amount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run('COMMIT', (err) => {
|
||||||
|
txStmt.finalize();
|
||||||
|
splitStmt.finalize();
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Error inserting transaction:", err);
|
console.error("DB commit error:", err);
|
||||||
} else {
|
return res.status(500).send('Error saving transactions.');
|
||||||
const transactionId = this.lastID;
|
|
||||||
splitStmt.run(userId, transactionId, split.category, split.description, split.amount);
|
|
||||||
}
|
}
|
||||||
|
res.status(201).send(`Successfully inserted ${transactionsToInsert.length} new transactions. Skipped ${skippedCount} duplicate transactions.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
db.run('COMMIT', (err) => {
|
} catch (error) {
|
||||||
txStmt.finalize();
|
console.error("Error processing upload:", error);
|
||||||
splitStmt.finalize();
|
res.status(500).send('Server error during file upload.');
|
||||||
if (err) {
|
}
|
||||||
console.error("DB commit error:", err);
|
|
||||||
return res.status(500).send('Error saving transactions.');
|
|
||||||
}
|
|
||||||
res.status(201).send(`Successfully inserted ${transactionsToInsert.length} transactions.`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Serve Frontend ---
|
// --- Serve Frontend ---
|
||||||
|
|||||||
BIN
simpleledger.db
BIN
simpleledger.db
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user