diff --git a/CLAUDE.md b/CLAUDE.md index 54f2344..7136156 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `controllers/` - Route handlers - `routes/` - Express route definitions - `middlewares/` - Custom middleware functions -- `views/` - EJS templates +- `views/` - EJS templatesa When working in this Express.js application, follow the existing patterns in controllers, models, and routes folders. Handle all form validation with express-validator and use the error handling middleware for consistent error responses. diff --git a/app.js b/app.js index e54a0fb..9c07003 100644 --- a/app.js +++ b/app.js @@ -20,6 +20,9 @@ const { pool } = require('./config/database'); const indexRoutes = require('./routes/index'); const authRoutes = require('./routes/auth'); const userRoutes = require('./routes/user'); +// New routes +const budgetRouter = require('./routes/budget'); +const transactionRouter = require('./routes/transaction'); // Import custom middleware const { setLocals } = require('./middlewares/locals'); @@ -27,6 +30,8 @@ const { handleErrors } = require('./middlewares/error-handler'); // Initialize Express app const app = express(); +// Tell Express it's behind a proxy (Render/Cloudflare), so secure cookies work +app.set('trust proxy', 1); // Test database connection on startup if (process.env.DATABASE_URL) { @@ -43,8 +48,11 @@ if (process.env.DATABASE_URL) { // Configure Express app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// data parsed as simple key-value pairs +app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static('public')); + // Set up EJS view engine app.set('view engine', 'ejs'); @@ -110,6 +118,9 @@ app.use(setLocals); app.use('/', indexRoutes); app.use('/auth', authRoutes); app.use('/user', userRoutes); +// new routes +app.use('/', budgetRouter); +app.use('/', transactionRouter); // Error handling middleware app.use(handleErrors); diff --git a/models/Category.js b/models/Category.js new file mode 100644 index 0000000..e358a96 --- /dev/null +++ b/models/Category.js @@ -0,0 +1,51 @@ +const { pool } = require('../config/database'); + +// Get all categories for a user +async function getCategoriesForUser(userId) { + const result = await pool.query( + ` + SELECT * + FROM categories + WHERE user_id = $1 + ORDER BY type, name + `, + [userId] + ); + return result.rows; +} + +// Create a new category +async function createCategory(userId, { name, type }) { + const result = await pool.query( + ` + INSERT INTO categories (user_id, name, type) + VALUES ($1, $2, $3) + RETURNING * + `, + [userId, name, type] + ); + return result.rows[0]; +} + +// seeder for first time users +async function seedDefaultCategoriesForUser(userId) { + const defaults = [ + { name: 'Salary', type: 'income' }, + { name: 'Freelance', type: 'income' }, + { name: 'Rent', type: 'expense' }, + { name: 'Food', type: 'expense' }, + { name: 'Utilities', type: 'expense' }, + { name: 'Savings', type: 'expense' }, + { name: 'Entertainment', type: 'expense' }, + ]; + + for (const c of defaults) { + await createCategory(userId, c); + } + } + +module.exports = { + getCategoriesForUser, + createCategory, + seedDefaultCategoriesForUser, +}; diff --git a/models/Transaction.js b/models/Transaction.js new file mode 100644 index 0000000..6658825 --- /dev/null +++ b/models/Transaction.js @@ -0,0 +1,98 @@ +const { pool } = require('../config/database'); + +async function getTransactionsForUser(userId) { + // Fetch transactions along with category names + + const result = await pool.query( + ` + SELECT t.*, c.name AS category_name + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.user_id = $1 + ORDER BY t.date DESC, t.created_at DESC + `, + [userId] + ); + return result.rows; +} +// Create a new transaction +async function createTransaction(userId, { categoryId, type, amount, description, date }) { + const result = await pool.query( + ` + INSERT INTO transactions (user_id, category_id, type, amount, description, date) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `, + [userId, categoryId || null, type, amount, description || null, date] + ); + return result.rows[0]; +} +// Get dashboard summary (total income, total expense, balance) +async function getDashboardSummary(userId) { + const result = await pool.query( + ` + SELECT + COALESCE(SUM(CASE WHEN type = 'income' THEN amount END), 0) AS total_income, + COALESCE(SUM(CASE WHEN type = 'expense' THEN amount END), 0) AS total_expense + FROM transactions + WHERE user_id = $1 + `, + [userId] + ); + + const row = result.rows[0]; + return { + totalIncome: Number(row.total_income), + totalExpense: Number(row.total_expense), + balance: Number(row.total_income) - Number(row.total_expense) + }; +} +// Get a single transaction by ID +async function getTransactionById(userId, transactionId) { + const result = await pool.query( + ` + SELECT t.*, c.name AS category_name + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.user_id = $1 AND t.id = $2 + `, + [userId, transactionId] + ); + return result.rows[0]; +} +// Update a transaction +async function updateTransaction(userId, transactionId, { amount, type, categoryId, description, date }) { + const result = await pool.query( + ` + UPDATE transactions + SET amount = $1, + type = $2, + category_id = $3, + description = $4, + date = $5 + WHERE id = $6 AND user_id = $7 + RETURNING * + `, + [amount, type, categoryId, description, date, transactionId, userId] + ); + return result.rows[0]; +} +// Delete a transaction +async function deleteTransaction(userId, transactionId) { + await pool.query( + ` + DELETE FROM transactions + WHERE id = $1 AND user_id = $2 + `, + [transactionId, userId] + ); +} + +module.exports = { + getTransactionsForUser, + createTransaction, + getDashboardSummary, + getTransactionById, + updateTransaction, + deleteTransaction, +}; diff --git a/package.json b/package.json index b21d016..7aff57a 100644 --- a/package.json +++ b/package.json @@ -20,16 +20,16 @@ "dependencies": { "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", - "csurf": "^1.11.0", - "dotenv": "^16.3.1", + "csurf": "^1.2.2", + "dotenv": "^16.6.1", "ejs": "^3.1.9", - "express": "^4.18.2", + "express": "^4.22.1", "express-session": "^1.18.1", "express-validator": "^7.0.1", "multer": "^1.4.5-lts.2", "pg": "^8.13.1" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.1.11" } } diff --git a/public/css/budget.css b/public/css/budget.css new file mode 100644 index 0000000..9fe07d3 --- /dev/null +++ b/public/css/budget.css @@ -0,0 +1,147 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + /* Updated: Soft yellow background */ + background-color: #FFF5E1; + color: #333; +} + +header { + /* Updated: Vibrant orange header background */ + background-color: #FF9900; + color: #FFFFFF; + text-align: center; + padding: 15px; +} + +.budget-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; +} + +section { + /* Updated: Lighter section background */ + background: #FFF5E1; + border: 1px solid #FFCC00; + padding: 10px; + margin: 10px 0; + width: 100%; + max-width: 800px; + box-sizing: border-box; + border-radius: 8px; +} + +/* Updated: Added text-align center to income-section */ +.income-section { + text-align: center; + /* Updated: Lighter section background */ + background: #FFF5E1; + border: 1px solid #FFCC00; + padding: 10px; + margin: 10px 0; + width: 100%; + max-width: 800px; + box-sizing: border-box; + border-radius: 8px; +} + +button { + /* Updated: Orange button background */ + background-color: #FF9900; + color: #FFFFFF; + border: none; + padding: 8px 15px; + cursor: pointer; + border-radius: 4px; + /* Added margin for spacing around buttons */ + margin: 5px; +} + +button:hover { + opacity: 0.8; +} + +.navbar { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 10px 20px; + border-bottom: 2px solid #ddd; + /* Updated: Darker orange/brown base */ + background-color: #A0522D; +} + +.navbar nav { + display: flex; + align-items: center; + gap: 20px; +} + +.nav-home { + position: absolute; + left: 20px; + text-decoration: none; + font-weight: 600; + font-size: 1.1rem; + /* Updated: Bright yellow link color */ + color: #FFCC00; +} + +.nav-home:hover { + text-decoration: underline; +} + +.navbar h1 { + margin: 0; +} + +ul { + list-style-type: none; + padding: 0; +/* Updated: Added text-align center to expense-inputs */ +.expense-inputs { + text-align: center; +} + +/* Added margin to inputs and select for better spacing when centered */ +input, select { + margin: 5px; +} + +/* Removes bullet points */ +main ul { + list-style-type: none; + padding-left: 0; /* Ensures the list items align with other content */ +} + +/* make the list items look cleaner */ +main ul li { + display: flex; + justify-content: space-between; /* Pushes the edit/delete buttons to the right */ + padding: 8px 0; + border-bottom: 1px solid #FFCC00; /* Use your existing border color */ + align-items: center; +} + +/* Style for the edit/delete*/ +main ul li a { + margin-right: 10px; + color: #A0522D; /* Use your existing navbar color for links */ + text-decoration: none; + font-size: 0.9em; +} + +main ul li button { + /* Inherit your existing button styles*/ + background-color: #DB4437; /* A distinct red for delete actions */ + color: white; + border: none; + padding: 5px 10px; + cursor: pointer; + border-radius: 4px; +} + diff --git a/public/css/style.css b/public/css/style.css index bd05cbb..e2222ff 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -8,21 +8,24 @@ } :root { - --primary-color: #3498db; - --primary-dark: #2980b9; - --secondary-color: #2ecc71; - --secondary-dark: #27ae60; - --text-color: #333; - --text-light: #666; - --background-color: #f5f5f5; - --border-color: #ddd; - --error-color: #e74c3c; - --success-color: #2ecc71; - --warning-color: #f39c12; - --white: #fff; + /* Orange and Brown Palette */ + --primary-color: #e67e22; + --primary-dark: #d35400; + --secondary-color: #A0522D; + --secondary-dark: #8B4513; + + --text-color: #333; + --text-light: #666; + --background-color: #f5f5f5; + --border-color: #ddd; + --error-color: #e74c3c; + --success-color: #2ecc71; + --warning-color: #f39c12; + --white: #fff; --shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } + body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; @@ -203,10 +206,20 @@ input:focus { background-color: var(--secondary-dark); } +.start-btn { + background-color: #e67e22; + color: var(--white); +} + +.start-btn:hover { + background-color: #d35400; +} + .action-buttons { display: flex; gap: 1rem; margin-top: 2rem; + justify-content: center; } /* @@ -299,12 +312,14 @@ input:focus { } .welcome-section h1 { + text-align: center; font-size: 2.5rem; margin-bottom: 1rem; color: var(--primary-color); } .features-section { + text-align: center; margin-top: 3rem; } @@ -315,6 +330,7 @@ input:focus { } .features-section h2:after { + text-align: center; content: ''; display: block; width: 50px; @@ -324,6 +340,7 @@ input:focus { } .feature-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; diff --git a/public/js/budget.js b/public/js/budget.js new file mode 100644 index 0000000..6a30ca9 --- /dev/null +++ b/public/js/budget.js @@ -0,0 +1,52 @@ +// Get elements from the page +const incomeInput = document.getElementById("income-input"); +const addIncomeButton = document.getElementById("add-income"); +const expenseAmountInput = document.getElementById("expense-amount"); +const expenseTypeSelect = document.getElementById("expense-type"); +const addExpenseButton = document.getElementById("add-expense"); +const expenseList = document.getElementById("expense-list"); +const totalIncomeDisplay = document.getElementById("total-income"); +const totalExpensesDisplay = document.getElementById("total-expenses"); +const balanceDisplay = document.getElementById("balance"); + +// Variables to keep track of totals +let totalIncome = 0; +let totalExpenses = 0; + +// When "Enter" is clicked for income +addIncomeButton.addEventListener("click", function() { + let income = parseFloat(incomeInput.value); + + // Check if the input is a valid number + if (!isNaN(income)) { + totalIncome += income; + totalIncomeDisplay.textContent = totalIncome; + incomeInput.value = ""; // clear box + updateBalance(); + } +}); + +// When "Add" is clicked for expense +addExpenseButton.addEventListener("click", function() { + let expense = parseFloat(expenseAmountInput.value); + let type = expenseTypeSelect.value; + + if (!isNaN(expense)) { + totalExpenses += expense; + totalExpensesDisplay.textContent = totalExpenses; + + // Add to the expense list + let listItem = document.createElement("li"); + listItem.textContent = type + ": $" + expense; + expenseList.appendChild(listItem); + + expenseAmountInput.value = ""; + updateBalance(); + } +}); + +// Function to update balance +function updateBalance() { + let balance = totalIncome - totalExpenses; + balanceDisplay.textContent = balance; +} diff --git a/routes/budget.js b/routes/budget.js new file mode 100644 index 0000000..0c6b44d --- /dev/null +++ b/routes/budget.js @@ -0,0 +1,38 @@ +// routes/budget.js + +const express = require('express'); +const router = express.Router(); + +const Transaction = require('../models/Transaction'); +const Category = require('../models/Category'); +const { isAuthenticated } = require('../middlewares/auth'); + +router.get('/budget', isAuthenticated, async (req, res, next) => { + try { + if (!req.session || !req.session.user) { + return res.redirect('/auth/login'); + } + + const userId = req.session.user.id; + console.log('HIT /budget route, userId =', userId); + + const [summary, transactions, categories] = await Promise.all([ + Transaction.getDashboardSummary(userId), + Transaction.getTransactionsForUser(userId), + Category.getCategoriesForUser(userId), + ]); + // If user has no categories yet, seed defaults once + if (!categories.length) { + await Category.seedDefaultCategoriesForUser(userId); + categories = await Category.getCategoriesForUser(userId); + } + + console.log('Summary from DB:', summary); + res.render('budget', { summary, transactions, categories }); + } catch (err) { + next(err); + } + }); + + +module.exports = router; diff --git a/routes/index.js b/routes/index.js index 8bbf05c..49fe7ca 100644 --- a/routes/index.js +++ b/routes/index.js @@ -9,7 +9,7 @@ const router = express.Router(); router.get('/', (req, res) => { res.render('index', { title: 'Home', - message: 'Welcome to the Authentication Template' + message: 'Welcome to the Budget Planner' }); }); @@ -21,4 +21,7 @@ router.get('/about', (req, res) => { }); }); + + + module.exports = router; \ No newline at end of file diff --git a/routes/transaction.js b/routes/transaction.js new file mode 100644 index 0000000..9dbe8d7 --- /dev/null +++ b/routes/transaction.js @@ -0,0 +1,120 @@ +// routes/transaction.js +const express = require('express'); +const router = express.Router(); + +const Transaction = require('../models/Transaction'); +const { isAuthenticated } = require('../middlewares/auth'); + +router.post('/transactions', isAuthenticated, async (req, res, next) => { + try { + console.log('Session in /transactions route:', req.session); + console.log('User in session:', req.session.user); + + const userId = req.session.user.id; // use session.user.id + const { amount, type, category_id, description, date } = req.body; + // Basic validation + if (!amount || isNaN(Number(amount))) { + return res.status(400).send('Invalid amount'); + } + if (type !== 'income' && type !== 'expense') { + return res.status(400).send('Invalid type'); + } + // Create the transaction + await Transaction.createTransaction(userId, { + categoryId: category_id || null, + type, + amount, + description, + date: date || new Date(), + }); + + res.redirect('/budget'); + } catch (err) { + next(err); + } +}); +// Delete a transaction +router.post('/transactions/:id/delete', isAuthenticated, async (req, res, next) => { + try { + // we need to get the user id from the session + const userId = req.session.user.id; + // and the transaction id from the route parameters + const transactionId = req.params.id; + // Safety: only delete rows belonging to this user + await Transaction.deleteTransaction(userId, transactionId); + res.redirect('/budget'); + } catch (err) { + next(err); + } +}); + +// edit logic + +// Show edit form for a transaction +router.get('/transactions/:id/edit', isAuthenticated, async (req, res, next) => { + try { + const userId = req.session.user.id; + const transactionId = req.params.id; + + const transaction = await Transaction.getTransactionById(userId, transactionId); + if (!transaction) { + return res.status(404).send('Transaction not found'); + } + + const categories = await require('../models/Category').getCategoriesForUser(userId); + + res.render('editTransaction', { transaction, categories }); + } catch (err) { + next(err); + } +}); + +// Handle edit form submission +router.post('/transactions/:id/edit', isAuthenticated, async (req, res, next) => { + try { + const userId = req.session.user.id; + const transactionId = req.params.id; + const { amount, type, category_id, description, date } = req.body; + + if (!amount || isNaN(Number(amount))) { + return res.status(400).send('Invalid amount'); + } + + await Transaction.updateTransaction(userId, transactionId, { + amount, + type, + categoryId: category_id || null, + description, + date: date || new Date(), + }); + + res.redirect('/budget'); + } catch (err) { + next(err); + } +}); + + +// Transaction deletion route +router.post('/transactions/delete/:id', isAuthenticated, async (req, res, next) => { + try { + const transactionId = req.params.id; + const userId = req.session.user.id; + + const deleted = await Transaction.deleteTransaction(transactionId, userId); + + if (deleted) { + // You might redirect back to the page they came from, or a default view + res.redirect('/login'); + } else { + // Transaction not found error + res.status(404).send('Transaction not found or unauthorized'); + } + + } catch (err) { + next(err); + } +}); + + +module.exports = router; diff --git a/scripts/init-db.js b/scripts/init-db.js index 42400d3..b96e319 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -35,6 +35,33 @@ const createTables = async () => { `); console.log('✓ Profile images table created'); + // Create categories table + await pool.query(` + CREATE TABLE IF NOT EXISTS categories ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('income', 'expense')) + ) + `); + console.log('✓ Categories table created'); + + // Create transactions table + await pool.query(` + CREATE TABLE IF NOT EXISTS transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES categories(id), + type TEXT NOT NULL CHECK (type IN ('income', 'expense')), + amount NUMERIC(10, 2) NOT NULL CHECK (amount >= 0), + description TEXT, + date DATE NOT NULL DEFAULT CURRENT_DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + console.log('✓ Transactions table created'); + + // Create session table for connect-pg-simple await pool.query(` CREATE TABLE IF NOT EXISTS "session" ( diff --git a/views/about.ejs b/views/about.ejs index 7514a62..6728a6b 100644 --- a/views/about.ejs +++ b/views/about.ejs @@ -3,9 +3,12 @@

