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:
parent
4fd8a3350d
commit
0ae3bda8be
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
simpleledger.db
|
||||||
|
debug.log
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
10
docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
volumes:
|
||||||
|
- ./ledger_db:/usr/src/app/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ledger_db:
|
||||||
765
index.html
765
index.html
File diff suppressed because it is too large
Load Diff
367
server.js
367
server.js
@ -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'],
|
||||||
|
'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 tx;
|
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.`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -541,5 +749,4 @@ app.get('*', (req, res) => {
|
|||||||
// --- Start Server ---
|
// --- Start Server ---
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`SimpleLedger server running at http://localhost:${port}`);
|
console.log(`SimpleLedger server running at http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user