├── shared
├── constants
│ └── constans.ts
└── types
│ └── types.ts
├── frontend
├── src
│ ├── vite-env.d.ts
│ ├── assets
│ │ └── logo512.png
│ ├── types
│ │ └── types.ts
│ ├── components
│ │ ├── Loading.tsx
│ │ ├── ErrorBoundary.tsx
│ │ ├── ExpenseItem.tsx
│ │ ├── UpdateExpenseModal.tsx
│ │ ├── ThemeSwitch.tsx
│ │ ├── WeeklyChart.tsx
│ │ └── Navbar.tsx
│ ├── layouts
│ │ └── MainLayout.tsx
│ ├── pages
│ │ ├── 404.tsx
│ │ ├── Login.tsx
│ │ ├── Home.tsx
│ │ ├── Register.tsx
│ │ ├── Add.tsx
│ │ └── UserProfile.tsx
│ ├── app
│ │ └── store.ts
│ ├── main.tsx
│ ├── utils
│ │ └── currencyFormatter.ts
│ ├── styles
│ │ └── index.css
│ ├── hooks
│ │ └── useSystemTheme.ts
│ ├── features
│ │ ├── settings
│ │ │ └── settingsSlice.ts
│ │ ├── expenses
│ │ │ ├── expenseService.ts
│ │ │ └── expenseSlice.ts
│ │ └── auth
│ │ │ ├── authService.ts
│ │ │ └── authSlice.ts
│ └── App.tsx
├── postcss.config.js
├── tsconfig.json
├── .gitignore
├── tsconfig.node.json
├── index.html
├── .eslintrc.cjs
├── tsconfig.app.json
├── package.json
├── vite.config.ts
├── README.md
├── tailwind.config.ts
└── public
│ └── logo.svg
├── .prettierignore
├── .prettierrc
├── screenshots
├── logo.png
├── console.png
└── preview.png
├── .gitignore
├── .env.example
├── backend
├── config
│ └── db.ts
├── tsconfig.json
├── middleware
│ ├── errorMiddleware.ts
│ ├── authMiddleware.ts
│ └── upload.ts
├── routes
│ ├── expenseRoutes.ts
│ └── userRoutes.ts
├── types
│ └── types.ts
├── models
│ ├── user.ts
│ └── expense.ts
├── server.ts
└── controllers
│ ├── expenseController.ts
│ └── userController.ts
├── LICENSE
├── package.json
└── README.md
/shared/constants/constans.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | frontend/dist
3 | frontend/node_modules
4 | backend/dist
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "printWidth": 100,
4 | "tabWidth": 2
5 | }
6 |
--------------------------------------------------------------------------------
/screenshots/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/screenshots/logo.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | .env
4 | *.env
5 | *.env.local
6 | *.env.staging
7 |
8 | uploads/*
9 |
--------------------------------------------------------------------------------
/screenshots/console.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/screenshots/console.png
--------------------------------------------------------------------------------
/screenshots/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/screenshots/preview.png
--------------------------------------------------------------------------------
/frontend/src/assets/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/frontend/src/assets/logo512.png
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/src/types/types.ts:
--------------------------------------------------------------------------------
1 | export interface Settings {
2 | theme: "system" | "light" | "dark";
3 | currency: `${string}${string}${string}`;
4 | }
5 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 | PORT=8000
3 | MONGO_URI="Your MongoDB URI (mongodb+srv://[username:password@]host[/[defaultauthdb][?options]])"
4 | JWT_SECRET="Your JWT secret"
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | function Loading() {
2 | return (
3 |
4 |
5 |
6 | );
7 | }
8 |
9 | export default Loading;
10 |
--------------------------------------------------------------------------------
/frontend/src/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "../components/Navbar";
2 |
3 | function MainLayout({ children }: { children: React.ReactNode }) {
4 | return (
5 |
6 |
7 | {children}
8 |
9 | );
10 | }
11 |
12 | export default MainLayout;
13 |
--------------------------------------------------------------------------------
/frontend/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | function NotFound() {
2 | return (
3 |
4 |
5 |
404
6 |
Page not found
7 |
8 |
9 | );
10 | }
11 |
12 | export default NotFound;
13 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | dev-dist
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/backend/config/db.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 | import "dotenv/config";
3 |
4 | const connectDB = async () => {
5 | try {
6 | await mongoose.connect((process.env.MONGO_URI as string) || "");
7 | console.log(`🟢 Connected to MongoDB!`);
8 | } catch (error) {
9 | console.log("Error connecting to MongoDB:", error);
10 | process.exit(1);
11 | }
12 | };
13 | export default connectDB;
14 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "CommonJS",
4 | "target": "ES2016",
5 | "outDir": "./dist",
6 | "esModuleInterop": true,
7 | "composite": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "skipLibCheck": true,
11 | "baseUrl": ".",
12 | "rootDir": "../",
13 | "paths": {
14 | "@shared/*": ["../shared/*"]
15 | }
16 | },
17 | "include": ["./**/*", "../shared/**/*"],
18 | "exclude": ["./dist"]
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 | Expense Tracker
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/backend/middleware/errorMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from "express";
2 |
3 | interface ErrorResponse {
4 | message: string;
5 | stack?: string;
6 | }
7 |
8 | export const errorHandler = (
9 | err: ErrorResponse,
10 | req: Request,
11 | res: Response,
12 | next: NextFunction,
13 | ) => {
14 | const statusCode = res.statusCode || 500;
15 | res.status(statusCode).json({
16 | message: err.message,
17 | stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/backend/routes/expenseRoutes.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import {
3 | createExpense,
4 | deleteExpense,
5 | getAllExpenses,
6 | updateExpense,
7 | } from "../controllers/expenseController";
8 | import protect from "../middleware/authMiddleware";
9 |
10 | const router = Router();
11 |
12 | router.post("/", protect, createExpense);
13 |
14 | router.get("/", protect, getAllExpenses);
15 |
16 | router.put("/:id", protect, updateExpense);
17 |
18 | router.delete("/:id", protect, deleteExpense);
19 |
20 | export { router as ExpenseRouter };
21 |
--------------------------------------------------------------------------------
/backend/types/types.ts:
--------------------------------------------------------------------------------
1 | import type { Request } from "express";
2 |
3 | export interface User {
4 | name: string;
5 | email: string;
6 | password: string;
7 | profilePicture?: string;
8 | }
9 |
10 | export interface Expense {
11 | user: User;
12 | text: string;
13 | customDate?: string;
14 | type: "income" | "expense";
15 | amount: number;
16 | }
17 |
18 | export interface UserData {
19 | id: string;
20 | name: string;
21 | email: string;
22 | }
23 | export interface AuthenticatedRequest extends Request {
24 | user: UserData;
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/app/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import authReducer from "../features/auth/authSlice";
3 | import expenseReducer from "../features/expenses/expenseSlice";
4 | import settingsReducer from "../features/settings/settingsSlice";
5 |
6 | export const store = configureStore({
7 | reducer: {
8 | auth: authReducer,
9 | expenses: expenseReducer,
10 | settings: settingsReducer,
11 | },
12 | });
13 | export type AppDispatch = typeof store.dispatch;
14 | export type RootState = ReturnType;
15 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom/client";
2 | import App from "./App.tsx";
3 | import "./styles/index.css";
4 | import { BrowserRouter } from "react-router-dom";
5 | import { Provider } from "react-redux";
6 | import { store } from "./app/store.ts";
7 | import { IconContext } from "react-icons";
8 | ReactDOM.createRoot(document.getElementById("root")!).render(
9 |
10 |
11 |
12 |
13 | {" "}
14 |
15 | ,
16 | );
17 |
--------------------------------------------------------------------------------
/frontend/src/utils/currencyFormatter.ts:
--------------------------------------------------------------------------------
1 | import { store } from "../app/store";
2 | import { RootState } from "../app/store";
3 | import { Settings } from "../types/types";
4 |
5 | // Function to get settings from the store
6 | function getSettings(): Settings {
7 | const state = store.getState() as RootState;
8 | return state.settings.settings;
9 | }
10 |
11 | // Function to format currency based on settings
12 | export function formatCurrency(amount: number): string {
13 | const settings = getSettings();
14 | const formatter = new Intl.NumberFormat(navigator.language || "en-US", {
15 | style: "currency",
16 | currency: settings.currency || "USD",
17 | });
18 | return formatter.format(amount);
19 | }
20 |
--------------------------------------------------------------------------------
/backend/models/user.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 | import { User } from "@shared/types/types";
3 |
4 | const userSchema = new mongoose.Schema(
5 | {
6 | name: {
7 | type: String,
8 | required: [true, "Username is required"],
9 | default: "User",
10 | },
11 | profilePicture: {
12 | type: String,
13 | default: undefined,
14 | },
15 | email: {
16 | type: String,
17 | required: [true, "Email is required"],
18 | unique: true,
19 | },
20 | password: {
21 | type: String,
22 | required: [true, "Password is required"],
23 | minlength: [4, "Password must be at least 4 characters"],
24 | },
25 | },
26 | { timestamps: true },
27 | );
28 | const User = mongoose.model("User", userSchema);
29 | export default User;
30 |
--------------------------------------------------------------------------------
/backend/routes/userRoutes.ts:
--------------------------------------------------------------------------------
1 | import { Router } from "express";
2 | import {
3 | getUserData,
4 | loginUser,
5 | registerUser,
6 | removeProfilePicture,
7 | updateUser,
8 | uploadProfilePicture,
9 | } from "../controllers/userController";
10 | import protect from "../middleware/authMiddleware";
11 | import upload from "../middleware/upload";
12 |
13 | const router = Router();
14 |
15 | router.post("/", registerUser);
16 | router.post("/login", loginUser);
17 | router.get("/me", protect, getUserData);
18 | router.put("/update", protect, updateUser);
19 | router.post(
20 | "/upload-profile-picture",
21 | protect,
22 | upload.single("profilePicture"),
23 | uploadProfilePicture,
24 | );
25 |
26 | router.post("/remove-profile-picture", protect, removeProfilePicture);
27 |
28 | export { router as UserRouter };
29 |
--------------------------------------------------------------------------------
/frontend/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "baseUrl": ".",
22 | "paths": {
23 | "@shared/*": ["../shared/*"]
24 | }
25 | },
26 | "include": ["src", "../shared/**/*"]
27 | }
28 |
--------------------------------------------------------------------------------
/backend/models/expense.ts:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose";
2 | import type { Expense } from "@shared/types/types";
3 |
4 | const expenseSchema = new mongoose.Schema(
5 | {
6 | user: {
7 | type: mongoose.Schema.Types.ObjectId,
8 | required: true,
9 | ref: "User",
10 | },
11 | text: {
12 | type: String,
13 | required: [true, "Expense text is required"],
14 | },
15 | customDate: {
16 | type: String,
17 | },
18 | type: {
19 | type: String,
20 | required: [true, "Expense type is required"],
21 | enum: ["income", "expense"],
22 | default: "expense",
23 | },
24 | amount: {
25 | type: Number,
26 | required: [true, "Expense amount is required"],
27 | default: 0,
28 | },
29 | },
30 | { timestamps: true },
31 | );
32 |
33 | const Expense = mongoose.model("Expense", expenseSchema);
34 | export default Expense;
35 |
--------------------------------------------------------------------------------
/frontend/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600;700&display=swap");
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | :root {
7 | font-family: "Poppins", sans-serif;
8 | line-height: 1.5;
9 | font-weight: 400;
10 | color-scheme: light dark;
11 | font-synthesis: none;
12 | text-rendering: optimizeLegibility;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | }
16 |
17 | [data-theme="lightTheme"] {
18 | color-scheme: light;
19 | }
20 |
21 | [data-theme="darkTheme"] {
22 | color-scheme: dark;
23 | }
24 |
25 | * ::selection {
26 | @apply bg-primary;
27 | @apply text-primary-content;
28 | }
29 |
30 | .tooltip::before {
31 | @apply p-2 rounded-md;
32 | }
33 | .tooltip::after {
34 | @apply hidden;
35 | }
36 |
37 | .sidebar-btn {
38 | @apply block p-2 text-lg hover:bg-base-300 rounded w-full text-left;
39 | }
40 |
--------------------------------------------------------------------------------
/shared/types/types.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | name: string;
3 | email: string;
4 | password: string;
5 | profilePicture?: string;
6 | }
7 |
8 | export interface Expense {
9 | user: User;
10 | text: string;
11 | customDate?: string;
12 | type: "income" | "expense";
13 | amount: number;
14 | }
15 |
16 | export interface MongoDocument {
17 | _id: string;
18 | createdAt: string;
19 | updatedAt: string;
20 | }
21 |
22 | export interface ExpenseDocument extends MongoDocument, Expense {}
23 | export interface UserDocument extends MongoDocument, User {}
24 | export interface UserDataDocument extends MongoDocument, UserData {}
25 |
26 | export interface UserData {
27 | name: string;
28 | email: string;
29 | password: string;
30 | profilePicture?: string;
31 | token?: string;
32 | }
33 |
34 | export interface AuthResponse {
35 | _id: string;
36 | name: string;
37 | email: string;
38 | profilePicture?: string;
39 | token: string;
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useSystemTheme.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | type Theme = "light" | "dark" | "unknown";
4 |
5 | /**
6 | * A React hook to detect the system theme preference.
7 | * @returns The current system theme ('light', 'dark', or 'unknown').
8 | */
9 | export const useSystemTheme = (): Theme => {
10 | const [theme, setTheme] = useState("unknown");
11 | useEffect(() => {
12 | const mediaQueryListener = (e: MediaQueryListEvent) => {
13 | setTheme(e.matches ? "dark" : "light");
14 | };
15 |
16 | const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
17 | setTheme(prefersDarkScheme.matches ? "dark" : "light");
18 |
19 | // Listen for changes in system theme
20 | prefersDarkScheme.addEventListener("change", mediaQueryListener);
21 |
22 | return () => {
23 | prefersDarkScheme.removeEventListener("change", mediaQueryListener);
24 | };
25 | }, []);
26 |
27 | return theme;
28 | };
29 |
--------------------------------------------------------------------------------
/backend/server.ts:
--------------------------------------------------------------------------------
1 | import cors from "cors";
2 | import "dotenv/config";
3 | import express from "express";
4 | import path from "path";
5 | import connectDB from "./config/db";
6 | import { errorHandler } from "./middleware/errorMiddleware";
7 | import { ExpenseRouter } from "./routes/expenseRoutes";
8 | import { UserRouter } from "./routes/userRoutes";
9 |
10 | const app = express();
11 | const port = process.env.PORT || 8000;
12 |
13 | // Connect to the database
14 | connectDB();
15 |
16 | // Middleware
17 | app.use(cors());
18 | app.use(express.json());
19 | app.use(express.urlencoded({ extended: true }));
20 |
21 | // Static file serving
22 | app.use("/uploads", express.static(path.join("uploads")));
23 |
24 | // Routes
25 | app.use("/api/expenses", ExpenseRouter);
26 | app.use("/api/users", UserRouter);
27 |
28 | // Error handling middleware
29 | app.use(errorHandler);
30 |
31 | // Default route
32 | app.get("/", (req, res) => {
33 | res.send("hello world");
34 | });
35 |
36 | // Start server
37 | app.listen(port, () => {
38 | console.log(`🌐 Server running on http://localhost:${port}`);
39 | });
40 |
--------------------------------------------------------------------------------
/frontend/src/features/settings/settingsSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit";
2 | import { Settings } from "../../types/types";
3 |
4 | const loadSettingsFromLocalStorage = (): Settings => {
5 | const savedSettings = localStorage.getItem("settings");
6 | return savedSettings ? JSON.parse(savedSettings) : { theme: "system", currency: "USD" };
7 | };
8 |
9 | const defaultSettings: Settings = loadSettingsFromLocalStorage();
10 |
11 | export const initialState = {
12 | settings: defaultSettings,
13 | };
14 |
15 | export const settingsSlice = createSlice({
16 | name: "settings",
17 | initialState,
18 | reducers: {
19 | reset: (state) => {
20 | localStorage.removeItem("settings");
21 | state.settings = defaultSettings;
22 | },
23 | updateSettings: (state, action: PayloadAction) => {
24 | state.settings = action.payload;
25 | localStorage.setItem("settings", JSON.stringify(action.payload));
26 | },
27 | },
28 | });
29 |
30 | export const { reset, updateSettings } = settingsSlice.actions;
31 | export default settingsSlice.reducer;
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Maciej Twarog (github.com/maciekt07)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/backend/middleware/authMiddleware.ts:
--------------------------------------------------------------------------------
1 | import jwt from "jsonwebtoken";
2 | import asyncHandler from "express-async-handler";
3 | import User from "../models/user";
4 | import type { Request } from "express";
5 |
6 | interface DecodedToken {
7 | id: string;
8 | iat: number;
9 | exp: number;
10 | }
11 |
12 | interface CustomRequest extends Request {
13 | user?: any;
14 | }
15 |
16 | const protect = asyncHandler(async (req: CustomRequest, res, next) => {
17 | let token;
18 |
19 | if (req.headers.authorization && req.headers.authorization.startsWith("Bearer")) {
20 | try {
21 | // get token from header
22 | token = req.headers.authorization.split(" ")[1];
23 | // verify token
24 | const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as DecodedToken;
25 | // get user from token
26 | req.user = await User.findById(decoded.id).select("-password");
27 | next();
28 | } catch (error) {
29 | console.log(error);
30 | res.status(401);
31 | throw new Error("Unauthorized");
32 | }
33 | }
34 | if (!token) {
35 | res.status(401);
36 | throw new Error("Unauthorized, no token");
37 | }
38 | });
39 |
40 | export default protect;
41 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "devhost": "vite --host",
9 | "build": "tsc -b && vite build",
10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
11 | "preview": "vite preview"
12 | },
13 | "dependencies": {
14 | "@reduxjs/toolkit": "^2.2.6",
15 | "axios": "^1.7.2",
16 | "react": "^18.3.1",
17 | "react-dom": "^18.3.1",
18 | "react-hot-toast": "^2.4.1",
19 | "react-icons": "^5.2.1",
20 | "react-redux": "^9.1.2",
21 | "react-router-dom": "^6.25.1"
22 | },
23 | "devDependencies": {
24 | "@types/react": "^18.3.3",
25 | "@types/react-dom": "^18.3.0",
26 | "@typescript-eslint/eslint-plugin": "^7.15.0",
27 | "@typescript-eslint/parser": "^7.15.0",
28 | "@vitejs/plugin-react": "^4.3.1",
29 | "autoprefixer": "^10.4.19",
30 | "daisyui": "^4.12.10",
31 | "eslint": "^8.57.0",
32 | "eslint-plugin-react-hooks": "^4.6.2",
33 | "eslint-plugin-react-refresh": "^0.4.7",
34 | "postcss": "^8.4.39",
35 | "tailwindcss": "^3.4.6",
36 | "typescript": "^5.2.2",
37 | "vite": "^5.3.4",
38 | "vite-plugin-pwa": "^0.20.1"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { VitePWA } from "vite-plugin-pwa";
4 | import path from "path";
5 |
6 | // https://vitejs.dev/config/
7 | export default defineConfig({
8 | plugins: [
9 | react(),
10 | VitePWA({
11 | devOptions: {
12 | enabled: true,
13 | type: "module",
14 | },
15 | registerType: "autoUpdate",
16 | includeAssets: ["**/*"],
17 | manifest: {
18 | name: "Expense Tracker",
19 | short_name: "Expense Tracker",
20 | theme_color: "#0061FF",
21 | //TODO: Add icons
22 | icons: [
23 | {
24 | src: "/logo.svg",
25 | sizes: "512x512",
26 | type: "image/svg+xml",
27 | },
28 | {
29 | src: "/logo.png",
30 | sizes: "512x512",
31 | type: "image/png",
32 | },
33 | ],
34 | },
35 | }),
36 | ],
37 | resolve: {
38 | alias: {
39 | "@shared": path.resolve(__dirname, "../shared"), // Add alias for shared folder
40 | },
41 | },
42 | server: {
43 | proxy: {
44 | "/api": "http://localhost:8000",
45 | "/uploads": {
46 | target: "http://localhost:8000",
47 | changeOrigin: true,
48 | rewrite: (path) => path.replace(/^\/uploads/, "/uploads"),
49 | },
50 | },
51 | },
52 | });
53 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default {
18 | // other rules...
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | sourceType: 'module',
22 | project: ['./tsconfig.json', './tsconfig.node.json', './tsconfig.app.json'],
23 | tsconfigRootDir: __dirname,
24 | },
25 | }
26 | ```
27 |
28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expense-tracker",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "backend/server.ts",
6 | "scripts": {
7 | "dev": "concurrently --names \"SERVER,CLIENT\" --prefix \"[{name}]\" --prefix-colors \"yellow,blue\" \"npm run server\" \"npm run client\"",
8 | "format": "prettier --write \"frontend/**/*.{ts,tsx,css,md,json}\" && prettier --write \"backend/**/*.{ts,tsx,json}\"",
9 | "client": "npm run dev --prefix frontend",
10 | "server": "nodemon backend/server.ts",
11 | "build": "tsc --build backend",
12 | "start": "node /backend/dist/server.js"
13 | },
14 | "keywords": [],
15 | "author": "github.com/maciekt07",
16 | "license": "MIT",
17 | "paths": {
18 | "@shared/*": [
19 | "shared/*"
20 | ]
21 | },
22 | "devDependencies": {
23 | "@types/bcrypt": "^5.0.2",
24 | "@types/bcryptjs": "^2.4.6",
25 | "@types/cors": "^2.8.17",
26 | "@types/express": "^4.17.21",
27 | "@types/jsonwebtoken": "^9.0.6",
28 | "@types/mongoose": "^5.11.97",
29 | "@types/multer": "^1.4.11",
30 | "@types/node": "^20.14.11",
31 | "concurrently": "^8.2.2",
32 | "nodemon": "^3.1.4",
33 | "prettier": "^3.3.3",
34 | "ts-node": "^10.9.2",
35 | "typescript": "^5.5.3"
36 | },
37 | "dependencies": {
38 | "bcrypt": "^5.1.1",
39 | "cors": "^2.8.5",
40 | "dotenv": "^16.4.5",
41 | "express": "^4.19.2",
42 | "express-async-handler": "^1.2.0",
43 | "js": "^0.1.0",
44 | "jsonwebtoken": "^9.0.2",
45 | "mongoose": "^8.5.1",
46 | "multer": "^1.4.5-lts.1"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/components/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import React, { ErrorInfo } from "react";
2 |
3 | interface ErrorBoundaryProps {
4 | children: React.ReactNode;
5 | }
6 |
7 | interface ErrorBoundaryState {
8 | hasError: boolean;
9 | error?: Error;
10 | }
11 |
12 | /**
13 | * ErrorBoundary component that catches and displays errors.
14 | */
15 |
16 | class ErrorBoundary extends React.Component {
17 | constructor(props: ErrorBoundaryProps) {
18 | super(props);
19 | this.state = {
20 | hasError: false,
21 | };
22 | }
23 |
24 | static getDerivedStateFromError(error: Error): ErrorBoundaryState {
25 | return {
26 | hasError: true,
27 | error: error,
28 | };
29 | }
30 |
31 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
32 | console.error("Error:", error);
33 | console.error("Error Info:", errorInfo);
34 | }
35 |
36 | handleClearData() {
37 | localStorage.clear();
38 | sessionStorage.clear();
39 | location.reload();
40 | }
41 |
42 | render() {
43 | if (this.state.hasError) {
44 | return (
45 |
46 |
Oops! An error occurred.
47 |
48 | To fix it, try clearing your local files (cookies and cache) and then refresh the page.
49 |
50 |
{this.state.error?.message}
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | return this.props.children;
58 | }
59 | }
60 |
61 | export default ErrorBoundary;
62 |
--------------------------------------------------------------------------------
/frontend/src/components/ExpenseItem.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useDispatch } from "react-redux";
3 | import { AppDispatch } from "../app/store";
4 | import { deleteExpense } from "../features/expenses/expenseSlice";
5 | import { ExpenseDocument } from "@shared/types/types";
6 | import { FaTrashCan } from "react-icons/fa6";
7 | import { formatCurrency } from "../utils/currencyFormatter";
8 | import UpdateExpenseModal from "./UpdateExpenseModal";
9 |
10 | function ExpenseItem({ expense }: { expense: ExpenseDocument }) {
11 | const dispatch = useDispatch();
12 | const [isModalOpen, setIsModalOpen] = useState(false);
13 |
14 | const handleDelete = () => {
15 | dispatch(deleteExpense(expense._id));
16 | };
17 |
18 | return (
19 |
20 |
{expense.text}
21 |
22 | {expense.type === "expense" ? "-" : "+"}
23 | {formatCurrency(expense.amount)}
24 |
25 |
26 |
29 |
32 |
33 |
setIsModalOpen(false)}
37 | />
38 |
39 | );
40 | }
41 |
42 | export default ExpenseItem;
43 |
--------------------------------------------------------------------------------
/frontend/src/features/expenses/expenseService.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { Expense } from "@shared/types/types";
3 |
4 | const API_URL = "api/expenses/";
5 |
6 | // Reusable config function
7 | const createConfig = (token: string) => ({
8 | headers: {
9 | Authorization: `Bearer ${token}`,
10 | },
11 | });
12 |
13 | // Create new expense
14 | const createExpense = async (expenseData: Expense, token: string) => {
15 | const response = await axios.post(API_URL, expenseData, createConfig(token));
16 | return response.data;
17 | };
18 |
19 | // get user expenses
20 | const getExpenses = async (token: string) => {
21 | const response = await axios.get(API_URL, createConfig(token));
22 | return response.data;
23 | };
24 |
25 | const getExpenseDetails = async (expenseId: string, token: string) => {
26 | try {
27 | const response = await axios.get(`${API_URL}${expenseId}`, createConfig(token));
28 | return response.data;
29 | } catch (error) {
30 | console.error("Error fetching expense details:", error);
31 | throw error;
32 | }
33 | };
34 |
35 | // delete expense
36 | const deleteExpense = async (expenseId: string, token: string) => {
37 | const response = await axios.delete(API_URL + expenseId, createConfig(token));
38 | return response.data;
39 | };
40 |
41 | // Add this function
42 | const updateExpense = async (expenseData: Expense, token: string) => {
43 | const response = await axios.put(
44 | `${API_URL}${expenseData._id}`,
45 | expenseData,
46 | createConfig(token),
47 | );
48 | return response.data;
49 | };
50 |
51 | const expenseService = {
52 | createExpense,
53 | getExpenses,
54 | getExpenseDetails,
55 | deleteExpense,
56 | updateExpense,
57 | };
58 | export default expenseService;
59 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import daisyui from "daisyui";
2 | import type { Config } from "tailwindcss";
3 |
4 | export default {
5 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
6 | plugins: [daisyui],
7 | daisyui: {
8 | themes: [
9 | {
10 | lightTheme: {
11 | primary: "#0061FF",
12 | "primary-content": "#ffffff",
13 | secondary: "#498EFF",
14 | "secondary-content": "#ffffff",
15 | accent: "#00cdb7",
16 | "accent-content": "#ffffff",
17 | neutral: "#2a323c",
18 | "neutral-content": "#ffffff",
19 | "base-100": "#ffffff",
20 | "base-200": "#f0f0f0",
21 | "base-300": "#e0e0e0",
22 | "base-content": "#1d232a",
23 | info: "#1e90ff",
24 | "info-content": "#ffffff",
25 | success: "#28a745",
26 | "success-content": "#ffffff",
27 | warning: "#ffc107",
28 | "warning-content": "#ffffff",
29 | error: "#dc3545",
30 | "error-content": "#ffffff",
31 | },
32 | },
33 | {
34 | darkTheme: {
35 | primary: "#0061FF",
36 | "primary-content": "#ffffff",
37 | secondary: "#498EFF",
38 | "secondary-content": "#ffffff",
39 | accent: "#00cdb7",
40 | "accent-content": "#ffffff",
41 | neutral: "#2a323c",
42 | "neutral-content": "#ffffff",
43 | "base-100": "#1d232a",
44 | "base-200": "#2a323c",
45 | "base-300": "#3a3f45",
46 | "base-content": "#e0e0e0",
47 | info: "#1e90ff",
48 | "info-content": "#ffffff",
49 | success: "#28a745",
50 | "success-content": "#ffffff",
51 | warning: "#ffc107",
52 | "warning-content": "#ffffff",
53 | error: "#dc3545",
54 | "error-content": "#ffffff",
55 | },
56 | },
57 | ],
58 | darkTheme: "darkTheme",
59 | base: true,
60 | styled: true,
61 | utils: true,
62 | prefix: "",
63 | themeRoot: ":root",
64 | },
65 | } satisfies Config;
66 |
--------------------------------------------------------------------------------
/backend/middleware/upload.ts:
--------------------------------------------------------------------------------
1 | import multer, { FileFilterCallback } from "multer";
2 | import path from "path";
3 | import fs from "fs/promises";
4 | import { Request } from "express";
5 | import { AuthenticatedRequest } from "../types/types";
6 |
7 | // Create uploads directory if it doesn't exist
8 | const uploadDir = "uploads";
9 |
10 | const createUploadsDir = async (): Promise => {
11 | try {
12 | await fs.mkdir(uploadDir, { recursive: true });
13 | } catch (err) {
14 | console.error("Failed to create directory:", err);
15 | }
16 | };
17 |
18 | createUploadsDir();
19 |
20 | // Helper function to delete all files with the same user ID
21 | const deleteOldFiles = async (userId: string): Promise => {
22 | try {
23 | const files = await fs.readdir(uploadDir);
24 | await Promise.all(
25 | files
26 | .filter((file) => file.startsWith(userId))
27 | .map((file) => fs.unlink(path.join(uploadDir, file))),
28 | );
29 | console.log(`Deleted old files for user ID: ${userId}`);
30 | } catch (err) {
31 | console.error("Failed to delete files:", err);
32 | }
33 | };
34 |
35 | // File filter function to allow only image formats
36 | const imageFileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallback): void => {
37 | const allowedMimeTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
38 | if (allowedMimeTypes.includes(file.mimetype)) {
39 | cb(null, true);
40 | } else {
41 | cb(new Error("Invalid file type. Only image files are allowed."));
42 | }
43 | };
44 |
45 | const storage = multer.diskStorage({
46 | destination: (req, file, cb) => {
47 | cb(null, uploadDir);
48 | },
49 | filename: async (req, file, cb) => {
50 | const AuthReq = req as AuthenticatedRequest;
51 | const userId = AuthReq.user ? AuthReq.user.id : Date.now().toString(); // Fallback to timestamp if user ID is not available
52 |
53 | try {
54 | await deleteOldFiles(userId);
55 | cb(null, `${userId}${path.extname(file.originalname)}`);
56 | } catch (err) {
57 | cb(err as Error, ""); // Pass empty string for filename if there's an error
58 | }
59 | },
60 | });
61 |
62 | const upload = multer({
63 | storage,
64 | fileFilter: imageFileFilter, // Apply the file filter
65 | });
66 |
67 | export default upload;
68 |
--------------------------------------------------------------------------------
/frontend/src/components/UpdateExpenseModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Expense, ExpenseDocument } from "@shared/types/types";
3 | import { useDispatch } from "react-redux";
4 | import { updateExpense } from "../features/expenses/expenseSlice";
5 | import toast from "react-hot-toast";
6 | import { AppDispatch } from "src/app/store";
7 |
8 | interface UpdateExpenseModalProps {
9 | expense: ExpenseDocument;
10 | isOpen: boolean;
11 | onClose: () => void;
12 | }
13 |
14 | const UpdateExpenseModal: React.FC = ({ expense, isOpen, onClose }) => {
15 | const [text, setText] = useState(expense.text);
16 | const [amount, setAmount] = useState(expense.amount);
17 | const [type, setType] = useState(expense.type);
18 | const dispatch = useDispatch();
19 |
20 | const handleUpdate = async () => {
21 | try {
22 | await dispatch(updateExpense({ ...expense, text, amount, type }));
23 | toast.success("Expense updated successfully!");
24 | onClose();
25 | } catch (error) {
26 | toast.error("Failed to update expense.");
27 | }
28 | };
29 |
30 | return (
31 | <>
32 | {isOpen && (
33 |
34 |
35 |
Update Expense {expense.text}
36 |
setText(e.target.value)}
40 | className="input input-bordered w-full mt-2"
41 | placeholder="Expense Name"
42 | />
43 |
setAmount(Number(e.target.value))}
47 | className="input input-bordered w-full mt-2"
48 | placeholder="Amount"
49 | />
50 |
58 |
59 |
62 |
65 |
66 |
67 |
68 | )}
69 | >
70 | );
71 | };
72 |
73 | export default UpdateExpenseModal;
74 |
--------------------------------------------------------------------------------
/frontend/src/components/ThemeSwitch.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { AppDispatch, RootState } from "../app/store";
4 | import { updateSettings } from "../features/settings/settingsSlice";
5 | import { Settings } from "../types/types";
6 | import { FaMoon, FaSun, FaDesktop } from "react-icons/fa";
7 | import { useSystemTheme } from "../hooks/useSystemTheme";
8 |
9 | type ThemeKey = Settings["theme"];
10 |
11 | const themes: Record = {
12 | light: {
13 | icon: ,
14 | label: "Light",
15 | },
16 | dark: {
17 | icon: ,
18 | label: "Dark",
19 | },
20 | system: {
21 | icon: ,
22 | label: "System",
23 | },
24 | };
25 |
26 | const ThemeSwitcher: React.FC = () => {
27 | const dispatch = useDispatch();
28 | const settings = useSelector((state: RootState) => state.settings.settings);
29 | const [isOpen, setIsOpen] = useState(false);
30 | const systemTheme = useSystemTheme();
31 | const dropdownRef = useRef(null);
32 |
33 | const handleThemeChange = (newTheme: ThemeKey) => {
34 | dispatch(updateSettings({ theme: newTheme, currency: settings.currency }));
35 | setIsOpen(false); // Close menu after selecting theme
36 | };
37 |
38 | const handleClickOutside = (event: MouseEvent) => {
39 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
40 | setIsOpen(false);
41 | }
42 | };
43 |
44 | useEffect(() => {
45 | document.addEventListener("mousedown", handleClickOutside);
46 | return () => {
47 | document.removeEventListener("mousedown", handleClickOutside);
48 | };
49 | }, []);
50 |
51 | const currentTheme = settings.theme;
52 | const themeIcon =
53 | currentTheme === "system"
54 | ? systemTheme === "dark"
55 | ? themes.dark.icon
56 | : themes.light.icon
57 | : themes[currentTheme]?.icon;
58 |
59 | return (
60 |
61 |
64 | {isOpen && (
65 |
66 | {Object.keys(themes).map((key) => {
67 | const themeKey = key as ThemeKey;
68 | return (
69 | -
70 |
76 |
77 | );
78 | })}
79 |
80 | )}
81 |
82 | );
83 | };
84 |
85 | export default ThemeSwitcher;
86 |
--------------------------------------------------------------------------------
/backend/controllers/expenseController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import asyncHandler from "express-async-handler";
3 | import Expense from "../models/expense";
4 | import User from "../models/user";
5 | import { AuthenticatedRequest } from "../types/types";
6 |
7 | export const getAllExpenses = asyncHandler(async (req: Request, res: Response) => {
8 | const expenses = await Expense.find({
9 | user: (req as AuthenticatedRequest).user.id,
10 | });
11 | res.status(200).json(expenses);
12 | });
13 |
14 | export const createExpense = asyncHandler(async (req: Request, res: Response) => {
15 | if (!req.body.text) {
16 | res.status(400);
17 | throw new Error("Missing text");
18 | }
19 | if (!req.body.amount) {
20 | res.status(400);
21 | throw new Error("Missing amount");
22 | }
23 |
24 | if (req.body.amount < 0) {
25 | res.status(400);
26 | throw new Error("Amount cannot be negative");
27 | }
28 |
29 | if (req.body.type !== "income" && req.body.type !== "expense") {
30 | res.status(400);
31 | throw new Error("Invalid type");
32 | }
33 |
34 | const expense = Expense.create({
35 | text: req.body.text,
36 | amount: req.body.amount,
37 | type: req.body.type,
38 | user: (req as AuthenticatedRequest).user.id,
39 | customDate: req.body.customDate,
40 | });
41 |
42 | res.status(200).json(expense);
43 | });
44 |
45 | export const updateExpense = asyncHandler(async (req: Request, res: Response) => {
46 | const expense = await Expense.findById(req.params.id);
47 | if (!expense) {
48 | res.status(400);
49 | throw new Error("Expense not found");
50 | }
51 | if (req.body.amount < 0) {
52 | res.status(400);
53 | throw new Error("Amount cannot be negative");
54 | }
55 |
56 | const user = await User.findById((req as AuthenticatedRequest).user.id);
57 | if (!user) {
58 | res.status(401);
59 | throw new Error("User not found");
60 | }
61 |
62 | if (expense.user.toString() !== user.id) {
63 | res.status(401);
64 | throw new Error("Not authorized");
65 | }
66 |
67 | if (req.body.type !== "income" && req.body.type !== "expense") {
68 | res.status(400);
69 | throw new Error("Invalid type");
70 | }
71 |
72 | const updatedExpense = await Expense.findByIdAndUpdate(req.params.id, req.body, { new: true });
73 | res.status(200).json(updatedExpense);
74 | });
75 |
76 | export const deleteExpense = asyncHandler(async (req: Request, res: Response) => {
77 | const expense = await Expense.findById(req.params.id);
78 | if (!expense) {
79 | res.status(400);
80 | throw new Error("Expense not found");
81 | }
82 | const user = await User.findById((req as AuthenticatedRequest).user.id);
83 | if (!user) {
84 | res.status(401);
85 | throw new Error("User not found");
86 | }
87 |
88 | if (expense.user.toString() !== user.id) {
89 | res.status(401);
90 | throw new Error("Not authorized");
91 | }
92 | await Expense.findByIdAndDelete(req.params.id);
93 | res.status(200).json(expense);
94 | });
95 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes } from "react-router-dom";
2 | import Home from "./pages/Home";
3 | import NotFound from "./pages/404";
4 | import Login from "./pages/Login";
5 | import Register from "./pages/Register";
6 | import MainLayout from "./layouts/MainLayout";
7 | import { useSelector } from "react-redux";
8 | import { RootState } from "./app/store";
9 | import Add from "./pages/Add";
10 | import { Toaster } from "react-hot-toast";
11 | import UserProfile from "./pages/UserProfile";
12 | import { useEffect } from "react";
13 | import { useSystemTheme } from "./hooks/useSystemTheme";
14 |
15 | function App() {
16 | const theme = useSelector((state: RootState) => state.settings.settings.theme);
17 |
18 | const systemTheme = useSystemTheme();
19 |
20 | useEffect(() => {
21 | const htmlElement = document.documentElement;
22 | const metaThemeColor = document.querySelector("meta[name=theme-color]") as HTMLMetaElement;
23 | if (theme === "system") {
24 | htmlElement.removeAttribute("data-theme");
25 | metaThemeColor.setAttribute("content", systemTheme === "dark" ? "#1d232a" : "#ffffff");
26 | } else if (theme === "light") {
27 | htmlElement.setAttribute("data-theme", "lightTheme");
28 | metaThemeColor.setAttribute("content", "#ffffff");
29 | } else if (theme === "dark") {
30 | htmlElement.setAttribute("data-theme", "darkTheme");
31 | metaThemeColor.setAttribute("content", "#1d232a");
32 | }
33 | }, [theme, systemTheme]);
34 |
35 | return (
36 |
37 |
74 |
75 | } />
76 | } />
77 | } />
78 | } />
79 | } />
80 | } />
81 |
82 |
83 | );
84 | }
85 |
86 | export default App;
87 |
--------------------------------------------------------------------------------
/frontend/src/pages/Login.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { Link, useNavigate } from "react-router-dom";
4 | import { AppDispatch, RootState } from "../app/store";
5 | import { login, reset } from "../features/auth/authSlice";
6 |
7 | import toast from "react-hot-toast";
8 | import Loading from "../components/Loading";
9 | import { UserData } from "@shared/types/types";
10 |
11 | interface FormData {
12 | email: string;
13 | password: string;
14 | }
15 |
16 | function Login() {
17 | const [formData, setFormData] = useState({
18 | email: "",
19 | password: "",
20 | });
21 |
22 | const { email, password } = formData;
23 |
24 | const n = useNavigate();
25 | const dispatch = useDispatch();
26 |
27 | const { user, isLoading, isError, isSuccess, message } = useSelector(
28 | (state: RootState) => state.auth,
29 | );
30 |
31 | useEffect(() => {
32 | if (isError) {
33 | toast.error(message);
34 | }
35 |
36 | if (isSuccess || user) {
37 | n("/");
38 | }
39 |
40 | dispatch(reset());
41 | }, [user, isError, isSuccess, message, n, dispatch]);
42 |
43 | const onChange = (e: React.ChangeEvent) => {
44 | setFormData({
45 | ...formData,
46 | [e.target.name]: e.target.value,
47 | });
48 | };
49 |
50 | const onSubmit = async (e: React.FormEvent) => {
51 | e.preventDefault();
52 |
53 | const userData = {
54 | email,
55 | password,
56 | };
57 | //FIXME: Type error
58 | dispatch(login(userData as UserData));
59 | };
60 |
61 | if (isLoading) {
62 | return ;
63 | }
64 |
65 | return (
66 |
67 |
68 | Login
69 | Please login to your account
70 |
95 |
96 | Don't have an account?{" "}
97 |
98 | Register here
99 |
100 |
101 |
102 |
103 | );
104 | }
105 |
106 | export default Login;
107 |
--------------------------------------------------------------------------------
/frontend/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useSelector, useDispatch } from "react-redux";
3 | import { Link, useNavigate } from "react-router-dom";
4 | import { AppDispatch, RootState } from "../app/store";
5 | import { getExpenses, reset } from "../features/expenses/expenseSlice";
6 | import ExpenseItem from "../components/ExpenseItem";
7 | import Loading from "../components/Loading";
8 | import { ExpenseDocument } from "@shared/types/types";
9 | import { FaPlus, FaWallet } from "react-icons/fa6";
10 | import { formatCurrency } from "../utils/currencyFormatter";
11 | import WeeklyChart from "../components/WeeklyChart";
12 |
13 | function Home() {
14 | const n = useNavigate();
15 | const dispatch = useDispatch();
16 |
17 | const { user } = useSelector((state: RootState) => state.auth);
18 | const { expenses, isLoading, isError, message } = useSelector(
19 | (state: RootState) => state.expenses,
20 | );
21 |
22 | useEffect(() => {
23 | if (!user) {
24 | n("/login");
25 | }
26 | dispatch(getExpenses({} as ExpenseDocument));
27 | return () => {
28 | dispatch(reset());
29 | };
30 | }, [user, n, isError, dispatch, message]);
31 |
32 | if (isLoading) {
33 | return ;
34 | }
35 |
36 | const sortedExpenses = [...(expenses as ExpenseDocument[])].sort((a, b) => {
37 | const dateA = new Date(a.customDate || a.createdAt);
38 | const dateB = new Date(b.customDate || b.createdAt);
39 | return dateB.getTime() - dateA.getTime();
40 | });
41 |
42 | // Calculate total income for all time
43 | const totalIncome = (expenses as ExpenseDocument[])
44 | .filter((expense) => expense.type === "income")
45 | .reduce((total, expense) => total + (expense.amount || 0), 0);
46 |
47 | // Calculate total expenses for all time
48 | const totalExpenses = (expenses as ExpenseDocument[])
49 | .filter((expense) => expense.type === "expense")
50 | .reduce((total, expense) => total + (expense.amount || 0), 0);
51 |
52 | // Calculate actual balance
53 | const actualBalance = totalIncome - totalExpenses;
54 |
55 | return (
56 |
57 |
Hello, {user && user.name} 👋
58 |
59 |
= 0 ? "bg-primary" : "bg-error"
62 | } text-primary-content`}
63 | >
64 |
65 |
My Balance
66 |
{formatCurrency(actualBalance)}
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
Recent Transactions
77 | {sortedExpenses.length > 0 ? (
78 |
79 | {sortedExpenses.map((expense) => (
80 |
81 | ))}
82 |
83 | ) : (
84 |
No expenses found
85 | )}
86 |
87 |
88 |
96 |
97 |
98 | );
99 | }
100 |
101 | export default Home;
102 |
--------------------------------------------------------------------------------
/frontend/src/components/WeeklyChart.tsx:
--------------------------------------------------------------------------------
1 | import { ExpenseDocument } from "@shared/types/types";
2 | import { formatCurrency } from "../utils/currencyFormatter";
3 |
4 | interface WeeklyChartProps {
5 | expenses: ExpenseDocument[];
6 | }
7 |
8 | export default function WeeklyChart({ expenses }: WeeklyChartProps) {
9 | const now = new Date();
10 | const startOfWeek = new Date(now);
11 | startOfWeek.setDate(now.getDate() - now.getDay() + 1); // Assuming week starts on Monday
12 | startOfWeek.setHours(0, 0, 0, 0);
13 |
14 | const endOfWeek = new Date(startOfWeek);
15 | endOfWeek.setDate(startOfWeek.getDate() + 6);
16 |
17 | const sortedExpenses = [...expenses].sort((a, b) => {
18 | const dateA = new Date(a.customDate || a.createdAt);
19 | const dateB = new Date(b.customDate || b.createdAt);
20 | return dateB.getTime() - dateA.getTime();
21 | });
22 |
23 | const weeklyExpenses = sortedExpenses.filter((expense) => {
24 | const expenseDate = new Date(expense.customDate || expense.createdAt);
25 | return expenseDate >= startOfWeek && expenseDate <= endOfWeek && expense.type === "expense";
26 | });
27 |
28 | const dailyExpenses = Array.from({ length: 7 }, (_, i) => {
29 | const date = new Date(startOfWeek);
30 | date.setDate(startOfWeek.getDate() + i);
31 |
32 | const dayExpenses = weeklyExpenses.filter((expense) => {
33 | const expenseDate = new Date(expense.customDate || expense.createdAt);
34 | return (
35 | expenseDate.getDate() === date.getDate() &&
36 | expenseDate.getMonth() === date.getMonth() &&
37 | expenseDate.getFullYear() === date.getFullYear()
38 | );
39 | });
40 | return dayExpenses.reduce((total, expense) => total + (expense.amount || 0), 0);
41 | });
42 |
43 | const locale = navigator.language;
44 | const daysOfWeek = Array.from({ length: 7 }, (_, i) => {
45 | const date = new Date(startOfWeek);
46 | date.setDate(startOfWeek.getDate() + i);
47 | return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date);
48 | });
49 |
50 | const maxDataValue = Math.max(...dailyExpenses, 0);
51 |
52 | const monthlyExpenses = weeklyExpenses.reduce(
53 | (total, expense) => total + (expense.amount || 0),
54 | 0,
55 | );
56 |
57 | return (
58 |
59 |
60 |
Spending - This Week
61 |
62 | {dailyExpenses.reduce((acc, value) => acc + value, 0) > 0 ? (
63 | dailyExpenses.map((value, index) => (
64 |
74 |
{value.toFixed(0)}
75 |
76 |
{daysOfWeek[index]}
77 |
78 | ))
79 | ) : (
80 |
81 | No expenses this week
82 |
83 | )}
84 |
85 |
86 |
87 |
88 |
89 |
Total this week
90 |
{formatCurrency(monthlyExpenses)}
91 |
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/frontend/src/features/auth/authService.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { UserData } from "@shared/types/types";
3 |
4 | const API_URL = "api/users/";
5 |
6 | const register = async (userData: UserData) => {
7 | try {
8 | console.log("Making request to register user:", userData);
9 | const response = await axios.post(API_URL, userData);
10 | console.log("Received response:", response);
11 |
12 | if (response.data) {
13 | localStorage.setItem("user", JSON.stringify(response.data));
14 | }
15 |
16 | return response.data;
17 | } catch (error) {
18 | console.error("Error registering user:", error);
19 | throw error;
20 | }
21 | };
22 |
23 | const login = async (userData: Pick) => {
24 | try {
25 | console.log("Making request to login user:", userData);
26 | const response = await axios.post(API_URL + "login", userData);
27 | console.log("Received response:", response);
28 |
29 | if (response.data) {
30 | localStorage.setItem("user", JSON.stringify(response.data));
31 | }
32 |
33 | return response.data;
34 | } catch (error) {
35 | console.error("Error logging in user:", error);
36 | throw error;
37 | }
38 | };
39 |
40 | // Update user information
41 | const updateUser = async (userData: Partial) => {
42 | try {
43 | // Get the token from local storage
44 | const user = JSON.parse(localStorage.getItem("user") || "{}");
45 |
46 | if (!user.token) {
47 | throw new Error("No token found");
48 | }
49 |
50 | // Make the API call to update user
51 | const response = await axios.put(API_URL + "update", userData, {
52 | headers: {
53 | Authorization: `Bearer ${user.token}`,
54 | },
55 | });
56 |
57 | // Update local storage with the new user data
58 | if (response.data) {
59 | localStorage.setItem("user", JSON.stringify(response.data));
60 | }
61 |
62 | return response.data;
63 | } catch (error) {
64 | console.error("Error updating user:", error);
65 | throw error;
66 | }
67 | };
68 |
69 | // Upload profile picture
70 | const uploadProfilePicture = async (formData: FormData) => {
71 | try {
72 | const user = JSON.parse(localStorage.getItem("user") || "{}");
73 |
74 | if (!user.token) {
75 | throw new Error("No token found");
76 | }
77 |
78 | const response = await axios.post(API_URL + "upload-profile-picture", formData, {
79 | headers: {
80 | Authorization: `Bearer ${user.token}`,
81 | },
82 | });
83 | // Update local storage with the new user data
84 | if (response.data) {
85 | localStorage.setItem("user", JSON.stringify(response.data));
86 | }
87 |
88 | return response.data;
89 | } catch (error) {
90 | console.error("Error uploading profile picture:", error);
91 | throw error;
92 | }
93 | };
94 |
95 | const logout = () => {
96 | localStorage.removeItem("user");
97 | };
98 | const removeProfilePicture = async () => {
99 | try {
100 | const user = JSON.parse(localStorage.getItem("user") || "{}");
101 | console.log("User from localStorage:", user);
102 |
103 | if (!user.token) {
104 | throw new Error("No token found");
105 | }
106 |
107 | const response = await axios.post(
108 | API_URL + "remove-profile-picture",
109 | {},
110 | {
111 | headers: {
112 | Authorization: `Bearer ${user.token}`,
113 | },
114 | },
115 | );
116 |
117 | if (response.data) {
118 | localStorage.setItem("user", JSON.stringify(response.data));
119 | }
120 |
121 | return response.data;
122 | } catch (error) {
123 | console.error("Error removing profile picture:", error);
124 | throw error;
125 | }
126 | };
127 |
128 | const authService = {
129 | register,
130 | logout,
131 | login,
132 | updateUser,
133 | uploadProfilePicture,
134 | removeProfilePicture,
135 | };
136 | export default authService;
137 |
--------------------------------------------------------------------------------
/frontend/src/pages/Register.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { useSelector, useDispatch } from "react-redux";
3 | import { Link, useNavigate } from "react-router-dom";
4 | import { register, reset } from "../features/auth/authSlice";
5 | import { AppDispatch, RootState } from "../app/store";
6 | import toast from "react-hot-toast";
7 | import Loading from "../components/Loading";
8 |
9 | function Register() {
10 | const [formData, setFormData] = useState({
11 | name: "",
12 | email: "",
13 | password: "",
14 | password2: "",
15 | });
16 |
17 | const { name, email, password, password2 } = formData;
18 |
19 | const n = useNavigate();
20 | const dispatch = useDispatch();
21 |
22 | const { user, isLoading, isError, isSuccess, message } = useSelector(
23 | (state: RootState) => state.auth,
24 | );
25 |
26 | useEffect(() => {
27 | if (isError) {
28 | console.log(message);
29 | }
30 |
31 | if (isSuccess || user) {
32 | n("/");
33 | }
34 |
35 | dispatch(reset());
36 | }, [user, isError, isSuccess, message, n, dispatch]);
37 |
38 | const onChange = (e: React.ChangeEvent) => {
39 | setFormData((prevState) => ({
40 | ...prevState,
41 | [e.target.name]: e.target.value,
42 | }));
43 | };
44 |
45 | const onSubmit = (e: React.FormEvent) => {
46 | e.preventDefault();
47 |
48 | console.log("Form submitted with data:", formData);
49 |
50 | if (password !== password2) {
51 | toast.error("Passwords do not match");
52 | } else {
53 | const userData = {
54 | name,
55 | email,
56 | password,
57 | };
58 |
59 | console.log("Dispatching register action with userData:", userData);
60 | dispatch(register(userData));
61 | }
62 | };
63 |
64 | if (isLoading) {
65 | return ;
66 | }
67 |
68 | return (
69 |
70 |
71 | Register
72 | Please create an account
73 |
114 |
115 | Already have an account?{" "}
116 |
117 | Login here
118 |
119 |
120 |
121 |
122 | );
123 | }
124 |
125 | export default Register;
126 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
💸 Expense Tracker
4 | A simple expense tracker application with authentication built with the MERN stack.
5 |
6 |
7 | 
8 | 
9 | 
10 |
11 |
12 |
13 |
14 |
15 | ## Tech stack
16 |
17 |
18 | -
19 |
React
20 |
21 | -
22 |
Redux
23 |
24 | -
25 |
TypeScript
26 |
27 | -
28 |
Vite
29 |
30 | -
31 |
Tailwind CSS
32 |
33 | -
34 |
DaisyUI
35 |
36 | -
37 |
Node.js
38 |
39 | -
40 |
Express
41 |
42 | -
43 |
MongoDB
44 |
45 |
46 |
47 | ## Features
48 |
49 | - Dark Mode
50 | - Currency Selection with Intl API [https://codepen.io/maciekt07/pen/zYVdPLy](https://codepen.io/maciekt07/pen/zYVdPLy)
51 | - Profile Picture Upload
52 | - JWT Authentication
53 |
54 | ## To run this project locally
55 |
56 | ### 1. Clone the Repository
57 |
58 | First, clone the project repository from GitHub.
59 |
60 | ```bash
61 | git clone https://github.com/maciekt07/ExpenseTracker.git
62 | cd ExpenseTracker
63 | ```
64 |
65 | ### 2. Configure Environment Variables
66 |
67 | Create a .env file and fill it with your MongoDB token and JSON Web Token (JWT) key. You can use .env.example as a reference for the required format.
68 |
69 | ```env
70 | MONGODB_URI=your_mongodb_token
71 | JWT_SECRET=your_jwt_secret
72 | ```
73 |
74 | ### 3. Install Backend Dependencies
75 |
76 | ```bash
77 | npm install
78 | ```
79 |
80 | ### 4. Install Frontend Dependencies
81 |
82 | ```bash
83 | cd frontend
84 | npm install
85 | ```
86 |
87 | ### 5. Run the Server and Client
88 |
89 | ```bash
90 | cd ..
91 | npm run dev
92 | ```
93 |
94 |
95 |
96 | The server will start running on port 8000.
97 |
98 | The client will start running on port 5173.
99 |
100 | ## Credits
101 |
102 | Made with ❤️ by [maciekt07](https://github.com/maciekt07).
103 |
104 | Inspired by [Traversy Media Course](https://youtu.be/-0exw-9YJBo?si=Sb0nOUDenxp5Ez3X).
105 |
--------------------------------------------------------------------------------
/frontend/src/pages/Add.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { AppDispatch, RootState } from "../app/store";
4 | import { createExpense } from "../features/expenses/expenseSlice";
5 | import { useNavigate } from "react-router-dom";
6 | import toast from "react-hot-toast";
7 | import { Expense } from "../../../shared/types/types";
8 |
9 | function Add() {
10 | const [text, setText] = useState("");
11 | const [amount, setAmount] = useState(undefined);
12 | const [type, setType] = useState("expense");
13 | const [date, setDate] = useState(null);
14 |
15 | const dispatch = useDispatch();
16 | const n = useNavigate();
17 |
18 | const { settings } = useSelector((state: RootState) => state.settings);
19 |
20 | const onSubmit = async (e: React.FormEvent) => {
21 | e.preventDefault();
22 |
23 | let isoDate = "";
24 | if (date) {
25 | const parsedDate = new Date(date);
26 | if (!isNaN(parsedDate.getTime())) {
27 | isoDate = parsedDate.toISOString();
28 | } else {
29 | toast.error("Invalid date");
30 | return;
31 | }
32 | }
33 |
34 | //FIXME: negative amount should be handled
35 |
36 | await dispatch(createExpense({ text, amount, type, customDate: isoDate } as Expense));
37 | toast.success(`Added ${type}: ` + text);
38 | setText("");
39 | setAmount(0);
40 | n("/");
41 | setDate(null);
42 | };
43 |
44 | return (
45 |
46 |
47 |
Add New Expense
48 |
116 |
117 |
118 | );
119 | }
120 |
121 | export default Add;
122 |
--------------------------------------------------------------------------------
/backend/controllers/userController.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from "express";
2 | import jwt from "jsonwebtoken";
3 | import bcrypt from "bcrypt";
4 | import asyncHandler from "express-async-handler";
5 | import User from "../models/user";
6 | import { Types } from "mongoose";
7 | import { AuthenticatedRequest } from "../types/types";
8 | import fs from "fs/promises";
9 | import path from "path";
10 |
11 | const generateToken = (id: Types.ObjectId) => {
12 | return jwt.sign({ id }, process.env.JWT_SECRET as string, {
13 | expiresIn: "30d",
14 | });
15 | };
16 |
17 | export const registerUser = asyncHandler(async (req: Request, res: Response) => {
18 | const { name, email, password } = req.body;
19 |
20 | if (!name || !email || !password) {
21 | res.status(400);
22 | throw new Error("Missing required fields");
23 | }
24 |
25 | // Check if user already exists
26 | const userExists = await User.findOne({ email });
27 | if (userExists) {
28 | res.status(400);
29 | throw new Error("User already exists");
30 | }
31 |
32 | // Hash password
33 | const salt = await bcrypt.genSalt(10);
34 | const hashedPassword = await bcrypt.hash(password, salt);
35 |
36 | // Create user
37 | const user = await User.create({
38 | name,
39 | email,
40 | password: hashedPassword,
41 | });
42 |
43 | if (user) {
44 | res.status(201).json({
45 | _id: user._id,
46 | name: user.name,
47 | profilePicture: user.profilePicture,
48 | email: user.email,
49 | token: generateToken(user._id),
50 | });
51 | } else {
52 | res.status(400);
53 | throw new Error("Failed to create user");
54 | }
55 | });
56 |
57 | export const loginUser = asyncHandler(async (req: Request, res: Response) => {
58 | const { email, password } = req.body;
59 |
60 | // Check for user email
61 | const user = await User.findOne({ email });
62 | if (user && (await bcrypt.compare(password, user.password))) {
63 | res.json({
64 | _id: user._id,
65 | name: user.name,
66 | profilePicture: user.profilePicture,
67 | email: user.email,
68 | token: generateToken(user._id),
69 | });
70 | } else {
71 | res.status(400);
72 | throw new Error("Invalid credentials");
73 | }
74 | });
75 |
76 | //https://www.youtube.com/watch?v=UXjMo25Nnvc&list=PLillGF-RfqbbQeVSccR9PGKHzPJSWqcsm&index=4&ab_channel=TraversyMedia
77 | export const getUserData = asyncHandler(async (req: Request, res: Response) => {
78 | if (!(req as AuthenticatedRequest).user) {
79 | res.status(401);
80 | throw new Error("Not authorized");
81 | }
82 |
83 | const user = await User.findById((req as AuthenticatedRequest).user.id);
84 | if (!user) {
85 | res.status(404);
86 | throw new Error("User not found");
87 | }
88 |
89 | const { _id, name, email, profilePicture } = user;
90 | res.status(200).json({
91 | id: _id,
92 | name,
93 | email,
94 | profilePicture,
95 | });
96 | });
97 |
98 | export const updateUser = asyncHandler(async (req: Request, res: Response) => {
99 | const user = await User.findById((req as AuthenticatedRequest).user.id);
100 |
101 | if (!user) {
102 | res.status(401);
103 | throw new Error("User not found");
104 | }
105 |
106 | if (req.body.name && typeof req.body.name !== "string") {
107 | res.status(400);
108 | throw new Error("Invalid name");
109 | }
110 |
111 | if (req.body.email && typeof req.body.email !== "string") {
112 | res.status(400);
113 | throw new Error("Invalid email");
114 | }
115 |
116 | const updatedUser = await User.findByIdAndUpdate(
117 | (req as AuthenticatedRequest).user.id,
118 | req.body,
119 | { new: true, runValidators: true },
120 | );
121 |
122 | if (!updatedUser) {
123 | res.status(400);
124 | throw new Error("Failed to update user");
125 | }
126 |
127 | res.status(200).json({
128 | _id: updatedUser._id,
129 | name: updatedUser.name,
130 | email: updatedUser.email,
131 | token: generateToken(updatedUser._id),
132 | profilePicture: updatedUser.profilePicture,
133 | });
134 | });
135 |
136 | export const uploadProfilePicture = asyncHandler(async (req: Request, res: Response) => {
137 | if (!(req as AuthenticatedRequest).user) {
138 | res.status(401);
139 | throw new Error("Not authorized");
140 | }
141 |
142 | const user = await User.findById((req as AuthenticatedRequest).user.id);
143 | if (!user) {
144 | res.status(404);
145 | throw new Error("User not found");
146 | }
147 |
148 | if (!req.file) {
149 | res.status(400);
150 | throw new Error("No file uploaded");
151 | }
152 |
153 | user.profilePicture = req.file.path;
154 |
155 | await user.save();
156 |
157 | res.status(200).json({
158 | _id: user._id,
159 | name: user.name,
160 | email: user.email,
161 | token: generateToken(user._id),
162 | profilePicture: user.profilePicture,
163 | });
164 | });
165 |
166 | export const removeProfilePicture = asyncHandler(async (req: Request, res: Response) => {
167 | if (!(req as AuthenticatedRequest).user) {
168 | res.status(401);
169 | throw new Error("Not authorized");
170 | }
171 |
172 | const user = await User.findById((req as AuthenticatedRequest).user.id);
173 | if (!user) {
174 | res.status(404);
175 | throw new Error("User not found");
176 | }
177 |
178 | const uploadDir = "uploads";
179 |
180 | // Delete profile picture file
181 | if (user.profilePicture) {
182 | const filePath = path.join(uploadDir, path.basename(user.profilePicture));
183 | try {
184 | await fs.unlink(filePath);
185 | console.log(`Deleted profile picture file: ${filePath}`);
186 | } catch (err) {
187 | console.error("Failed to delete file:", err);
188 | }
189 | }
190 |
191 | // Clear profile picture reference in the database
192 | user.profilePicture = undefined;
193 | await user.save();
194 |
195 | res.status(200).json({
196 | _id: user._id,
197 | name: user.name,
198 | email: user.email,
199 | token: generateToken(user._id),
200 | profilePicture: undefined,
201 | });
202 | });
203 |
--------------------------------------------------------------------------------
/frontend/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Link, useNavigate } from "react-router-dom";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { logout, reset } from "../features/auth/authSlice";
5 | import { AppDispatch, RootState } from "../app/store";
6 | import logo from "../assets/logo512.png";
7 | import { FaBars, FaTimes } from "react-icons/fa";
8 | import { FaArrowRightFromBracket } from "react-icons/fa6";
9 | import ThemeSwitcher from "./ThemeSwitch";
10 |
11 | function Navbar() {
12 | const [isSidebarOpen, setIsSidebarOpen] = useState(false);
13 | const navigate = useNavigate();
14 | const dispatch = useDispatch();
15 | const { user } = useSelector((state: RootState) => state.auth);
16 |
17 | const handleLogout = () => {
18 | dispatch(logout());
19 | dispatch(reset());
20 | navigate("/");
21 | };
22 |
23 | const closeSidebar = () => setIsSidebarOpen(false);
24 |
25 | return (
26 |
27 | {/* Backdrop */}
28 | {isSidebarOpen &&
}
29 |
30 | {/* Sidebar */}
31 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
45 | {user ? (
46 | <>
47 |
52 | Home
53 |
54 |
63 | >
64 | ) : (
65 | <>
66 |
71 | Login
72 |
73 |
78 | Register
79 |
80 | >
81 | )}
82 |
83 | {user && (
84 |
85 |
86 | {user.profilePicture ? (
87 |

92 | ) : (
93 |
94 | {user.name ? user.name[0] : ""}
95 |
96 | )}
97 |
{user.name}
98 |
99 |
100 | )}
101 |
102 |
103 |
104 | {/* Navbar */}
105 |
154 |
155 | );
156 | }
157 |
158 | export default Navbar;
159 |
--------------------------------------------------------------------------------
/frontend/src/pages/UserProfile.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { AppDispatch, RootState } from "../app/store";
4 | import {
5 | removeProfilePicture,
6 | reset,
7 | updateUser,
8 | uploadProfilePicture,
9 | } from "../features/auth/authSlice";
10 | import toast from "react-hot-toast";
11 | import { useNavigate } from "react-router-dom";
12 | import { updateSettings } from "../features/settings/settingsSlice";
13 | import { Settings } from "../types/types";
14 |
15 | function UserProfile() {
16 | const { user, isLoading, isError, isSuccess, message } = useSelector(
17 | (state: RootState) => state.auth,
18 | );
19 | const dispatch = useDispatch();
20 | const { settings } = useSelector((state: RootState) => state.settings);
21 | const [name, setName] = useState(user?.name || "");
22 | const [selectedCurrency, setSelectedCurrency] = useState(
23 | settings.currency || "USD",
24 | );
25 |
26 | const n = useNavigate();
27 |
28 | const onSubmit = (e: React.FormEvent) => {
29 | e.preventDefault();
30 | dispatch(updateUser({ name }));
31 | };
32 |
33 | useEffect(() => {
34 | if (isSuccess) {
35 | toast.success("User updated successfully!");
36 | dispatch(reset());
37 | }
38 | if (isError) {
39 | toast.error(`Error: ${message}`);
40 | }
41 |
42 | if (!user) {
43 | n("/login");
44 | }
45 | }, [isSuccess, isError, message, user, n, dispatch]);
46 |
47 | const onFileChange = (e: React.ChangeEvent) => {
48 | const file = e.target.files?.[0];
49 | if (file) {
50 | const allowedFileTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"];
51 | if (!allowedFileTypes.includes(file.type)) {
52 | toast.error("Invalid file type.");
53 | return;
54 | }
55 | dispatch(uploadProfilePicture(file));
56 | }
57 | };
58 |
59 | const onRemoveProfilePicture = () => {
60 | dispatch(removeProfilePicture());
61 | };
62 |
63 | //@ts-expect-error it works :)
64 | const currencies: Settings["currency"][] = Intl.supportedValuesOf("currency");
65 |
66 | const displayNames = new Intl.DisplayNames([navigator.language], {
67 | type: "currency",
68 | });
69 |
70 | const onCurrencyChange = (e: React.ChangeEvent) => {
71 | const newCurrency = e.target.value;
72 | setSelectedCurrency(newCurrency);
73 | dispatch(
74 | updateSettings({
75 | ...settings,
76 | currency: newCurrency,
77 | }),
78 | );
79 | };
80 | return (
81 |
82 |
83 |
User Profile
84 | {user?.profilePicture ? (
85 |
86 |
87 |

91 |
92 |
93 | ) : (
94 |
95 |
96 | {user?.name ? user.name[0] : "?"}
97 |
98 |
99 | )}
100 |
101 |
107 |
110 |
111 |
112 |
113 |
114 | | Name |
115 | {user?.name} |
116 |
117 |
118 | | Email |
119 | {user?.email} |
120 |
121 |
122 |
123 |
124 |
Choose your currency
125 |
126 |
127 |
142 |
143 |
144 |
165 |
166 |
167 | );
168 | }
169 |
170 | export default UserProfile;
171 |
--------------------------------------------------------------------------------
/frontend/public/logo.svg:
--------------------------------------------------------------------------------
1 |
39 |
--------------------------------------------------------------------------------
/frontend/src/features/expenses/expenseSlice.ts:
--------------------------------------------------------------------------------
1 | import { AsyncThunk, createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 |
3 | import { RootState } from "../../app/store";
4 | import expenseService from "./expenseService";
5 | import { Expense, ExpenseDocument } from "@shared/types/types";
6 |
7 | interface AsyncThunkConfig {}
8 |
9 | const initialState = {
10 | expenses: [] as Expense[],
11 | expense: null as Expense | null,
12 | isError: false,
13 | isSuccess: false,
14 | isLoading: false,
15 | message: "",
16 | };
17 |
18 | // create new expense
19 |
20 | export const createExpense: AsyncThunk = createAsyncThunk(
21 | "expsense/create",
22 | async (expenseData: Expense, thunkAPI) => {
23 | try {
24 | const state = thunkAPI.getState() as RootState;
25 | const token = state.auth.user?.token;
26 | return await expenseService.createExpense(expenseData, token || "");
27 |
28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
29 | } catch (error: any) {
30 | console.error("Error in register thunk:", error);
31 | const message =
32 | (error.response && error.response.data && error.response.data.message) ||
33 | error.message ||
34 | error.toString();
35 | return thunkAPI.rejectWithValue(message);
36 | }
37 | },
38 | );
39 |
40 | // Get user expenses
41 | export const getExpenses: AsyncThunk = createAsyncThunk(
42 | "expense/getAll",
43 | async (_, thunkAPI) => {
44 | try {
45 | const state = thunkAPI.getState() as RootState;
46 | const token = state.auth.user?.token;
47 | console.log(await expenseService.getExpenses(token || ""));
48 |
49 | return await expenseService.getExpenses(token || "");
50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
51 | } catch (error: any) {
52 | const message =
53 | (error.response && error.response.data && error.response.data.message) ||
54 | error.message ||
55 | error.toString();
56 | return thunkAPI.rejectWithValue(message);
57 | }
58 | },
59 | );
60 |
61 | export const getExpenseDetails = createAsyncThunk(
62 | "expense/getDetails",
63 | async (id: string, thunkAPI) => {
64 | try {
65 | const state = thunkAPI.getState();
66 | const token = state.auth.user?.token;
67 | if (!token) {
68 | throw new Error("No authentication token available");
69 | }
70 | return await expenseService.getExpenseDetails(id, token);
71 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
72 | } catch (error: any) {
73 | const message =
74 | error.response?.data?.message || error.message || "Failed to fetch expense details";
75 | return thunkAPI.rejectWithValue(message);
76 | }
77 | },
78 | );
79 |
80 | // Delete expense
81 | export const deleteExpense: AsyncThunk<{ id: string }, string, AsyncThunkConfig> = createAsyncThunk(
82 | "expsense/delete",
83 | async (id: string, thunkAPI) => {
84 | try {
85 | const state = thunkAPI.getState() as RootState;
86 | const token = state.auth.user?.token;
87 | await expenseService.deleteExpense(id, token || "");
88 | return { id };
89 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
90 | } catch (error: any) {
91 | console.error("Error in register thunk:", error);
92 | const message =
93 | (error.response && error.response.data && error.response.data.message) ||
94 | error.message ||
95 | error.toString();
96 | return thunkAPI.rejectWithValue(message);
97 | }
98 | },
99 | );
100 |
101 | export const updateExpense = createAsyncThunk(
102 | "expense/update",
103 | async (expenseData: Expense, thunkAPI) => {
104 | try {
105 | const state = thunkAPI.getState() as RootState;
106 | const token = state.auth.user?.token;
107 | return await expenseService.updateExpense(expenseData, token || "");
108 | } catch (error) {
109 | return thunkAPI.rejectWithValue("Failed to update expense");
110 | }
111 | },
112 | );
113 |
114 | export const expenseSlice = createSlice({
115 | name: "expense",
116 | initialState,
117 | reducers: {
118 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
119 | reset: (_state) => initialState,
120 | },
121 | extraReducers: (builder) => {
122 | builder
123 | .addCase(createExpense.pending, (state) => {
124 | state.isLoading = true;
125 | })
126 | .addCase(createExpense.fulfilled, (state, action) => {
127 | state.isLoading = false;
128 | state.isSuccess = true;
129 | state.expenses.push(action.payload);
130 | })
131 | .addCase(createExpense.rejected, (state, action) => {
132 | state.isLoading = false;
133 | state.isError = true;
134 | state.message = action.payload as string;
135 | })
136 | // Get user expenses
137 | .addCase(getExpenses.pending, (state) => {
138 | state.isLoading = true;
139 | })
140 | .addCase(getExpenses.fulfilled, (state, action) => {
141 | state.isLoading = false;
142 | state.isSuccess = true;
143 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
144 | state.expenses = action.payload as any;
145 | })
146 | .addCase(getExpenses.rejected, (state, action) => {
147 | state.isLoading = false;
148 | state.isError = true;
149 | state.message = action.payload as string;
150 | })
151 |
152 | // Delete expense
153 | .addCase(deleteExpense.pending, (state) => {
154 | state.isLoading = true;
155 | })
156 | .addCase(deleteExpense.fulfilled, (state, action) => {
157 | state.isLoading = false;
158 | state.isSuccess = true;
159 |
160 | state.expenses = state.expenses.filter(
161 | (expense) => (expense as ExpenseDocument)._id !== action.payload.id,
162 | );
163 | })
164 | .addCase(deleteExpense.rejected, (state, action) => {
165 | state.isLoading = false;
166 | state.isError = true;
167 | state.message = action.payload as string;
168 | })
169 | .addCase(updateExpense.fulfilled, (state, action) => {
170 | state.isLoading = false;
171 | state.isSuccess = true;
172 | const index = state.expenses.findIndex((expense) => expense._id === action.payload._id);
173 | if (index !== -1) {
174 | state.expenses[index] = action.payload;
175 | }
176 | })
177 | .addCase(updateExpense.rejected, (state, action) => {
178 | state.isLoading = false;
179 | state.isError = true;
180 | state.message = action.payload as string;
181 | })
182 | .addCase(getExpenseDetails.pending, (state) => {
183 | state.isLoading = true;
184 | })
185 | .addCase(getExpenseDetails.fulfilled, (state, action) => {
186 | state.isLoading = false;
187 | state.isSuccess = true;
188 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
189 | state.expense = action.payload as Expense;
190 | })
191 | .addCase(getExpenseDetails.rejected, (state, action) => {
192 | state.isLoading = false;
193 | state.isError = true;
194 | state.message = action.payload as string;
195 | });
196 | },
197 | });
198 |
199 | export const { reset } = expenseSlice.actions;
200 | export default expenseSlice.reducer;
201 |
--------------------------------------------------------------------------------
/frontend/src/features/auth/authSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, createAsyncThunk, AsyncThunk } from "@reduxjs/toolkit";
2 | import authService from "./authService";
3 | import { UserData } from "@shared/types/types";
4 |
5 | interface AsyncThunkConfig {}
6 |
7 | const user = JSON.parse(localStorage.getItem("user") || "null");
8 |
9 | const initialState = {
10 | user: (user as UserData | null) || null,
11 | isError: false,
12 | isSuccess: false,
13 | isLoading: false,
14 | message: "",
15 | };
16 |
17 | // Register user
18 |
19 | export const register: AsyncThunk = createAsyncThunk(
20 | "auth/register",
21 | async (user: UserData, thunkAPI) => {
22 | try {
23 | console.log("Dispatching register action with user:", user);
24 | return await authService.register(user);
25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
26 | } catch (error: any) {
27 | console.error("Error in register thunk:", error);
28 | const message =
29 | (error.response && error.response.data && error.response.data.message) ||
30 | error.message ||
31 | error.toString();
32 | return thunkAPI.rejectWithValue(message);
33 | }
34 | },
35 | );
36 |
37 | export const logout = createAsyncThunk(
38 | "auth/logout",
39 | async (_, thunkAPI) => {
40 | try {
41 | authService.logout();
42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
43 | } catch (error: any) {
44 | console.error("Error logging out:", error);
45 | return thunkAPI.rejectWithValue(error.message || error.toString());
46 | }
47 | },
48 | );
49 |
50 | export const login: AsyncThunk = createAsyncThunk(
51 | "auth/login",
52 | async (user: UserData, thunkAPI) => {
53 | try {
54 | console.log("Dispatching login action with user:", user);
55 | return await authService.login(user);
56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
57 | } catch (error: any) {
58 | console.error("Error in login thunk:", error);
59 | const message =
60 | (error.response && error.response.data && error.response.data.message) ||
61 | error.message ||
62 | error.toString();
63 | return thunkAPI.rejectWithValue(message);
64 | }
65 | },
66 | );
67 |
68 | export const updateUser = createAsyncThunk, AsyncThunkConfig>(
69 | "auth/updateUser",
70 | async (userData, thunkAPI) => {
71 | try {
72 | return await authService.updateUser(userData);
73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
74 | } catch (error: any) {
75 | const message = error.response?.data?.message || error.message || error.toString();
76 | return thunkAPI.rejectWithValue(message);
77 | }
78 | },
79 | );
80 |
81 | export const uploadProfilePicture = createAsyncThunk>(
82 | "auth/uploadProfilePicture",
83 | async (file: File, thunkAPI) => {
84 | try {
85 | // Create FormData and append the file
86 | const formData = new FormData();
87 | formData.append("profilePicture", file);
88 | // Call the authService to upload the file
89 | return await authService.uploadProfilePicture(formData);
90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
91 | } catch (error: any) {
92 | // Handle error
93 | const message =
94 | (error.response && error.response.data && error.response.data.message) ||
95 | error.message ||
96 | error.toString();
97 | return thunkAPI.rejectWithValue(message);
98 | }
99 | },
100 | );
101 |
102 | export const removeProfilePicture = createAsyncThunk<
103 | void,
104 | void,
105 | { rejectValue: string } // Provide `rejectValue` type to handle errors
106 | >("auth/removeProfilePicture", async (_, thunkAPI) => {
107 | try {
108 | await authService.removeProfilePicture();
109 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
110 | } catch (error: any) {
111 | // Handle error
112 | const message =
113 | (error.response?.data?.message as string) ||
114 | error.message ||
115 | "Failed to remove profile picture";
116 | return thunkAPI.rejectWithValue(message);
117 | }
118 | });
119 |
120 | export const authSlice = createSlice({
121 | name: "auth",
122 | initialState,
123 | reducers: {
124 | reset: (state) => {
125 | state.isLoading = false;
126 | state.isError = false;
127 | state.isSuccess = false;
128 | state.message = "";
129 | },
130 | },
131 | extraReducers: (builder) => {
132 | builder // Register
133 | .addCase(register.pending, (state) => {
134 | state.isLoading = true;
135 | })
136 | .addCase(register.fulfilled, (state, action) => {
137 | state.isLoading = false;
138 | state.isSuccess = true;
139 | state.user = action.payload;
140 | })
141 | .addCase(register.rejected, (state, action) => {
142 | state.isLoading = false;
143 | state.isError = true;
144 | state.message = action.payload as string;
145 | state.user = null;
146 | }) // Login
147 | .addCase(login.pending, (state) => {
148 | state.isLoading = true;
149 | })
150 | .addCase(login.fulfilled, (state, action) => {
151 | state.isLoading = false;
152 | state.isSuccess = true;
153 | state.user = action.payload;
154 | })
155 | .addCase(login.rejected, (state, action) => {
156 | state.isLoading = false;
157 | state.isError = true;
158 | state.message = action.payload as string;
159 | state.user = null;
160 | })
161 | .addCase(logout.fulfilled, (state) => {
162 | state.user = null;
163 | })
164 | .addCase(updateUser.pending, (state) => {
165 | state.isLoading = true;
166 | })
167 | .addCase(updateUser.fulfilled, (state, action) => {
168 | state.isLoading = false;
169 | state.isSuccess = true;
170 | state.user = action.payload;
171 | })
172 | .addCase(updateUser.rejected, (state, action) => {
173 | state.isLoading = false;
174 | state.isError = true;
175 | state.message = action.payload as string;
176 | })
177 | .addCase(uploadProfilePicture.pending, (state) => {
178 | state.isLoading = true;
179 | })
180 | .addCase(uploadProfilePicture.fulfilled, (state, action) => {
181 | state.isLoading = false;
182 | state.isSuccess = true;
183 | state.user = action.payload;
184 | })
185 | .addCase(uploadProfilePicture.rejected, (state, action) => {
186 | state.isLoading = false;
187 | state.isError = true;
188 | state.message = action.payload as string;
189 | })
190 | .addCase(removeProfilePicture.pending, (state) => {
191 | state.isLoading = true;
192 | })
193 | .addCase(removeProfilePicture.fulfilled, (state) => {
194 | state.isLoading = false;
195 | state.isSuccess = true;
196 | if (state.user) {
197 | state.user = { ...state.user, profilePicture: "" };
198 | }
199 | })
200 | .addCase(removeProfilePicture.rejected, (state, action) => {
201 | state.isLoading = false;
202 | state.isError = true;
203 | state.message = action.payload as string;
204 | });
205 | },
206 | });
207 |
208 | export const { reset } = authSlice.actions;
209 | export default authSlice.reducer;
210 |
--------------------------------------------------------------------------------