About This Application

-

This is a starter template for a web application with user authentication features. It demonstrates:

+

A simple and secure budget planner that streamlines personal finance management. Users can easily register and log

+

in to access unique, practical tools designed to track spending habits. Effortlessly add total income, categorize expenses,

+

and instantly calculate your total balance, helping you stay in control of your financial picture every day.

-<%- include('./partials/footer') %> \ No newline at end of file +<%- include('./partials/footer') %> diff --git a/views/budget.ejs b/views/budget.ejs new file mode 100644 index 0000000..d47049b --- /dev/null +++ b/views/budget.ejs @@ -0,0 +1,121 @@ + + + + + + Budget Planner + + + + + + + <% + const safeSummary = (typeof summary !== 'undefined' && summary) + ? summary + : { totalIncome: 0, totalExpense: 0, balance: 0 }; + %> + +
+ +
+

Add Income

+
+ + + + + + +
+ +
+ + +
+

Expenses

+ +
+

Add Expense

+
+ + + + + + + + +
+ +

Recent Expenses

+
    + <% if (typeof transactions !== 'undefined' && transactions && transactions.length) { %> + <% transactions.forEach(t => { if (t.type === 'expense') { %> +
  • + $<%= t.amount %> – <%= t.category_name || 'Uncategorized' %> + + edit +
    + +
    +
  • + <% } }) %> + <% } else { %> +
  • No expenses yet
  • + <% } %> +
