feat: Dockerize application and add features

- Add Dockerfile, .dockerignore, and docker-compose.yml to containerize the application.

- Update server.js to use a data directory for the database.

- Add SweetAlert for improved user experience.

- Add date filtering to the transactions page.

- Fix bug with saving split transactions.
This commit is contained in:
chris 2025-11-05 11:56:16 -05:00
parent 4fd8a3350d
commit 0ae3bda8be
6 changed files with 853 additions and 316 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
debug.log
.git
.gitignore

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
simpleledger.db
debug.log

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# Use an official Node.js runtime as a parent image
FROM node:18-alpine
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install app dependencies
RUN npm install
# Copy the rest of the application files to the working directory
COPY . .
# Make port 3000 available to the world outside this container
EXPOSE 3000
# Define the command to run your app
CMD [ "node", "server.js" ]

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
services:
app:
build: .
ports:
- "3001:3000"
volumes:
- ./ledger_db:/usr/src/app/data
volumes:
ledger_db:

File diff suppressed because it is too large Load Diff

367
server.js
View File

@ -11,7 +11,7 @@ const fs = require('fs');
// --- Config --- // --- Config ---
const app = express(); const app = express();
const port = 3000; const port = 3000;
const dbFile = './simpleledger.db'; const dbFile = './data/simpleledger.db';
const JWT_SECRET = 'your-super-secret-key-change-this!'; // CHANGE THIS const JWT_SECRET = 'your-super-secret-key-change-this!'; // CHANGE THIS
const upload = multer({ dest: 'uploads/' }); const upload = multer({ dest: 'uploads/' });
@ -62,18 +62,31 @@ function initDb() {
FOREIGN KEY(userId) REFERENCES users(id) FOREIGN KEY(userId) REFERENCES users(id)
)`); )`);
// --- MODIFIED: transactions is now a parent table ---
db.run(`CREATE TABLE IF NOT EXISTS transactions ( db.run(`CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER, userId INTEGER,
date TEXT, date TEXT,
description TEXT, description TEXT,
amount REAL, amount REAL,
category TEXT,
vendorId INTEGER,
source TEXT, source TEXT,
createdAt TEXT, createdAt TEXT,
reconciliation_status TEXT DEFAULT 'uncategorized',
FOREIGN KEY(userId) REFERENCES users(id)
)`);
// --- NEW: transaction_splits table ---
db.run(`CREATE TABLE IF NOT EXISTS transaction_splits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER,
transactionId INTEGER,
category TEXT,
vendorId INTEGER,
description TEXT,
amount REAL,
FOREIGN KEY(userId) REFERENCES users(id), FOREIGN KEY(userId) REFERENCES users(id),
FOREIGN KEY(vendorId) REFERENCES vendors(id) FOREIGN KEY(transactionId) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY(vendorId) REFERENCES vendors(id) ON DELETE SET NULL
)`); )`);
}); });
} }
@ -95,6 +108,7 @@ function authenticateToken(req, res, next) {
// === AUTH ENDPOINTS === // === AUTH ENDPOINTS ===
app.post('/api/register', async (req, res) => { app.post('/api/register', async (req, res) => {
// ... (This endpoint is unchanged from the previous version) ...
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
return res.status(400).send('Username and password are required.'); return res.status(400).send('Username and password are required.');
@ -130,7 +144,8 @@ app.post('/api/register', async (req, res) => {
{ name: 'Expense - Taxes & Licenses', type: 'expense' }, { name: 'Expense - Taxes & Licenses', type: 'expense' },
{ name: 'Expense - Utilities', type: 'expense' }, { name: 'Expense - Utilities', type: 'expense' },
{ name: 'Expense - Vehicle', type: 'expense' }, { name: 'Expense - Vehicle', type: 'expense' },
{ name: 'Liability - Sales Tax', type: 'liability' } { name: 'Liability - Sales Tax', type: 'liability' },
{ name: 'Liability - Owner\'s Draw', type: 'liability' }
]; ];
const stmt = db.prepare('INSERT INTO categories (userId, name, type) VALUES (?, ?, ?)'); const stmt = db.prepare('INSERT INTO categories (userId, name, type) VALUES (?, ?, ?)');
@ -143,10 +158,7 @@ app.post('/api/register', async (req, res) => {
stmt.finalize(); stmt.finalize();
if (commitErr) { if (commitErr) {
console.error("Error populating default categories:", commitErr); console.error("Error populating default categories:", commitErr);
// User was created, but categories failed.
// Still send success for registration but log the error.
} }
// Send success response AFTER all DB operations
res.status(201).send('User created. Please log in.'); res.status(201).send('User created. Please log in.');
}); });
}); });
@ -158,6 +170,7 @@ app.post('/api/register', async (req, res) => {
}); });
app.post('/api/login', (req, res) => { app.post('/api/login', (req, res) => {
// ... (This endpoint is unchanged) ...
const { username, password } = req.body; const { username, password } = req.body;
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => { db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
@ -183,6 +196,7 @@ app.post('/api/login', (req, res) => {
// --- Categories --- // --- Categories ---
app.get('/api/categories', authenticateToken, (req, res) => { app.get('/api/categories', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
db.all('SELECT * FROM categories WHERE userId = ? ORDER BY name', [req.user.id], (err, rows) => { db.all('SELECT * FROM categories WHERE userId = ? ORDER BY name', [req.user.id], (err, rows) => {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
res.json(rows); res.json(rows);
@ -190,6 +204,7 @@ app.get('/api/categories', authenticateToken, (req, res) => {
}); });
app.post('/api/categories', authenticateToken, (req, res) => { app.post('/api/categories', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
const { name, type } = req.body; const { name, type } = req.body;
db.run('INSERT INTO categories (userId, name, type) VALUES (?, ?, ?)', [req.user.id, name, type], function(err) { db.run('INSERT INTO categories (userId, name, type) VALUES (?, ?, ?)', [req.user.id, name, type], function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
@ -198,6 +213,7 @@ app.post('/api/categories', authenticateToken, (req, res) => {
}); });
app.delete('/api/categories/:id', authenticateToken, (req, res) => { app.delete('/api/categories/:id', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
db.run('DELETE FROM categories WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) { db.run('DELETE FROM categories WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
if (this.changes === 0) return res.status(404).send('Category not found or user unauthorized.'); if (this.changes === 0) return res.status(404).send('Category not found or user unauthorized.');
@ -207,6 +223,7 @@ app.delete('/api/categories/:id', authenticateToken, (req, res) => {
// --- Vendors --- // --- Vendors ---
app.get('/api/vendors', authenticateToken, (req, res) => { app.get('/api/vendors', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
db.all('SELECT * FROM vendors WHERE userId = ? ORDER BY name', [req.user.id], (err, rows) => { db.all('SELECT * FROM vendors WHERE userId = ? ORDER BY name', [req.user.id], (err, rows) => {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
res.json(rows); res.json(rows);
@ -214,6 +231,7 @@ app.get('/api/vendors', authenticateToken, (req, res) => {
}); });
app.post('/api/vendors', authenticateToken, (req, res) => { app.post('/api/vendors', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
const { name } = req.body; const { name } = req.body;
if (!name) return res.status(400).send('Vendor name is required.'); if (!name) return res.status(400).send('Vendor name is required.');
@ -229,8 +247,7 @@ app.post('/api/vendors', authenticateToken, (req, res) => {
}); });
app.delete('/api/vendors/:id', authenticateToken, (req, res) => { app.delete('/api/vendors/:id', authenticateToken, (req, res) => {
// TODO: We should check if this vendor is associated with any transactions // ... (This endpoint is unchanged) ...
// For now, we'll just delete.
db.run('DELETE FROM vendors WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) { db.run('DELETE FROM vendors WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
if (this.changes === 0) return res.status(404).send('Vendor not found or user unauthorized.'); if (this.changes === 0) return res.status(404).send('Vendor not found or user unauthorized.');
@ -241,6 +258,7 @@ app.delete('/api/vendors/:id', authenticateToken, (req, res) => {
// --- Rules --- // --- Rules ---
app.get('/api/rules', authenticateToken, (req, res) => { app.get('/api/rules', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
db.all('SELECT * FROM rules WHERE userId = ?', [req.user.id], (err, rows) => { db.all('SELECT * FROM rules WHERE userId = ?', [req.user.id], (err, rows) => {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
res.json(rows); res.json(rows);
@ -248,6 +266,7 @@ app.get('/api/rules', authenticateToken, (req, res) => {
}); });
app.post('/api/rules', authenticateToken, (req, res) => { app.post('/api/rules', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
const { keyword, category } = req.body; const { keyword, category } = req.body;
db.run('INSERT INTO rules (userId, keyword, category) VALUES (?, ?, ?)', [req.user.id, keyword, category], function(err) { db.run('INSERT INTO rules (userId, keyword, category) VALUES (?, ?, ?)', [req.user.id, keyword, category], function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
@ -255,12 +274,11 @@ app.post('/api/rules', authenticateToken, (req, res) => {
}); });
}); });
// *** NEW ENDPOINT *** // --- MODIFIED: apply-all now updates splits ---
app.post('/api/rules/apply-all', authenticateToken, async (req, res) => { app.post('/api/rules/apply-all', authenticateToken, async (req, res) => {
const userId = req.user.id; const userId = req.user.id;
try { try {
// 1. Get all rules for the user
const rules = await new Promise((resolve, reject) => { const rules = await new Promise((resolve, reject) => {
db.all('SELECT * FROM rules WHERE userId = ?', [userId], (err, rows) => { db.all('SELECT * FROM rules WHERE userId = ?', [userId], (err, rows) => {
if (err) reject(err); if (err) reject(err);
@ -268,32 +286,36 @@ app.post('/api/rules/apply-all', authenticateToken, async (req, res) => {
}); });
}); });
if (rules.length === 0) { if (rules.length === 0) return res.status(200).send('No rules to apply.');
return res.status(200).send('No rules to apply.');
}
// 2. Get all uncategorized transactions // Get all transactions that are uncategorized and have only one split
const uncategorizedTransactions = await new Promise((resolve, reject) => { const transactionsToUpdate = await new Promise((resolve, reject) => {
db.all("SELECT * FROM transactions WHERE userId = ? AND (category = 'Uncategorized' OR category = 'Expense - Uncategorized')", [userId], (err, rows) => { db.all(`
SELECT t.id as transactionId, s.id as splitId, t.description
FROM transactions t
JOIN transaction_splits s ON t.id = s.transactionId
WHERE t.userId = ? AND t.reconciliation_status = 'uncategorized'
`, [userId], (err, rows) => {
if (err) reject(err); if (err) reject(err);
resolve(rows); resolve(rows);
}); });
}); });
if (uncategorizedTransactions.length === 0) { if (transactionsToUpdate.length === 0) {
return res.status(200).send('No uncategorized transactions to update.'); return res.status(200).send('No uncategorized transactions to update.');
} }
// 3. Apply rules and update
let updatedCount = 0; let updatedCount = 0;
const updateStmt = db.prepare('UPDATE transactions SET category = ? WHERE id = ?'); const updateSplitStmt = db.prepare('UPDATE transaction_splits SET category = ? WHERE id = ?');
const updateTxStmt = db.prepare("UPDATE transactions SET reconciliation_status = 'categorized' WHERE id = ?");
db.run('BEGIN TRANSACTION'); db.run('BEGIN TRANSACTION');
for (const tx of uncategorizedTransactions) { for (const tx of transactionsToUpdate) {
const desc = tx.description.toUpperCase(); const desc = tx.description.toUpperCase();
for (const rule of rules) { for (const rule of rules) {
if (desc.includes(rule.keyword.toUpperCase())) { if (desc.includes(rule.keyword.toUpperCase())) {
updateStmt.run(rule.category, tx.id); updateSplitStmt.run(rule.category, tx.splitId);
updateTxStmt.run(tx.transactionId);
updatedCount++; updatedCount++;
break; // Stop after first matching rule break; // Stop after first matching rule
} }
@ -304,8 +326,8 @@ app.post('/api/rules/apply-all', authenticateToken, async (req, res) => {
console.error("Error committing rule updates:", err); console.error("Error committing rule updates:", err);
return res.status(500).send('Error updating transactions.'); return res.status(500).send('Error updating transactions.');
} }
updateSplitStmt.finalize();
updateStmt.finalize(); updateTxStmt.finalize();
res.status(200).send(`Successfully updated ${updatedCount} transactions.`); res.status(200).send(`Successfully updated ${updatedCount} transactions.`);
}); });
@ -314,9 +336,9 @@ app.post('/api/rules/apply-all', authenticateToken, async (req, res) => {
res.status(500).send('Server error while applying rules.'); res.status(500).send('Server error while applying rules.');
} }
}); });
// *** END NEW ENDPOINT ***
app.delete('/api/rules/:id', authenticateToken, (req, res) => { app.delete('/api/rules/:id', authenticateToken, (req, res) => {
// ... (This endpoint is unchanged) ...
db.run('DELETE FROM rules WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) { db.run('DELETE FROM rules WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
if (this.changes === 0) return res.status(404).send('Rule not found or user unauthorized.'); if (this.changes === 0) return res.status(404).send('Rule not found or user unauthorized.');
@ -325,34 +347,154 @@ app.delete('/api/rules/:id', authenticateToken, (req, res) => {
}); });
// --- Transactions --- // --- Transactions ---
// --- MODIFIED: /api/transactions now returns summary data ---
app.get('/api/transactions', authenticateToken, (req, res) => { app.get('/api/transactions', authenticateToken, (req, res) => {
db.all('SELECT t.*, v.name as vendorName FROM transactions t LEFT JOIN vendors v ON t.vendorId = v.id WHERE t.userId = ? ORDER BY date DESC', [req.user.id], (err, rows) => { // This query is now more complex. It joins transactions with their splits
// to get a split_count and the category (if only one split exists).
const query = `
SELECT
t.id,
t.date,
t.description,
t.amount,
t.reconciliation_status,
COUNT(s.id) as split_count,
MAX(CASE WHEN (SELECT COUNT(*) FROM transaction_splits s_inner WHERE s_inner.transactionId = t.id) = 1 THEN s.category ELSE NULL END) as category,
MAX(CASE WHEN (SELECT COUNT(*) FROM transaction_splits s_inner WHERE s_inner.transactionId = t.id) = 1 THEN v.name ELSE NULL END) as vendorName
FROM transactions t
LEFT JOIN transaction_splits s ON t.id = s.transactionId
LEFT JOIN vendors v ON s.vendorId = v.id
WHERE t.userId = ?
GROUP BY t.id
ORDER BY t.date DESC
`;
db.all(query, [req.user.id], (err, rows) => {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
// Convert dates from text
rows.forEach(r => r.date = new Date(r.date)); rows.forEach(r => r.date = new Date(r.date));
res.json(rows); res.json(rows);
}); });
}); });
app.put('/api/transactions/:id', authenticateToken, (req, res) => { // --- NEW: /api/transactions/:id/splits (GET) ---
const { date, description, amount, category, vendorId } = req.body; // Gets the detailed splits for a single transaction (for the modal)
app.get('/api/transactions/:id/splits', authenticateToken, (req, res) => {
if (!date || !description || isNaN(parseFloat(amount)) || !category) { const query = `
return res.status(400).send('Missing required fields.'); SELECT s.*, v.name as vendorName
} FROM transaction_splits s
LEFT JOIN vendors v ON s.vendorId = v.id
db.run(`UPDATE transactions WHERE s.userId = ? AND s.transactionId = ?
SET date = ?, description = ?, amount = ?, category = ?, vendorId = ? `;
WHERE id = ? AND userId = ?`, db.all(query, [req.user.id, req.params.id], (err, rows) => {
[date, description, amount, category, vendorId || null, req.params.id, req.user.id],
function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
if (this.changes === 0) return res.status(404).send('Transaction not found or user unauthorized.'); res.json(rows);
res.sendStatus(200);
}); });
}); });
// --- NEW: /api/transactions/:id/splits (PUT) ---
// Saves the new set of splits from the modal
app.put('/api/transactions/:id/splits', authenticateToken, (req, res) => {
const log = (msg) => require('fs').appendFileSync('debug.log', `[${new Date().toISOString()}] ${msg}\n`);
log('--- New request to PUT /api/transactions/:id/splits ---');
const userId = req.user.id;
const transactionId = parseInt(req.params.id, 10);
const splits = req.body;
log(`userId: ${userId}, transactionId: ${transactionId}`);
log(`splits payload: ${JSON.stringify(splits, null, 2)}`);
if (isNaN(transactionId)) {
log('Error: Invalid transaction ID.');
return res.status(400).send('Invalid transaction ID.');
}
if (!splits || !Array.isArray(splits) || splits.length === 0) {
log('Error: Valid splits array is required.');
return res.status(400).send('Valid splits array is required.');
}
db.get('SELECT amount FROM transactions WHERE id = ? AND userId = ?', [transactionId, userId], (err, tx) => {
if (err) {
log(`Error getting transaction: ${err.message}`);
return res.status(500).send('Server error.');
}
if (!tx) {
log('Error: Transaction not found.');
return res.status(404).send('Transaction not found.');
}
const splitSum = splits.reduce((sum, s) => sum + parseFloat(s.amount), 0);
if (Math.abs(splitSum - tx.amount) > 0.001) {
log(`Error: Split amounts do not match transaction total. splitSum: ${splitSum}, tx.amount: ${tx.amount}`);
return res.status(400).send(`Split amounts do not match transaction total.`);
}
db.run('BEGIN TRANSACTION', (err) => {
if (err) {
log(`Error beginning transaction: ${err.message}`);
return res.status(500).send('Error saving splits.');
}
db.run('DELETE FROM transaction_splits WHERE userId = ? AND transactionId = ?', [userId, transactionId], function(err) {
if (err) {
log(`Error deleting splits: ${err.message}`);
return db.run('ROLLBACK');
}
log(`Deleted ${this.changes} old splits.`);
const insertStmt = db.prepare('INSERT INTO transaction_splits (userId, transactionId, category, vendorId, description, amount) VALUES (?, ?, ?, ?, ?, ?)');
function insertSplit(index) {
if (index >= splits.length) {
log('All splits inserted.');
db.run("UPDATE transactions SET reconciliation_status = 'categorized' WHERE id = ?", [transactionId], (err) => {
if (err) {
log(`Error updating transaction status: ${err.message}`);
return db.run('ROLLBACK');
}
db.run('COMMIT', (commitErr) => {
insertStmt.finalize();
if (commitErr) {
log(`Error committing transaction: ${commitErr.message}`);
return res.status(500).send('Error saving splits.');
}
log('Transaction committed successfully.');
res.sendStatus(200);
});
});
return;
}
const s = splits[index];
const vendorId = s.vendorId ? parseInt(s.vendorId, 10) : null;
const description = s.description ? s.description.trim() : '';
log(`Inserting split ${index + 1}/${splits.length}: ${JSON.stringify(s)}`);
insertStmt.run(userId, transactionId, s.category, vendorId, description, s.amount, (err) => {
if (err) {
log(`Error inserting split: ${err.message}`);
db.run('ROLLBACK', () => {
insertStmt.finalize();
res.status(500).send('Error saving splits.');
});
} else {
insertSplit(index + 1);
}
});
}
insertSplit(0);
});
});
});
});
// --- MODIFIED: DELETE now triggers ON DELETE CASCADE ---
app.delete('/api/transactions/:id', authenticateToken, (req, res) => { app.delete('/api/transactions/:id', authenticateToken, (req, res) => {
// Thanks to 'ON DELETE CASCADE' in the database schema,
// deleting the parent transaction will automatically delete all its splits.
db.run('DELETE FROM transactions WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) { db.run('DELETE FROM transactions WHERE id = ? AND userId = ?', [req.params.id, req.user.id], function(err) {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
if (this.changes === 0) return res.status(404).send('Transaction not found or user unauthorized.'); if (this.changes === 0) return res.status(404).send('Transaction not found or user unauthorized.');
@ -361,6 +503,7 @@ app.delete('/api/transactions/:id', authenticateToken, (req, res) => {
}); });
// --- Dashboard --- // --- Dashboard ---
// --- MODIFIED: Dashboard queries now read from splits table ---
app.get('/api/dashboard-summary', authenticateToken, (req, res) => { app.get('/api/dashboard-summary', authenticateToken, (req, res) => {
const userId = req.user.id; const userId = req.user.id;
let summary = { let summary = {
@ -368,17 +511,18 @@ app.get('/api/dashboard-summary', authenticateToken, (req, res) => {
pieData: {} pieData: {}
}; };
// --- FIX: Only 'expense' should count as totalExpenses. 'liability' should not. ---
const statsQuery = ` const statsQuery = `
SELECT SELECT
(SELECT SUM(amount) FROM transactions WHERE userId = ? AND amount > 0 AND category LIKE 'Income%') as totalIncome, (SELECT SUM(s.amount) FROM transaction_splits s JOIN categories c ON s.category = c.name WHERE s.userId = ? AND c.type = 'income' AND s.amount > 0) as totalIncome,
(SELECT SUM(amount) FROM transactions WHERE userId = ? AND amount < 0 AND category LIKE 'Income%') as totalReturns, (SELECT SUM(s.amount) FROM transaction_splits s JOIN categories c ON s.category = c.name WHERE s.userId = ? AND c.type = 'income' AND s.amount < 0) as totalReturns,
(SELECT SUM(amount) FROM transactions WHERE userId = ? AND amount < 0 AND category LIKE 'Expense%') as totalExpenses (SELECT SUM(s.amount) FROM transaction_splits s JOIN categories c ON s.category = c.name WHERE s.userId = ? AND c.type = 'expense') as totalExpenses
`; `;
const pieQuery = ` const pieQuery = `
SELECT category, SUM(amount) as total SELECT category, SUM(amount) as total
FROM transactions FROM transaction_splits
WHERE userId = ? AND amount < 0 AND category LIKE 'Expense%' WHERE userId = ? AND amount < 0
GROUP BY category GROUP BY category
ORDER BY total ASC ORDER BY total ASC
LIMIT 10 LIMIT 10
@ -388,7 +532,7 @@ app.get('/api/dashboard-summary', authenticateToken, (req, res) => {
if (err) return res.status(500).send(err); if (err) return res.status(500).send(err);
summary.stats = { summary.stats = {
totalIncome: stats.totalIncome || 0, totalIncome: stats.totalIncome || 0,
totalReturns: stats.totalReturns || 0, totalReturns: stats.totalReturns || 0, // This includes 'Discounts & Comps' if categorized as 'income' (negative)
totalExpenses: stats.totalExpenses || 0 totalExpenses: stats.totalExpenses || 0
}; };
@ -404,11 +548,10 @@ app.get('/api/dashboard-summary', authenticateToken, (req, res) => {
}); });
// --- File Upload & Processing --- // --- File Upload & Processing ---
// --- MODIFIED: Upload now creates a transaction AND a default split ---
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;
let transactionsToInsert = [];
// 1. Get current rules from DB
const rules = await new Promise((resolve, reject) => { const rules = await new Promise((resolve, reject) => {
db.all('SELECT * FROM rules WHERE userId = ?', [userId], (err, rows) => { db.all('SELECT * FROM rules WHERE userId = ?', [userId], (err, rows) => {
if (err) reject(err); if (err) reject(err);
@ -416,40 +559,85 @@ app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', ma
}); });
}); });
// Helper: Apply categorization rules let transactionsToInsert = []; // This will hold { tx, split } objects
const applyCategorizationRules = (tx) => {
if (tx.category && tx.category !== 'Uncategorized') return tx; // --- NEW: High-confidence "smart guess" logic ---
const desc = tx.description.toUpperCase(); const smartGuessCategory = (description) => {
for (const rule of rules) { const desc = description.toUpperCase();
if (desc.includes(rule.keyword.toUpperCase())) { const guesses = {
tx.category = rule.category; 'Expense - Payroll': ['PAYROLL', 'ADP '],
return tx; 'Expense - Rent/Lease': ['RENT', 'COURTYARD OF ACH'],
} 'Expense - Utilities': ['UINET', 'COMCAST', 'VERIZON', 'ELECTRIC', 'POWER', 'GAS '],
} 'Expense - Vehicle': ['CARWASH', 'AUTOZONE', 'PEP BOYS', 'GASOLINE', 'EXXON', 'MOBIL', 'RSCARWASH'],
return tx; 'Expense - Office Supplies': ['STAPLES', 'OFFICE DEPOT', 'OFFICEMAX', 'WAL MART', 'MAUI INC'],
'Expense - Software/Subscriptions': ['OPENAI', 'CHATGPT', 'GOOGLE *', 'MSFT *', 'ADOBE'],
'Expense - Meals & Entertainment': ['RESTAURANT', 'DOORDASH', 'UBER EATS', 'GRUBHUB'],
'Expense - Bank Fees': ['MAINTENANCE FEE', 'SQUARE PAYMENT PROCESSING'],
'Liability - Owner\'s Draw': ['PERSONAL', 'TRANSFER TO SELF'] // Added for owner's draw
};
for (const [category, keywords] of Object.entries(guesses)) {
for (const keyword of keywords) {
if (desc.includes(keyword)) {
return category; // Found a confident match
}
}
}
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 guess = smartGuessCategory(tx.description);
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'
}; };
// Helper: Process Bank CSV
const processBankCSV = (data) => { const processBankCSV = (data) => {
data.forEach(row => { data.forEach(row => {
const date = new Date(row.Date); const date = new Date(row.Date);
const debit = parseFloat(row.Debit) || 0; const debit = parseFloat(row.Debit) || 0;
const credit = parseFloat(row.Credit) || 0; const credit = parseFloat(row.Credit) || 0;
const amount = credit - debit; const amount = credit - debit;
const description = row.Description ? row.Description.trim() : '';
if (!isNaN(date) && amount !== 0) { if (!isNaN(date) && amount !== 0) {
transactionsToInsert.push(applyCategorizationRules({ let tx = {
date: date, date: date.toISOString(),
description: row.Description, description: description,
amount: amount, amount: amount,
category: 'Uncategorized',
source: 'bank_csv', 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 });
} }
}); });
}; };
// Helper: Process Sales CSV
const processSalesCSV = (data, fileName) => { const processSalesCSV = (data, fileName) => {
let salesData = {}; let salesData = {};
const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/); const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/);
@ -472,13 +660,20 @@ app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', ma
const addTx = (desc, cat, amount) => { const addTx = (desc, cat, amount) => {
if (amount !== 0) { if (amount !== 0) {
transactionsToInsert.push(applyCategorizationRules({ let tx = {
date: txDate, date: txDate.toISOString(),
description: desc, description: desc,
amount: amount, amount: amount,
category: cat,
source: 'sales_summary_csv', 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 });
} }
}; };
@ -496,7 +691,7 @@ app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', ma
const content = fs.readFileSync(file.path, 'utf8'); const content = fs.readFileSync(file.path, 'utf8');
const results = Papa.parse(content, { header: true, skipEmptyLines: true }); const results = Papa.parse(content, { header: true, skipEmptyLines: true });
processBankCSV(results.data); processBankCSV(results.data);
fs.unlinkSync(file.path); // Clean up fs.unlinkSync(file.path);
} }
if (req.files.salesFile) { if (req.files.salesFile) {
@ -504,30 +699,43 @@ app.post('/api/upload', authenticateToken, upload.fields([{ name: 'bankFile', ma
const content = fs.readFileSync(file.path, 'utf8'); const content = fs.readFileSync(file.path, 'utf8');
const results = Papa.parse(content, { header: false, skipEmptyLines: true }); const results = Papa.parse(content, { header: false, skipEmptyLines: true });
processSalesCSV(results.data, file.originalname); processSalesCSV(results.data, file.originalname);
fs.unlinkSync(file.path); // Clean up fs.unlinkSync(file.path);
} }
} catch (parseError) { } catch (parseError) {
console.error("File parse error:", parseError); console.error("File parse error:", parseError);
return res.status(500).send('Error parsing files.'); return res.status(500).send('Error parsing files.');
} }
// 3. Insert transactions into DB // 3. Insert transactions and their default splits
if (transactionsToInsert.length === 0) { if (transactionsToInsert.length === 0) {
return res.status(400).send('No valid transactions found to insert.'); return res.status(400).send('No valid transactions found to insert.');
} }
const stmt = db.prepare('INSERT INTO transactions (userId, date, description, amount, category, source, createdAt) 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 (?, ?, ?, ?, ?)');
db.serialize(() => { db.serialize(() => {
db.run('BEGIN TRANSACTION'); db.run('BEGIN TRANSACTION');
transactionsToInsert.forEach(tx => {
stmt.run(userId, tx.date.toISOString(), tx.description, tx.amount, tx.category, tx.source, new Date().toISOString()); 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) => { db.run('COMMIT', (err) => {
txStmt.finalize();
splitStmt.finalize();
if (err) { if (err) {
console.error("DB commit error:", err); console.error("DB commit error:", err);
return res.status(500).send('Error saving transactions.'); return res.status(500).send('Error saving transactions.');
} }
stmt.finalize();
res.status(201).send(`Successfully inserted ${transactionsToInsert.length} transactions.`); res.status(201).send(`Successfully inserted ${transactionsToInsert.length} transactions.`);
}); });
}); });
@ -542,4 +750,3 @@ app.get('*', (req, res) => {
app.listen(port, () => { app.listen(port, () => {
console.log(`SimpleLedger server running at http://localhost:${port}`); console.log(`SimpleLedger server running at http://localhost:${port}`);
}); });