diff --git a/financial-tracker/api-gateway/app/app.py b/financial-tracker/api-gateway/app/app.py index 9d86bcea4c2c51aa2d36c82e155543dd11a1265e..a98550772383027c587b9eb45c45d37e75212103 100644 --- a/financial-tracker/api-gateway/app/app.py +++ b/financial-tracker/api-gateway/app/app.py @@ -1,14 +1,29 @@ +from datetime import timedelta from flask import Flask, jsonify, request from flask_cors import CORS +from flask_jwt_extended import ( + jwt_required, get_jwt_identity, JWTManager +) import os import requests # Initialize Flask app app = Flask(__name__) -CORS(app) +CORS(app, resources={r"/*": {"origins": "http://localhost:3000"}}) -# Configure user service URL +# Configuration USER_SERVICE_URL = os.getenv('USER_SERVICE_URL', 'http://user-service:5001') +TRANSACTION_SERVICE_URL = os.getenv("TRANSACTION_SERVICE_URL", "http://transaction-service:5002") +app.config["JWT_SECRET_KEY"] = os.getenv( + "JWT_SECRET_KEY", "3b5e41af18179f530c5881a5191e15f0ab35eed2fefdc068fda254eed3fb1ecb" +) +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) +app.config["JWT_TOKEN_LOCATION"] = ["headers"] +app.config["JWT_HEADER_NAME"] = "Authorization" # Default header for JWT +app.config["JWT_HEADER_TYPE"] = "Bearer" + +# Initialise Flask-JWT-Extended +jwt = JWTManager(app) # HEALTH CHECK ROUTE @app.route('/health', methods=['GET']) @@ -80,5 +95,124 @@ def me(): return jsonify({"error": "User service unavailable"}), 503 +@app.route('/expenses', methods=['POST']) +def add_expense(): + """Forward request to transaction-service to add an expense.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + headers = {"Authorization": token, "Content-Type": "application/json"} + data = request.get_json() + app.logger.info(f"Forwarding expense addition request: {data}") + + response = requests.post(f"{TRANSACTION_SERVICE_URL}/expenses", headers=headers, json=data) + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + + +@app.route('/expenses', methods=['GET']) +def fetch_expenses(): + """Fetch expenses for the logged-in user.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + headers = {"Authorization": token} # Forward token + response = requests.get(f"{TRANSACTION_SERVICE_URL}/expenses", headers=headers) + app.logger.info(f"Response from transaction-service (expenses): {response.status_code} - {response.json()}") + return jsonify(response.json()), response.status_code + except requests.exceptions.RequestException as e: + return jsonify({"error": "Transaction service unavailable"}), 503 + + +@app.route('/expenses/<expense_id>', methods=['PUT']) +@jwt_required() +def edit_expense(expense_id): + """Forward request to transaction-service to edit an expense (excluding currency).""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + headers = {"Authorization": token, "Content-Type": "application/json"} + data = request.get_json() + + # Ensure currency field is not included in the update request + if "currency" in data: + app.logger.warning(f"Attempt to modify currency field in expense {expense_id}") + return jsonify({"error": "Currency field cannot be updated"}), 400 + + app.logger.info(f"Forwarding expense update request for ID {expense_id}: {data}") + + response = requests.put(f"{TRANSACTION_SERVICE_URL}/expenses/{expense_id}", headers=headers, json=data) + return jsonify(response.json()), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + + +@app.route('/expenses/<expense_id>', methods=['DELETE']) +@jwt_required() +def delete_expense(expense_id): + """Forward request to transaction-service to delete an expense.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + app.logger.error("Authorization token is missing.") + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + headers = {"Authorization": token} # Forward token + response = requests.delete(f"{TRANSACTION_SERVICE_URL}/expenses/{expense_id}", headers=headers) + + if response.status_code == 200: + app.logger.info(f"Expense with ID {expense_id} deleted successfully.") + return jsonify({"message": "Expense deleted successfully."}), 200 + else: + app.logger.error(f"Failed to delete expense with ID {expense_id}.") + return jsonify({"error": "Failed to delete expense"}), response.status_code + + except requests.exceptions.RequestException as e: + app.logger.error(f"Error contacting transaction-service: {e}") + return jsonify({"error": "Transaction service unavailable"}), 503 + + +@app.route('/categories', methods=['GET']) +@jwt_required() +def fetch_categories(): + """Fetch available expense categories from transaction-service.""" + token = request.headers.get("Authorization") # Get token from headers + + if not token: + return jsonify({"error": "Authorization token is missing"}), 400 + + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + headers = {"Authorization": token} + response = requests.get(f"{TRANSACTION_SERVICE_URL}/categories", headers=headers) + + # Add CORS headers to the response + response_data = jsonify(response.json()) + response_data.headers.add("Access-Control-Allow-Origin", "http://localhost:3000") + response_data.headers.add("Access-Control-Allow-Headers", "Content-Type, Authorization") + response_data.headers.add("Access-Control-Allow-Methods", "GET, OPTIONS") + + return response_data, response.status_code + except requests.exceptions.RequestException as e: + return jsonify({"error": "Transaction service unavailable"}), 503 + + if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, debug=True) diff --git a/financial-tracker/docker-compose.yml b/financial-tracker/docker-compose.yml index 6a7a78d547e1ad596363390a266130171f65918a..130d285ac21641f15e5d5bb625cbec6b3a850247 100644 --- a/financial-tracker/docker-compose.yml +++ b/financial-tracker/docker-compose.yml @@ -48,6 +48,10 @@ services: - transaction-db networks: - app-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5002/health"] + interval: 30s + retries: 3 budget-service: build: diff --git a/financial-tracker/frontend/package-lock.json b/financial-tracker/frontend/package-lock.json index aaf12de8f91640f2d677702213f2b903b1604e81..d109621253c524b888e4c90e15211c45de14c131 100644 --- a/financial-tracker/frontend/package-lock.json +++ b/financial-tracker/frontend/package-lock.json @@ -13,9 +13,13 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.8.4", + "chart.js": "^4.4.8", "formik": "^2.4.6", + "jwt-decode": "^4.0.0", "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", + "react-paginate": "^8.3.0", "react-router-dom": "^7.4.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4", @@ -2964,6 +2968,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -5584,6 +5594,18 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", + "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -11126,6 +11148,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13913,6 +13944,16 @@ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "license": "MIT" }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", @@ -14048,6 +14089,18 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-paginate": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-paginate/-/react-paginate-8.3.0.tgz", + "integrity": "sha512-TptZE37HPkT3R+7AszWA++LOTIsIHXcCSWMP9WW/abeF8sLpJzExFB/dVs7xbtqteJ5njF6kk+udTDC0AR3y5w==", + "license": "MIT", + "dependencies": { + "prop-types": "^15" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18 || ^19" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", diff --git a/financial-tracker/frontend/package.json b/financial-tracker/frontend/package.json index 8bfa2e5d5c862fba9f11d3ec5163d7361bcb07b1..bf17e4b0261820c73a6d4e022a3e06dffcd2bcdc 100644 --- a/financial-tracker/frontend/package.json +++ b/financial-tracker/frontend/package.json @@ -8,9 +8,13 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.8.4", + "chart.js": "^4.4.8", "formik": "^2.4.6", + "jwt-decode": "^4.0.0", "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0", + "react-paginate": "^8.3.0", "react-router-dom": "^7.4.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4", diff --git a/financial-tracker/frontend/src/App.js b/financial-tracker/frontend/src/App.js index 8ceaacc42b7cb8236fd52aa38621a6d7d0c81b19..15b508747874982d406ddce4337af6b06f4de5cd 100644 --- a/financial-tracker/frontend/src/App.js +++ b/financial-tracker/frontend/src/App.js @@ -5,17 +5,17 @@ import Home from "./pages/Home"; import Register from "./pages/Register"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; +import Expenses from "./pages/Expenses"; function App() { const [token, setToken] = useState(localStorage.getItem("token")); useEffect(() => { const handleStorageChange = () => { - setToken(localStorage.getItem("token")); // Update token when it changes + setToken(localStorage.getItem("token")); }; window.addEventListener("storage", handleStorageChange); - return () => { window.removeEventListener("storage", handleStorageChange); }; @@ -33,6 +33,7 @@ function App() { {token ? ( <> <li><Link to="/dashboard">Dashboard</Link></li> + <li><Link to="/expenses">Expenses</Link></li> <li> <Link to="/" onClick={() => { localStorage.removeItem("token"); setToken(null); }}> Logout @@ -53,6 +54,7 @@ function App() { <Route path="/register" element={<Register />} /> <Route path="/login" element={<Login setToken={setToken} />} /> <Route path="/dashboard" element={<Dashboard setToken={setToken} />} /> + <Route path="/expenses" element={<Expenses />} /> </Routes> </div> </Router> diff --git a/financial-tracker/frontend/src/pages/ExpenseChart.js b/financial-tracker/frontend/src/pages/ExpenseChart.js new file mode 100644 index 0000000000000000000000000000000000000000..3b5a66336d4deed410fdcf7d15562d8c4ca5dd92 --- /dev/null +++ b/financial-tracker/frontend/src/pages/ExpenseChart.js @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from "react"; +import { Pie, Bar, Line } from "react-chartjs-2"; +import { + Chart, + ArcElement, + Tooltip, + Legend, + CategoryScale, + LinearScale, + BarElement, + PointElement, + LineElement, + LineController, +} from "chart.js"; +import "../styles/ExpenseChart.css"; + +// Register chart components +Chart.register( + ArcElement, + Tooltip, + Legend, + CategoryScale, + LinearScale, + BarElement, + PointElement, + LineElement, + LineController +); + +const ExpenseChart = ({ expenses, type, timeRange }) => { + const [categoryTotals, setCategoryTotals] = useState({}); + const [timeSeriesData, setTimeSeriesData] = useState({}); + + // Predefined color palette + const colorPalette = [ + "#FF6384", "#36A2EB", "#FFCE56", "#4CAF50", "#FF9800", "#9C27B0", + "#FF5733", "#33FF57", "#3357FF", "#FF33A2", "#A233FF", "#57FF33", + "#FFD700", "#800080", "#00BFFF", "#00FF00", "#FF1493", "#8A2BE2", + "#FF6347", "#7CFC00", "#FF4500", "#32CD32", "#ADFF2F", "#FF8C00", + ]; + + // Function to group expenses by time range + const groupExpensesByTime = (expenses, range) => { + const grouped = {}; + + expenses.forEach((expense) => { + const date = new Date(expense.date); + let timeKey; + + if (range === "daily") { + timeKey = date.toISOString().split("T")[0]; // YYYY-MM-DD + } else if (range === "weekly") { + const weekNumber = getWeekNumber(date); + timeKey = `${date.getFullYear()}-W${weekNumber}`; + } else if (range === "monthly") { + timeKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + } else if (range === "yearly") { + timeKey = `${date.getFullYear()}`; + } + + if (!grouped[expense.category]) { + grouped[expense.category] = {}; + } + grouped[expense.category][timeKey] = + (grouped[expense.category][timeKey] || 0) + expense.amount; + }); + + return grouped; + }; + + // Function to get the week number of a date + const getWeekNumber = (date) => { + const startOfYear = new Date(date.getFullYear(), 0, 1); + const pastDays = Math.floor((date - startOfYear) / (24 * 60 * 60 * 1000)); + return Math.ceil((pastDays + startOfYear.getDay() + 1) / 7); + }; + + useEffect(() => { + if (expenses.length > 0) { + // Calculate total spending by category + const totals = expenses.reduce((acc, expense) => { + acc[expense.category] = (acc[expense.category] || 0) + expense.amount; + return acc; + }, {}); + setCategoryTotals(totals); + + // Group expenses by selected time range (daily, weekly, monthly, yearly) + const groupedData = groupExpensesByTime(expenses, timeRange); + setTimeSeriesData(groupedData); + } + }, [expenses, timeRange]); + + // Generate colors for the categories + const dataColors = Object.keys(categoryTotals).map((_, index) => { + return colorPalette[index % colorPalette.length]; + }); + + // Data for Pie Chart + const pieData = { + labels: Object.keys(categoryTotals), + datasets: [ + { + label: "Spending by Category", + data: Object.values(categoryTotals), + backgroundColor: dataColors, + }, + ], + }; + + // Data for Bar Chart + const barData = { + labels: Object.keys(categoryTotals), + datasets: [ + { + data: Object.values(categoryTotals), + backgroundColor: dataColors, + }, + ], + }; + + // Prepare data for the line chart + const timeLabels = [ + ...new Set( + Object.values(timeSeriesData).flatMap((data) => Object.keys(data)) + ), + ].sort(); + + const lineDatasets = Object.keys(timeSeriesData).map((category, index) => ({ + label: category, + data: timeLabels.map((label) => timeSeriesData[category]?.[label] || 0), + borderColor: colorPalette[index % colorPalette.length], + backgroundColor: colorPalette[index % colorPalette.length], + fill: false, + tension: 0.1, + })); + + const lineData = { + labels: timeLabels, + datasets: lineDatasets, + }; + + // Chart options + const options = { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: "Amount Spent", + }, + }, + x: { + title: { + display: true, + text: + type === "line" + ? `Time (${timeRange.charAt(0).toUpperCase() + timeRange.slice(1)})` + : "Categories", + }, + }, + }, + plugins: { + legend: { + display: false, // Set to false to hide the legend entirely + }, + tooltip: { + mode: "index", + intersect: false, + }, + }, + }; + + + return ( + <div className="expense-chart-container"> + {/* Pie Chart Section */} + {type === "pie" && ( + <div className="chart-section"> + <div className="text-container"> + <h4>Pie Chart: Spending Breakdown</h4> + <p> + The pie chart shows the proportion of spending for each category. + Each slice of the pie represents a category, and the size of each + slice is proportional to the amount spent in that category. + </p> + </div> + <div className="chart"> + <Pie data={pieData} /> + </div> + </div> + )} + + {/* Bar Chart Section */} + {type === "bar" && ( + <div className="chart-section"> + <div className="text-container"> + <h4>Bar Chart: Spending by Category</h4> + <p> + The bar chart provides a clear comparison of spending across + categories. Each bar represents the total amount spent in each + category, making it easy to compare amounts visually. + </p> + </div> + <div className="chart"> + <Bar data={barData} options={options} /> + </div> + </div> + )} + + {/* Line Chart Section */} + {type === "line" && ( + <div className="chart-section"> + <div className="text-container"> + <h4>Line Chart: Spending Trends Over Time</h4> + <p> + The line chart shows how your spending has changed over time for + each category. This helps identify trends and patterns in your + expenses. + </p> + </div> + <div className="chart"> + <Line data={lineData} options={options} /> + </div> + </div> + )} + </div> + ); +}; + +export default ExpenseChart; diff --git a/financial-tracker/frontend/src/pages/Expenses.js b/financial-tracker/frontend/src/pages/Expenses.js new file mode 100644 index 0000000000000000000000000000000000000000..f53a3106489566a3dc8239793e366c780cd567d1 --- /dev/null +++ b/financial-tracker/frontend/src/pages/Expenses.js @@ -0,0 +1,915 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { jwtDecode } from 'jwt-decode'; +import ReactPaginate from 'react-paginate'; +import ExpenseChart from "./ExpenseChart"; +import "../styles/Expenses.css"; + +const Expenses = () => { + const [expenses, setExpenses] = useState([]); + const expensesPerPage = 5; + const [currentPage, setCurrentPage] = useState(0); + const offset = currentPage * expensesPerPage; + const [filteredExpenses, setFilteredExpenses] = useState([]); + const currentExpenses = filteredExpenses.slice(offset, offset + expensesPerPage); + const [isSessionValid, setIsSessionValid] = useState(true); + const [isRecurring, setIsRecurring] = useState(false); + const [recurring_type, setRecurringType] = useState(""); + const [frequency, setFrequency] = useState(""); + const [interval, setInterval] = useState(""); + const [end_repeat, setEndRepeat] = useState("never"); + const [end_date, setEndDate] = useState(""); + const [sortConfig, setSortConfig] = useState({ key: "", direction: "asc" }); + const [showFilters, setShowFilters] = useState(false); + const [categories, setCategories] = useState([]); + const [chartType, setChartType] = useState("bar"); + const [timeRange, setTimeRange] = useState("monthly"); + const [newExpense, setNewExpense] = useState({ + category: "", + amount: "", + date: "", + description: "", + currency: "GBP", + recurring: false, + recurring_type: "", + frequency: "", + interval: "", + end_repeat: "never", + end_date: "", + }); + const [editExpense, setEditExpense] = useState({ + id: null, + category: "", + amount: "", + date: "", + description: "", + currency: "GBP", + recurring: false, + recurring_type: "", + frequency: "", + interval: "", + end_repeat: "never", + end_date: "", + }); + const [filters, setFilters] = useState({ + category: "", + minAmount: "", + maxAmount: "", + startDate: "", + end_date: "", + }); + const [deleteMessage, setDeleteMessage] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(false); + + const openModal = () => { + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + + // Check if token exists in localStorage to determine if session is valid + useEffect(() => { + const token = localStorage.getItem("token"); + + if (token) { + setIsSessionValid(true); // Token exists, session is valid + } else { + setIsSessionValid(false); // No token, session invalid + alert("Your session has expired. Please log in again."); + window.location.href = "/login"; // Redirect to login page + } + }, []); + + + // Only fetch data if session is valid + useEffect(() => { + if (isSessionValid) { + fetchCategories(); + fetchExpenses(); + } + }, [isSessionValid]); + + + const fetchExpenses = async () => { + try { + const token = localStorage.getItem("token"); + + if (!token) { + console.error("No token found, user might not be logged in."); + return; + } + + const decoded = jwtDecode(token); + console.log("Decoded JWT:", decoded); + + // Use 'sub' as user_id + const user_id = decoded.sub; + if (!user_id) { + console.error("User ID is undefined. Check JWT structure."); + return; + } + + const response = await axios.get("http://localhost:8000/expenses", { + headers: { Authorization: `Bearer ${token}` }, + }); + + console.log("Fetched expenses:", response.data); + + if (Array.isArray(response.data.expenses)) { + setExpenses(response.data.expenses); + setFilteredExpenses(response.data.expenses); + } else { + console.error("Expected an array but got:", response.data); + setExpenses([]); + setFilteredExpenses([]); + } + } catch (error) { + console.error("Error fetching expenses:", error); + setExpenses([]); + setFilteredExpenses([]); + } + }; + + + const fetchCategories = async () => { + try { + const token = localStorage.getItem("token"); + console.log("Token from localStorage:", token); + + if (!token) { + console.error("No token found, user might not be logged in."); + return; + } + + const decoded = jwtDecode(token); + console.log("Decoded JWT:", decoded); + + // Use 'sub' as user_id + const user_id = decoded.sub; + if (!user_id) { + console.error("User ID is undefined. Check JWT structure."); + return; + } + + const response = await axios.get(`http://localhost:8000/categories`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + console.log("Categories Response:", response.data); + + if (Array.isArray(response.data.categories)) { + setCategories(response.data.categories); + } else { + console.error("Categories data is not in expected format", response.data); + } + } catch (error) { + console.error("Error fetching categories:", error); + } + }; + + + const handleChange = (e) => { + const { name, type, value, checked } = e.target; + setNewExpense({ + ...newExpense, + [name]: type === "checkbox" ? checked : value, + }); + }; + + + const handleEdit = (expense) => { + if (!expense || !expense.id) { + console.error("Invalid expense to edit", expense); + return; + } + console.log("Editing expense:", expense); + setEditExpense({ + id: expense.id, + category: expense.category, + amount: expense.amount, + date: expense.date, + description: expense.description, + recurring: expense.recurring, + recurring_type: expense.recurring_type || "", + frequency: expense.frequency || "", + interval: expense.interval || "", + end_repeat: expense.end_repeat || "never", + end_date: expense.end_date || "", + }); + openModal(); + }; + + + const handleSaveEdit = async (e, id) => { + e.preventDefault(); + + try { + const updatedExpense = { + ...editExpense, + recurring_type: editExpense.recurring ? editExpense.recurring_type : null, + frequency: editExpense.recurring_type === "custom" ? editExpense.frequency : null, + interval: editExpense.recurring_type === "custom" ? editExpense.interval : null, + end_repeat: editExpense.recurring ? editExpense.end_repeat : null, + end_date: editExpense.end_repeat === "on_date" ? editExpense.end_date : null, + }; + + // Ensure end_date is sent only if 'end_repeat' is 'on_date' + if (updatedExpense.end_repeat === "on_date" && !updatedExpense.end_date) { + alert("Please specify an end date."); + return; + } + + if (updatedExpense.currency) { + delete updatedExpense.currency; + } + if (!updatedExpense.end_date) { + delete updatedExpense.end_date; + } + + console.log("Updated Expense Payload:", updatedExpense); + + const token = localStorage.getItem("token"); + + const response = await axios.put( + `http://localhost:8000/expenses/${id}`, + updatedExpense, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + } + ); + + if (response.status === 200) { + setExpenses((prevExpenses) => + prevExpenses.map((expense) => + expense.id === id ? { ...expense, ...updatedExpense } : expense + ) + ); + + setFilteredExpenses((prevFiltered) => + prevFiltered.map((expense) => + expense.id === id ? { ...expense, ...updatedExpense } : expense + ) + ); + + closeModal(); + setSuccessMessage("Expense updated successfully. Refreshing expense page..."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + console.error("Error updating expense:", response.data); + } + } catch (error) { + console.error("Error updating expense:", error); + alert( + `Error updating expense: ${ + error.response ? error.response.data.error || error.response.data.message : error.message + }` + ); + } + }; + + + const handleDelete = async (id) => { + const confirmDelete = window.confirm("Are you sure you want to delete this expense?"); + + if (confirmDelete) { + try { + const token = localStorage.getItem("token"); + const response = await axios.delete(`http://localhost:8000/expenses/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (response.status === 200) { + setDeleteMessage("Expense deleted successfully. Refreshing expense page..."); + + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } + } catch (error) { + console.error("Error deleting expense:", error); + setDeleteMessage("Error deleting expense. Please try again."); + } + } + }; + + + const handleAddExpense = async (e) => { + e.preventDefault(); + + // Ensure correct key names for backend + const expenseWithCurrency = { + ...newExpense, + currency: newExpense.currency || 'GBP', // Default to GBP + recurring: isRecurring, // Ensure recurring is passed as boolean + recurring_type: recurring_type || null, // Match backend field + frequency: frequency || null, // Match backend field + interval: interval || null, // Match backend field + end_repeat: end_repeat || null, // Match backend field + end_date: end_date || null, // Match backend field + }; + + try { + const token = localStorage.getItem("token"); + const response = await axios.post( + "http://localhost:8000/expenses", + expenseWithCurrency, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + } + ); + + if (response.status === 201) { + const addedExpense = response.data; + setExpenses([...expenses, addedExpense]); + setFilteredExpenses([...filteredExpenses, addedExpense]); + + // Re-fetch the expenses from the backend + fetchExpenses(); + + setNewExpense({ + category: "", + amount: "", + date: "", + description: "", + recurring: false, + recurring_type: "", + frequency: "", + interval: "", + end_repeat: "never", + end_date: "", + currency: "", + }); + + setSuccessMessage("Expense added successfully. Refreshing expense page..."); + // Wait 1.5 seconds before refreshing + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + console.error("Error adding expense:", response.data); + } + } catch (error) { + console.error("Error adding expense:", error); + alert( + `Error adding expense: ${ + error.response ? error.response.data.error : error.message + }` + ); + } + }; + + + const applyFilters = () => { + let filtered = expenses.filter((expense) => { + return ( + (!filters.category || expense.category.toLowerCase() === filters.category.toLowerCase()) && + (!filters.minAmount || parseFloat(expense.amount) >= parseFloat(filters.minAmount)) && + (!filters.maxAmount || parseFloat(expense.amount) <= parseFloat(filters.maxAmount)) && + (!filters.startDate || expense.date >= filters.startDate) && + (!filters.end_date || expense.date <= filters.end_date) + ); + }); + setFilteredExpenses(filtered); + }; + + + const sortData = (key) => { + let direction = "asc"; + if (sortConfig.key === key && sortConfig.direction === "asc") { + direction = "desc"; + } + const sortedExpenses = [...filteredExpenses].sort((a, b) => { + if (key === "date") { + return direction === "asc" + ? new Date(a[key]) - new Date(b[key]) + : new Date(b[key]) - new Date(a[key]); + } else if (key === "amount") { + return direction === "asc" + ? parseFloat(a[key]) - parseFloat(b[key]) + : parseFloat(b[key]) - parseFloat(a[key]); + } + return 0; + }); + setFilteredExpenses(sortedExpenses); + setSortConfig({ key, direction }); + }; + + + const handlePageClick = (event) => { + const selectedPage = event.selected; + setCurrentPage(selectedPage); + }; + + const handleChartChange = (event) => { + setChartType(event.target.value); + }; + + const handleTimeRangeChange = (event) => { + setTimeRange(event.target.value); + }; + + return ( + <div className="expenses-container"> + <h1>Expenses</h1> + + {/* Delete Message */} + {deleteMessage && <div className="delete-message">{deleteMessage}</div>} + + {/* Success Message */} + {successMessage && <div className="success-message">{successMessage}</div>} + + {/* Add Expense Form */} + <form onSubmit={handleAddExpense} className="expense-form"> + <div class="expense-inputs"> + <div class="expense-row"> + <div class="input-group"> + <label htmlFor="category">Category</label> + <input + type="text" + name="category" + placeholder="Category" + value={newExpense.category} + onChange={handleChange} + required + /> + </div> + <div class="input-group"> + <label htmlFor="amount">Amount</label> + <input + type="number" + name="amount" + placeholder="Amount" + value={newExpense.amount} + onChange={handleChange} + step="0.01" + required + /> + </div> + </div> + <div class="expense-row"> + <div class="input-group"> + <label htmlFor="date">Date</label> + <input + type="date" + name="date" + value={newExpense.date} + onChange={handleChange} + required + /> + </div> + <div class="input-group"> + <label htmlFor="description">Description (optional)</label> + <input + type="text" + name="description" + placeholder="Description (optional)" + value={newExpense.description} + onChange={handleChange} + /> + </div> + </div> + <div className="checkbox-container"> + <div className="checkbox-label"> + <input + type="checkbox" + id="recurring" + checked={isRecurring} + onChange={() => setIsRecurring((prev) => !prev)} + /> + <label htmlFor="recurring">Recurring Expense</label> + </div> + </div> + </div> + + {/* Recurring Options (Only shown if checkbox is checked) */} + {isRecurring && ( + <div className="recurring-options"> + {/* Left Column: Recurrence Type */} + <div className="recurring-left"> + <label htmlFor="recurring_type">Recurrence Type</label> + <select + id="recurring_type" + value={recurring_type} + onChange={(e) => setRecurringType(e.target.value)} + > + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="every_two_weeks">Every Two Weeks</option> + <option value="monthly">Every Month</option> + <option value="yearly">Every Year</option> + <option value="custom">Custom</option> + </select> + + {/* Custom Recurrence Options */} + {recurring_type === "custom" && ( + <div className="custom-recurring"> + <label htmlFor="frequency">Frequency</label> + <select + id="frequency" + value={frequency} + onChange={(e) => setFrequency(e.target.value)} + > + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="monthly">Monthly</option> + <option value="yearly">Yearly</option> + </select> + + <label htmlFor="interval">Interval (e.g., every X weeks)</label> + <input + id="interval" + type="number" + min="1" + value={interval} + onChange={(e) => setInterval(e.target.value)} + placeholder="Enter interval (e.g., every 3 weeks)" + /> + </div> + )} + </div> + + {/* Right Column: End Repeat */} + <div className="recurring-right"> + <label htmlFor="end_repeat">End Repeat</label> + <select + id="end_repeat" + value={end_repeat} + onChange={(e) => setEndRepeat(e.target.value)} + > + <option value="never">Never</option> + <option value="on_date">On Date</option> + </select> + + {end_repeat === "on_date" && ( + <div className="input-group"> + <label htmlFor="end_date">End Date:</label> + <input + id="end_date" + type="date" + value={end_date} + onChange={(e) => setEndDate(e.target.value)} + /> + </div> + )} + </div> + </div> + )} + <button type="submit" className="add-expense-btn"> + Add Expense + </button> + </form> + + + {/* Filters Section */} + <div className="filter-toggle"> + <button onClick={() => setShowFilters((prev) => !prev)}> + {showFilters ? "Hide Filters" : "Show Filters"} + </button> + </div> + + {showFilters && ( + <div className="filter-dropdown"> + {/* First Column: Category */} + <div className="filter-column"> + <label htmlFor="category">Category</label> + <select + id="category" + value={filters.category} + onChange={(e) => setFilters({ ...filters, category: e.target.value })} + > + <option value="">All Categories</option> + {categories.map((cat) => ( + <option key={cat} value={cat}> + {cat} + </option> + ))} + </select> + </div> + + {/* Second Column: Min/Max Amount */} + <div className="filter-column"> + <label htmlFor="minAmount">Min Amount</label> + <input + id="minAmount" + type="number" + placeholder="Min Amount" + value={filters.minAmount} + onChange={(e) => setFilters({ ...filters, minAmount: e.target.value })} + step="0.01" + /> + + <label htmlFor="maxAmount">Max Amount</label> + <input + id="maxAmount" + type="number" + placeholder="Max Amount" + value={filters.maxAmount} + onChange={(e) => setFilters({ ...filters, maxAmount: e.target.value })} + step="0.01" + /> + </div> + + {/* Third Column: Start/End Date */} + <div className="filter-column"> + <label htmlFor="startDate">Start Date (dd/mm/yyyy)</label> + <input + id="startDate" + type="date" + value={filters.startDate} + onChange={(e) => setFilters({ ...filters, startDate: e.target.value })} + /> + + <label htmlFor="end_date">End Date (dd/mm/yyyy)</label> + <input + id="end_date" + type="date" + value={filters.end_date} + onChange={(e) => setFilters({ ...filters, end_date: e.target.value })} + /> + </div> + {/* Apply Filters Button (Centered) */} + <div className="apply-filters-container"> + <button className="apply-filters-btn" onClick={applyFilters}> + Apply Filters + </button> + </div> + </div> + )} + + + {/* Modal Content */} + <div className={`modal-overlay ${isModalOpen ? "show" : ""}`}> + <div className="modal-content"> + <h3>Edit Expense</h3> + <form onSubmit={(e) => handleSaveEdit(e, editExpense.id)} className="expense-form"> + <div className="expense-row"> + <div className="input-group"> + <label htmlFor="category">Category</label> + <input + type="text" + id="category" + value={editExpense.category} + onChange={(e) => setEditExpense({ ...editExpense, category: e.target.value })} + required + /> + </div> + <div className="input-group"> + <label htmlFor="amount">Amount</label> + <input + type="number" + id="amount" + value={editExpense.amount} + onChange={(e) => setEditExpense({ ...editExpense, amount: e.target.value })} + step="0.01" + required + /> + </div> + </div> + <div className="expense-row"> + <div className="input-group"> + <label htmlFor="date">Date</label> + <input + type="date" + id="date" + value={editExpense.date} + onChange={(e) => setEditExpense({ ...editExpense, date: e.target.value })} + required + /> + </div> + <div className="input-group"> + <label htmlFor="description">Description</label> + <input + type="text" + id="description" + value={editExpense.description} + onChange={(e) => setEditExpense({ ...editExpense, description: e.target.value })} + /> + </div> + </div> + <div className="checkbox-container"> + <label className="checkbox-label"> + <input + type="checkbox" + checked={editExpense.recurring} + onChange={(e) => setEditExpense({ ...editExpense, recurring: e.target.checked })} + /> + Recurring Expense + </label> + </div> + {editExpense.recurring && ( + <div className="recurring-options"> + <div className="recurring-left"> + <label htmlFor="recurring_type">Recurring Type</label> + <select + id="recurring_type" + value={editExpense.recurring_type || ""} + onChange={(e) => setEditExpense({ ...editExpense, recurring_type: e.target.value })} + required + > + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="every_two_weeks">Every Two Weeks</option> + <option value="monthly">Every Month</option> + <option value="yearly">Every Year</option> + <option value="custom">Custom</option> + </select> + {editExpense.recurring_type === "custom" && ( + <div className="custom-recurring"> + <label htmlFor="frequency">Frequency</label> + <select + id="frequency" + value={editExpense.frequency || ""} + onChange={(e) => setEditExpense({ ...editExpense, frequency: e.target.value })} + > + <option value="">Select...</option> + <option value="daily">Daily</option> + <option value="weekly">Weekly</option> + <option value="monthly">Monthly</option> + <option value="yearly">Yearly</option> + </select> + <label htmlFor="interval">Interval (e.g., every X weeks)</label> + <input + id="interval" + type="number" + min="1" + value={editExpense.interval || ""} + onChange={(e) => setEditExpense({ ...editExpense, interval: e.target.value })} + placeholder="Enter interval (e.g., every 3 weeks)" + /> + </div> + )} + </div> + <div className="recurring-right"> + <label htmlFor="end_repeat">End Repeat</label> + <select + id="end_repeat" + value={editExpense.end_repeat || ""} + onChange={(e) => setEditExpense({ ...editExpense, end_repeat: e.target.value })} + > + <option value="never">Never</option> + <option value="on_date">On Date</option> + </select> + {editExpense.end_repeat === "on_date" && ( + <div className="recurring-right"> + <label htmlFor="end_date">End Date</label> + <input + type="date" + id="end_date" + value={editExpense.end_date || ""} + onChange={(e) => setEditExpense({ ...editExpense, end_date: e.target.value })} + required + /> + </div> + )} + </div> + </div> + )} + <button type="submit" className="add-expense-btn">Save Changes</button> + <button type="button" className="close-modal-btn" onClick={closeModal}>Cancel</button> + </form> + </div> + </div> + + + {/* Expenses Table */} + <table className="expenses-table"> + <thead> + <tr> + <th onClick={() => sortData("date")} className="sortable"> + Date {sortConfig.key === "date" && (sortConfig.direction === "asc" ? "↑" : "↓")} + </th> + <th>Category</th> + <th onClick={() => sortData("amount")} className="sortable"> + Amount (£) {sortConfig.key === "amount" && (sortConfig.direction === "asc" ? "↑" : "↓")} + </th> + <th>Description</th> + <th>Recurring</th> + <th>Actions</th> + </tr> + </thead> + <tbody> + {filteredExpenses && filteredExpenses.length > 0 ? ( + currentExpenses.map((expense) => ( + <tr key={expense.id}> + <td>{expense.date}</td> + <td>{expense.category}</td> + <td>£{parseFloat(expense.amount).toFixed(2)}</td> + <td>{expense.description}</td> + <td>{expense.recurring ? "Yes" : "No"}</td> + <td> + <button onClick={() => handleEdit(expense)} className="edit-expense-btn">Edit</button> + <button onClick={() => handleDelete(expense.id)}>Delete</button> + </td> + </tr> + )) + ) : ( + <tr> + <td colSpan="6" style={{ textAlign: "center" }}> + No expenses found. + </td> + </tr> + )} + </tbody> + </table> + + + {/* Pagination Component */} + {filteredExpenses.length > 0 && ( + <ReactPaginate + previousLabel={"<< Previous"} + nextLabel={"Next >>"} + breakLabel={"..."} + pageCount={Math.max(1, Math.ceil(filteredExpenses.length / expensesPerPage))} + marginPagesDisplayed={2} + pageRangeDisplayed={5} + onPageChange={handlePageClick} + containerClassName={"pagination"} + activeClassName={"active"} + /> + )} + + {/* Expenses Summary Section */} + <div className="expenses-summary"> + <h2>Expenses Summary</h2> + <p> + Here’s a breakdown of your spending by category. The charts below visualise how your expenses are distributed + across different categories. This summary helps you get a quick overview of where your money is going. + </p> + <p> + Take note of categories where you spend the most, and consider adjusting your budget or habits to ensure + you're staying on track with your financial goals. + </p> + </div> + + {/* Spending Distribution Section */} + <div className="spending-distribution"> + <h3>Spending Distribution</h3> + <p> + This chart displays the distribution of your spending across various categories. Depending on the selected + chart type, you can view this data in different ways. + </p> + + {/* Chart Type Selector for Bar/Pie Chart */} + <div className="chart-type-selector"> + <label htmlFor="chartType">Select Chart Type: </label> + <select id="chartType" value={chartType} onChange={handleChartChange}> + <option value="bar">Bar Chart</option> + <option value="pie">Pie Chart</option> + </select> + </div> + + {/* Render the selected Bar or Pie chart */} + <div className="chart-item"> + <ExpenseChart type={chartType} expenses={expenses} /> + </div> + </div> + + {/* Monthly Spending Trend Section */} + <div className="spending-trend"> + <h3>Spending Trend</h3> + <p> + This section provides a detailed view of your spending trends over different time periods—daily, weekly, + monthly, or yearly. By visualizing your expenses across various categories over these time ranges, you + can easily identify patterns, fluctuations, and long-term trends in your spending habits. This insight + allows you to make informed financial decisions, helping you better manage your budget and track changes + in your spending behavior over time. + </p> + + {/* Time Range Selector for the Line Chart */} + <div className="time-range-selector"> + <label htmlFor="timeRange">Select Time Range: </label> + <select id="timeRange" value={timeRange} onChange={handleTimeRangeChange}> + <option value="daily">Days</option> + <option value="weekly">Weeks</option> + <option value="monthly">Months</option> + <option value="yearly">Years</option> + </select> + </div> + + {/* Render the Line Chart with the selected Time Range */} + <div className="chart-item"> + <ExpenseChart expenses={expenses} type="line" timeRange={timeRange} /> + </div> + </div> + </div> + ); +}; + +export default Expenses; diff --git a/financial-tracker/frontend/src/styles/ExpenseChart.css b/financial-tracker/frontend/src/styles/ExpenseChart.css new file mode 100644 index 0000000000000000000000000000000000000000..a9934e8aa14a695981b35120ba9da1a0216d8e92 --- /dev/null +++ b/financial-tracker/frontend/src/styles/ExpenseChart.css @@ -0,0 +1,73 @@ +.charts-container { + display: flex; + gap: 20px; + justify-content: center; + margin-top: 20px; +} + +.chart-item { + flex: 1; + max-width: 100%; +} + +.chart canvas { + width: 100% !important; + height: 100% !important; + min-height: 350px !important; +} + +.expense-chart-container { + margin: 20px; +} + +.chart-section { + margin-bottom: 30px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: auto; + min-height: 450px; +} + +.chart-section .text-container { + width: 100%; + max-width: 800px; + text-align: left; +} + +.chart-section h4 { + margin-top: 15px; + font-size: 1.2em; + font-weight: bold; + text-align: center; +} + +.chart-section p { + font-size: 1em; + margin-top: 10px; + line-height: 1.5; + text-align: center !important; +} + +.chart { + margin-top: 20px; + height: 400px !important; + min-height: 400px; + max-height: 500px; + position: relative; +} + +.line-chart-container { + margin-top: 30px; + padding: 15px; + background-color: #f1f1f1; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.line-chart-container h3 { + font-size: 1.5rem; + margin-bottom: 15px; + color: #333; +} diff --git a/financial-tracker/frontend/src/styles/Expenses.css b/financial-tracker/frontend/src/styles/Expenses.css new file mode 100644 index 0000000000000000000000000000000000000000..38c3192b01a4b2da9f8ea1bbd2a733284cf7f5e3 --- /dev/null +++ b/financial-tracker/frontend/src/styles/Expenses.css @@ -0,0 +1,612 @@ +/* Main Container */ +.expenses-container { + width: 80%; + margin: auto; +} + + +/* Expense font */ +h1 { + font-size: 2.5em; + text-align: center; +} + +.expenses-summary h2 { + font-size: 2em; + margin-top: 15px; + font-weight: bold; + text-align: center; +} + +.spending-distribution h3, +.spending-trend h3 { + font-size: 1.5em; + margin-top: 15px; + font-weight: bold; + text-align: left; +} + +.expenses-summary p { + font-size: 1.3em; + margin-top: 10px; + line-height: 1.5; + text-align: justify; +} + +.spending-distribution p, +.spending-trend p { + font-size: 1.1em; + margin-top: 10px; + line-height: 1.5; + text-align: justify; +} + +/* Modal Overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 999; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.modal-overlay.show { + opacity: 1; + pointer-events: auto; +} + + +/* Modal Content */ +.modal-content { + background-color: #fff; + padding: 20px; + border-radius: 10px; + width: 500px; + max-width: 90%; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.modal-content .input-group label { + font-weight: bold; +} + +.modal-content .expense-row { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 15px; + width: 100%; +} + +.modal-content .expense-row .input-group { + box-sizing: border-box; + flex: 1 1 calc(50% - 15px); + min-width: 200px; + max-width: 250px; + box-sizing: border-box; +} + +.modal-content .input-group input, +.modal-content .input-group select { + width: 80%; + padding: 8px; + border: 1px solid #ccc; + border-radius: 5px; + max-width: 250px; +} + +.modal-content .custom-recurring label, +.modal-content .recurring-left label, +.modal-content .recurring-right label { + margin-top: 10px; + font-weight: bold; +} + +.modal-content .recurring-options { + display: flex; + justify-content: space-between; + gap: 20px; + margin-top: 20px; + width: 100% +} + + +.modal-content .recurring-left, +.modal-content .recurring-right { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; +} + +.modal-content .input-group { + margin-bottom: 15px; +} + +.modal-content .custom-recurring { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.modal-content .custom-recurring input, +.modal-content .custom-recurring select { + width: 100%; +} + + +/* Expense Form */ +.expense-form { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin-bottom: 20px; + padding: 20px; + border-radius: 10px; + background-color: #f9f9f9; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.expense-inputs { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} + +.input-group { + flex: 1; + min-width: calc(50% - 5px); +} + +.expense-inputs input, +.expense-inputs select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + width: calc(50% - 5px); + box-sizing: border-box; +} + +.expense-inputs input[type="checkbox"] { + width: auto; +} + +.expense-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + width: 100%; +} + +.expense-row .input-group { + flex: 1; + min-width: calc(50% - 5px); +} + +.expense-inputs label { + font-weight: bold; +} + +.checkbox-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.checkbox-label { + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: bold; + gap: 5px; +} +/* Recurring Options */ +.recurring-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + width: 100%; + max-width: 600px; + background-color: #f8f8f8; + padding: 15px; + border-radius: 5px; + border: 1px solid #ccc; +} + +.recurring-options label { + display: block; + font-size: 14px; + font-weight: bold; + margin-bottom: 5px; +} + +.recurring-left, +.recurring-right { + display: flex; + flex-direction: column; + gap: 10px; +} + +.recurring-options select, +.recurring-options input { + width: 100%; + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #fff; + box-sizing: border-box; +} + +.recurring-options input[type="date"], +.recurring-options input[type="number"] { + appearance: none; + width: 100%; +} + + +/* Custom Recurrence */ +.custom-recurring { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* End Repeat Section */ +.end-repeat { + margin-top: 10px; +} + +/* Add / Submit Button */ +.add-expense-btn { + padding: 10px 15px; + font-size: 14px; + background-color: #007bff; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + margin-top: 20px; +} + +.add-expense-btn:hover { + background-color: #0056b3; +} + +/* Close Button */ +.close-modal-btn { + padding: 10px 15px; + font-size: 14px; + background-color: #dc3545; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + margin-top: 10px; +} + +.close-modal-btn:hover { + background-color: #c82333; +} + +/* Expenses Table */ +.expenses-table { + width: 100%; + max-width: 800px; + margin: auto; + border-collapse: collapse; + margin-top: 20px; +} + +th.sortable { + cursor: pointer; +} + +.expenses-table th, +.expenses-table td { + border: 1px solid #ddd; + padding: 10px; + text-align: center; +} + +.expenses-table th { + background-color: #f4f4f4; +} + +.expenses-table tr:nth-child(even) { + background-color: #f9f9f9; +} + +.expenses-table tr:hover { + background-color: #f1f1f1; +} + +/* Action Buttons */ +.expenses-table button { + padding: 5px 10px; + font-size: 12px; + border: none; + cursor: pointer; + margin: 2px; + border-radius: 3px; +} + +.expenses-table button:first-child { + background-color: #ffc107; + color: black; +} + +.expenses-table button:last-child { + background-color: #dc3545; + color: white; +} + +/* Filter Dropdown */ +.filter-dropdown { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + margin-top: 20px; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +/* Each column */ +.filter-column { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; + min-width: 180px; +} + +/* Input and select styling */ +.filter-column label { + font-size: 14px; + font-weight: bold; + margin-bottom: 5px; +} + +.filter-column select, +.filter-column input { + width: 100%; + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; + background-color: #f8f8f8; +} + +.filter-column select { + cursor: pointer; +} + +/* Apply Filters Button */ +.apply-filters-container { + width: 100%; + display: flex; + justify-content: center; +} + +/* Apply Filters Button */ +.apply-filters-btn { + width: 100%; + max-width: 600px; + padding: 12px 20px; + background-color: #28a745; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + margin-top: 15px; + transition: background-color 0.3s; +} + +.apply-filters-btn:hover { + background-color: #218838; +} + +/* Responsive: Stack on small screens */ +@media (max-width: 600px) { + .expense-inputs { + flex-direction: column; + width: 100%; + } + + .expense-inputs input, + .expense-inputs select { + width: 100%; + } + + .expenses-table { + font-size: 12px; + } + + .expenses-table th, + .expenses-table td { + padding: 8px; + } + + .filter-dropdown { + flex-direction: column; + align-items: center; + } + + .filter-column { + width: 100%; + } + + .expense-inputs { + grid-template-columns: 1fr; + } + + .recurring-options { + grid-template-columns: 1fr; + } + + .chart-type-selector, + .time-range-selector { + flex-direction: column; + align-items: flex-start; + } + + .chart-type-selector select, + .time-range-selector select { + width: 100%; + } +} + +.delete-message { + padding: 10px; + margin-top: 10px; + background-color: #f44336; + color: white; + border-radius: 5px; + text-align: center; +} + +.success-message { + padding: 10px; + margin-top: 10px; + background-color: #4CAF50; + color: white; + border-radius: 5px; + text-align: center; +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + list-style-type: none; + padding: 10px 0; + margin-top: 20px; +} + +.pagination li { + margin: 0 10px; + cursor: pointer; + font-size: 14px; + padding: 8px 12px; + border-radius: 5px; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.pagination li:hover { + background-color: #007bff; + color: white; +} + +.pagination .active { + background-color: #007bff; + color: white; + font-weight: bold; + border-radius: 5px; + padding: 8px 12px; +} + +.pagination li.disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.pagination .previous, .pagination .next { + font-weight: bold; +} + +/* Adding styles for Previous and Next buttons */ +.pagination .previous, +.pagination .next { + padding: 8px 12px; + background-color: #007bff; + color: white; + border-radius: 5px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.pagination .previous:hover, +.pagination .next:hover { + background-color: #0056b3; +} + + +/* Chart Type Selector */ +.chart-type-selector { + display: flex; + align-items: center; + gap: 10px; + margin-top: 20px; + margin-bottom: 20px; + font-size: 16px; +} + +.chart-type-selector label { + font-weight: bold; +} + +.chart-type-selector select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f8f8f8; + cursor: pointer; + transition: border-color 0.3s; +} + +.chart-type-selector select:focus { + border-color: #007bff; + outline: none; +} + +/* Time Range Selector */ +.time-range-selector { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; + font-size: 16px; +} + +.time-range-selector label { + font-weight: bold; +} + +.time-range-selector select { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f8f8f8; + cursor: pointer; + transition: border-color 0.3s; +} + +.time-range-selector select:focus { + border-color: #007bff; + outline: none; +} diff --git a/financial-tracker/transaction-service/app/app.py b/financial-tracker/transaction-service/app/app.py index 8b37a7fc7a61b0eae0d7d86f3572e3091df78653..bb654fe16c940a660e8f77b057f9d882596fa774 100644 --- a/financial-tracker/transaction-service/app/app.py +++ b/financial-tracker/transaction-service/app/app.py @@ -1,10 +1,502 @@ -from flask import Flask, jsonify, request +from datetime import datetime, timedelta +from flask import Flask, request, jsonify +from flask_cors import CORS +from flask_jwt_extended import ( + jwt_required, get_jwt_identity, JWTManager +) +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import Enum +import enum +import logging +import os +# Initialise Flask app app = Flask(__name__) +app.debug = True +CORS(app) + +# Configure logging +logging.basicConfig(level=logging.INFO) +app.logger.setLevel(logging.INFO) + +# Configuration +app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv( + "DATABASE_URL", "postgresql://user:password@transaction-db:5432/transaction_db" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config["JWT_SECRET_KEY"] = os.getenv( + "JWT_SECRET_KEY", "3b5e41af18179f530c5881a5191e15f0ab35eed2fefdc068fda254eed3fb1ecb" +) +app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) +app.config["JWT_TOKEN_LOCATION"] = ["headers"] +app.config["JWT_HEADER_NAME"] = "Authorization" # Default header for JWT +app.config["JWT_HEADER_TYPE"] = "Bearer" + +# Initialise Flask-JWT-Extended & SQLAlchemy +jwt = JWTManager(app) +db = SQLAlchemy(app) + + +# Define Enums for Expense model +class CurrencyEnum(enum.Enum): + USD = "USD" + EUR = "EUR" + GBP = "GBP" + JPY = "JPY" + AUD = "AUD" + CAD = "CAD" + CHF = "CHF" + CNY = "CNY" + INR = "INR" + NZD = "NZD" + MXN = "MXN" + SGD = "SGD" + HKD = "HKD" + SEK = "SEK" + NOK = "NOK" + DKK = "DKK" + KRW = "KRW" + BRL = "BRL" + BWP = "BWP" + BDT = "BDT" + BGN = "BGN" + BHD = "BHD" + BIF = "BIF" + BOB = "BOB" + CVE = "CVE" + CZK = "CZK" + DOP = "DOP" + EGP = "EGP" + ETB = "ETB" + FJD = "FJD" + GHS = "GHS" + GIP = "GIP" + GMD = "GMD" + GNF = "GNF" + GTQ = "GTQ" + HUF = "HUF" + IDR = "IDR" + ISK = "ISK" + JOD = "JOD" + KES = "KES" + KWD = "KWD" + LAK = "LAK" + LKR = "LKR" + MAD = "MAD" + MGA = "MGA" + MWK = "MWK" + MYR = "MYR" + MZN = "MZN" + NGN = "NGN" + NPR = "NPR" + OMR = "OMR" + PEN = "PEN" + PHP = "PHP" + PKR = "PKR" + PLN = "PLN" + PYG = "PYG" + QAR = "QAR" + RON = "RON" + RWF = "RWF" + SAR = "SAR" + SLE = "SLE" + SRD = "SRD" + THB = "THB" + TND = "TND" + TRY = "TRY" + TWD = "TWD" + TZS = "TZS" + UGX = "UGX" + VND = "VND" + XAF = "XAF" + XCD = "XCD" + XOF = "XOF" + ZAR = "ZAR" + ZMW = "ZMW" + +class RecurringTypeEnum(enum.Enum): + DAILY = "daily" + WEEKLY = "weekly" + FORTNIGHTLY = "every_two_weeks" + MONTHLY = "monthly" + YEARLY = "yearly" + CUSTOM = "custom" + +class FrequencyEnum(enum.Enum): + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + YEARLY = "yearly" + +class EndRepeatEnum(enum.Enum): + NEVER = "never" + ON_DATE = "on_date" + + +# DATABASE MODELS +class Expense(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, nullable=False) + amount = db.Column(db.Float, nullable=False) + currency = db.Column(db.Enum(CurrencyEnum), nullable=False) + category = db.Column(db.String(50), nullable=False) + date = db.Column(db.Date, nullable=False) + description = db.Column(db.String(255)) + + # Recurring fields + recurring = db.Column(db.Boolean, nullable=False, default=False) + recurring_type = db.Column(db.Enum(RecurringTypeEnum), nullable=True) + frequency = db.Column(db.Enum(FrequencyEnum), nullable=True) + interval = db.Column(db.Integer, nullable=True) + end_repeat = db.Column(db.Enum(EndRepeatEnum), nullable=True) + end_date = db.Column(db.Date, nullable=True) + + def to_dict(self): + """Convert object to dictionary for JSON response""" + return { + "id": self.id, + "user_id": self.user_id, + "amount": self.amount, + "currency": self.currency.value, # Convert Enum to its string value + "category": self.category, + "date": self.date.strftime('%Y-%m-%d'), + "description": self.description, + "recurring": self.recurring, + "recurring_type": self.recurring_type.value if self.recurring_type else None, + "interval": self.interval, + "end_repeat": self.end_repeat.value if self.end_repeat else None, + "end_date": self.end_date.strftime('%Y-%m-%d') if self.end_date else None + } + @app.route('/transactions', methods=['GET']) def get_transactions(): return jsonify({"message": "Transaction service active"}), 200 -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5002) + +# EXPENSES ROUTES +@app.route('/expenses', methods=['POST']) +@jwt_required() +def add_expense(): + """Add a new expense for the logged-in user""" + data = request.get_json() + app.logger.info(f"Received data: {data}") + + # Default currency to GBP if not provided + currency = data.get('currency', 'GBP') + + # Required fields check + required_fields = ["amount", "currency", "category", "date", "description", "recurring"] + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + app.logger.error(f"Missing required fields: {missing_fields}") + return jsonify({"error": f"Missing required fields: {', '.join(missing_fields)}"}), 400 + + # Validate currency code (must be a 3-letter code) + if len(currency) != 3: + app.logger.error(f"Invalid currency code: {currency}") + return jsonify({"error": "Invalid currency code"}), 400 + + try: + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"User ID: {user_id}") + + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed"}), 401 + + # Validate date format and ensure it is not in the future + try: + expense_date = datetime.strptime(data["date"], '%Y-%m-%d').date() + if expense_date > datetime.today().date(): + app.logger.error(f"Invalid date: {expense_date}. Future expenses are not allowed.") + return jsonify({"error": "Expense date cannot be in the future"}), 400 + except ValueError: + app.logger.error(f"Invalid date format: {data['date']}") + return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400 + + # Parse recurring fields if applicable + recurring = data["recurring"] + recurring_type = data.get("recurring_type") + frequency = data.get("frequency") if recurring_type == "custom" else None + interval = data.get("interval") if recurring_type == "custom" else None + end_repeat = data.get("end_repeat") + end_date = data.get("end_date") + + # Convert end_date to a Date object if provided + if end_date: + try: + end_date = datetime.strptime(end_date, '%Y-%m-%d').date() + if end_date < expense_date: + app.logger.error("End date cannot be before the expense date.") + return jsonify({"error": "End date must be after the expense date"}), 400 + except ValueError: + app.logger.error(f"Invalid end_date format: {end_date}") + return jsonify({"error": "Invalid end_date format. Use YYYY-MM-DD"}), 400 + + # Custom recurrence validation + if recurring and recurring_type == "custom": + if not frequency or not interval: + app.logger.error("Custom recurrence requires frequency and interval.") + return jsonify({"error": "Custom recurrence requires frequency and interval"}), 400 + if interval <= 0: + app.logger.error("Interval must be greater than 0.") + return jsonify({"error": "Interval must be a positive number"}), 400 + + # Create a new expense object + new_expense = Expense( + user_id=user_id, + amount=data["amount"], + currency=currency, + category=data["category"], + date=expense_date, + description=data["description"], + recurring=recurring, + recurring_type=RecurringTypeEnum(recurring_type) if recurring_type else None, + frequency=FrequencyEnum(frequency) if frequency else None, + interval=interval, + end_repeat=EndRepeatEnum(end_repeat) if end_repeat else None, + end_date=end_date + ) + + # Add expense to the database + db.session.add(new_expense) + db.session.commit() + + app.logger.info(f"Expense added: {new_expense.to_dict()}") + return jsonify({"message": "Expense added", "expense": new_expense.to_dict()}), 201 + + except ValueError as ve: + app.logger.error(f"Value error: {str(ve)}") + return jsonify({"error": "Invalid value provided"}), 400 + + except Exception as e: + app.logger.error(f"Error while adding expense: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while adding the expense"}), 500 + + + +@app.route('/expenses', methods=['GET']) +@jwt_required() +def get_expenses(): + """Retrieve all expenses for the logged-in user""" + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + user_id = int(user_id) + app.logger.info(f"Retrieved user_id from JWT: {user_id}") # Log user_id + + if not user_id: + app.logger.error("User ID is None or invalid.") + return jsonify({"error": "Invalid user authentication"}), 401 + + expenses = Expense.query.filter_by(user_id=user_id).all() + app.logger.info(f"Found {len(expenses)} expenses for user_id {user_id}") + + expenses_list = [expense.to_dict() for expense in expenses] + return jsonify({"expenses": expenses_list}), 200 + except Exception as e: + app.logger.error(f"Error retrieving expenses: {e}", exc_info=True) + return jsonify({"error": "An error occurred while fetching expenses"}), 500 + + +@app.route('/expenses/<int:expense_id>', methods=['PUT']) +@jwt_required() +def edit_expense(expense_id): + """Edit an expense by ID for the logged-in user""" + try: + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"User ID: {user_id}") + + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed"}), 401 + + expense = Expense.query.filter_by(id=expense_id, user_id=user_id).first() + + if not expense: + app.logger.error(f"Expense not found for ID {expense_id} and User ID {user_id}") + return jsonify({"error": "Expense not found"}), 404 + + data = request.json + + app.logger.info(f"Received data: {data}") + # Validate date if it is being updated + if "date" in data: + try: + new_date = datetime.strptime(data["date"], '%Y-%m-%d').date() + if new_date > datetime.today().date(): + app.logger.error(f"Invalid date: {new_date}. Future expenses are not allowed.") + return jsonify({"error": "Expense date cannot be in the future"}), 400 + expense.date = new_date + except ValueError: + app.logger.error(f"Invalid date format: {data['date']}") + return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400 + + # Validate end_date if updated + if "end_date" in data: + try: + new_end_date = datetime.strptime(data["end_date"], '%Y-%m-%d').date() + if new_end_date < expense.date: + app.logger.error("End date cannot be before the expense date.") + return jsonify({"error": "End date must be after the expense date"}), 400 + expense.end_date = new_end_date + except ValueError: + app.logger.error(f"Invalid end_date format: {data['end_date']}") + return jsonify({"error": "Invalid end_date format. Use YYYY-MM-DD"}), 400 + + # Validate custom recurrence fields + if expense.recurring and expense.recurring_type == RecurringTypeEnum.CUSTOM: + if "interval" in data: + try: + interval_value = int(data["interval"]) # Convert to integer + if interval_value <= 0: + app.logger.error("Interval must be greater than 0.") + return jsonify({"error": "Interval must be a positive number"}), 400 + expense.interval = interval_value # Assign as an integer + except ValueError: + app.logger.error("Interval must be a valid integer.") + return jsonify({"error": "Interval must be a valid integer"}), 400 + + # Setting the recurring type enum + if expense.recurring: + recurring_type = data.get("recurring_type", None) + if recurring_type: + if recurring_type.lower() == 'daily': + expense.recurring_type = RecurringTypeEnum.DAILY + elif recurring_type.lower() == 'weekly': + expense.recurring_type = RecurringTypeEnum.WEEKLY + elif recurring_type.lower() == "every_two_weeks": + expense.recurring_type = RecurringTypeEnum.FORTNIGHTLY + elif recurring_type.lower() == 'monthly': + expense.recurring_type = RecurringTypeEnum.MONTHLY + elif recurring_type.lower() == 'yearly': + expense.recurring_type = RecurringTypeEnum.YEARLY + elif recurring_type.lower() == 'custom': + expense.recurring_type = RecurringTypeEnum.CUSTOM + else: + app.logger.error(f"Invalid recurring type: {recurring_type}") + return jsonify({"error": "Invalid recurring type"}), 400 + + # Setting the frequency enum + if expense.recurring and expense.recurring_type == RecurringTypeEnum.CUSTOM: + frequency = data.get("frequency", None) + if frequency: + if frequency.lower() == 'daily': + expense.frequency = FrequencyEnum.DAILY + elif frequency.lower() == 'weekly': + expense.frequency = FrequencyEnum.WEEKLY + elif frequency.lower() == 'monthly': + expense.frequency = FrequencyEnum.MONTHLY + elif frequency.lower() == 'yearly': + expense.frequency = FrequencyEnum.YEARLY + else: + app.logger.error(f"Invalid frequency: {frequency}") + return jsonify({"error": "Invalid frequency"}), 400 + + # Setting the end_repeat enum + if expense.recurring: + end_repeat = data.get("end_repeat", None) + if end_repeat: + if end_repeat.lower() == 'never': + expense.end_repeat = EndRepeatEnum.NEVER + expense.end_date = None + elif end_repeat.lower() == 'on_date': + expense.end_repeat = EndRepeatEnum.ON_DATE + else: + app.logger.error(f"Invalid end_repeat value: {end_repeat}") + return jsonify({"error": "Invalid end_repeat value"}), 400 + else: + app.logger.error("end_repeat is missing") + return jsonify({"error": "end_repeat is required"}), 400 + + # Update other fields + expense.category = data.get("category", expense.category) + expense.amount = data.get("amount", expense.amount) + expense.description = data.get("description", expense.description) + + db.session.commit() + + app.logger.info(f"Expense updated: {expense.to_dict()}") + return jsonify({"message": "Expense updated successfully", "expense": expense.to_dict()}), 200 + + except Exception as e: + app.logger.error(f"Error updating expense: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while updating the expense"}), 500 + + + +@app.route('/expenses/<int:expense_id>', methods=['DELETE']) +@jwt_required() +def delete_expense(expense_id): + """Delete an expense by ID for the logged-in user""" + try: + # Get user_id from JWT token + user_id = get_jwt_identity() + user_id = int(user_id) + app.logger.info(f"User ID: {user_id}") + + if not user_id: + app.logger.error("User ID is missing or invalid.") + return jsonify({"error": "User authentication failed"}), 401 + + # Fetch the expense from the database + expense = Expense.query.filter_by(id=expense_id, user_id=user_id).first() + + if not expense: + app.logger.error(f"Expense not found for ID {expense_id} and User ID {user_id}") + return jsonify({"error": "Expense not found"}), 404 + + # Delete the expense from the database + db.session.delete(expense) + db.session.commit() + + app.logger.info(f"Expense deleted: {expense.to_dict()}") + return jsonify({"message": "Expense deleted successfully"}), 200 + + except Exception as e: + app.logger.error(f"Error deleting expense: {str(e)}", exc_info=True) + return jsonify({"error": "An error occurred while deleting the expense"}), 500 + + +@app.route('/categories', methods=['GET']) +@jwt_required() +def get_categories(): + """Retrieve distinct categories for the logged-in user""" + try: + user_id = get_jwt_identity() # Extract user_id from JWT token + user_id = int(user_id) + app.logger.info(f"Retrieved user_id from JWT: {user_id}") # Log user_id + + if not user_id: + app.logger.error("User ID is None or invalid.") + return jsonify({"error": "Invalid user authentication"}), 401 + + categories = db.session.query(Expense.category).filter_by(user_id=user_id).distinct().all() + category_list = [category[0] for category in categories] + app.logger.info(f"Category list: {category_list}") + + return jsonify({"categories": category_list}), 200 + except Exception as e: + app.logger.error(f"Error retrieving categories: {e}", exc_info=True) + return jsonify({"error": "An error occurred while fetching categories"}), 500 + + +# HEALTH CHECK ROUTE +@app.route('/health') +def health(): + """Health check endpoint to verify service is running.""" + app.logger.info("Health check requested") + return jsonify({"status": "User service is running"}), 200 + + +# INITIALIZATION & RUNNING APP +if __name__ == "__main__": + # Create all tables in the database (if they don't exist) + with app.app_context(): + db.create_all() # Creates the tables for all models + + app.logger.info("Starting Transaction Service...") + app.run(host='0.0.0.0', port=5002, debug=True)