+
+ + +
+

Summary

+

Total Income: $<%= safeSummary.totalIncome %>

+

Total Expenses: $<%= safeSummary.totalExpense %>

+

Balance: $<%= safeSummary.balance %>

+
+ +
+ + + + diff --git a/views/design/Budget Planner Create Account.png b/views/design/Budget Planner Create Account.png new file mode 100644 index 0000000..ada24ad Binary files /dev/null and b/views/design/Budget Planner Create Account.png differ diff --git a/views/design/Budget Planner Login.png b/views/design/Budget Planner Login.png new file mode 100644 index 0000000..4097c14 Binary files /dev/null and b/views/design/Budget Planner Login.png differ diff --git a/views/design/Budget Planner add value.png b/views/design/Budget Planner add value.png new file mode 100644 index 0000000..a5a7bc5 Binary files /dev/null and b/views/design/Budget Planner add value.png differ diff --git a/views/design/Budget Planner dashboard.png b/views/design/Budget Planner dashboard.png new file mode 100644 index 0000000..5ea6d8b Binary files /dev/null and b/views/design/Budget Planner dashboard.png differ diff --git a/views/editTransaction.ejs b/views/editTransaction.ejs new file mode 100644 index 0000000..06074ab --- /dev/null +++ b/views/editTransaction.ejs @@ -0,0 +1,50 @@ + + + + + Edit Transaction + + + +

Edit Transaction

+ +
+ + + + + + + + + + + + Cancel +
+ + diff --git a/views/index.ejs b/views/index.ejs index f098628..937752d 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -3,16 +3,18 @@

<%= message %>

-

A simple authentication system for web applications

+

A simple Budget Planner for web applications

<% if (isAuthenticated) { %> <% } else { %> <% } %>
@@ -21,16 +23,16 @@

Features

-

User Authentication

-

Secure login and registration with password hashing

+

Allocation

+

Setting and tracking budgets per category

-

Session Management

-

Persistent sessions with secure cookies

+

Progress

+

Monitoring savgings and investment goals

-

Protected Routes

-

Restrict access to authenticated users only

+

Summary

+

Monthly totals

diff --git a/views/layouts/index.html b/views/layouts/index.html new file mode 100644 index 0000000..11ab352 --- /dev/null +++ b/views/layouts/index.html @@ -0,0 +1,60 @@ + + + + + + Budget Planner + + + + +
+

Budget Planner

+
+ +
+ + +
+

Enter Amount of Income

+ + +
+ + +
+

Expenses

+
+ + + + +
+ +

List of Expenses

+ +
+ + +
+

Budget Summary

+

Total Income: $0

+

Total Expenses: $0

+

Balance: $0

+
+ +
+ + + + + + + diff --git a/views/layouts/main.html b/views/layouts/main.html index b2ad94d..e6fce9e 100644 --- a/views/layouts/main.html +++ b/views/layouts/main.html @@ -3,14 +3,14 @@ - {{title}} | Authentication Template + {{title}} | Budget Planner