diff --git a/client/src/pages/CreateIncomePage.tsx b/client/src/pages/CreateIncomePage.tsx
new file mode 100644
index 0000000..301fd2c
--- /dev/null
+++ b/client/src/pages/CreateIncomePage.tsx
@@ -0,0 +1,46 @@
+import { useNavigate } from "react-router-dom";
+import { IncomeForm } from "../components/IncomeForm";
+import { Button, useToast } from "../ui";
+import { IncomeService } from "../services/IncomeService";
+import type { CreateIncomeRequest } from "../types/Income";
+
+export const CreateIncomePage = () => {
+ const navigate = useNavigate();
+ const toast = useToast();
+
+ const handleSave = async (formData: CreateIncomeRequest) => {
+ try {
+ await IncomeService.createIncome(formData);
+ toast.success("Income created successfully");
+ navigate("/incomes");
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to create income";
+ toast.error(message);
+ throw error;
+ }
+ };
+
+ const handleCancel = () => {
+ navigate("/incomes");
+ };
+
+ return (
+
+
+
+
Create New Income
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/pages/EditIncomePage.tsx b/client/src/pages/EditIncomePage.tsx
new file mode 100644
index 0000000..3b2dd01
--- /dev/null
+++ b/client/src/pages/EditIncomePage.tsx
@@ -0,0 +1,89 @@
+import { useState, useEffect } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { IncomeForm } from "../components/IncomeForm";
+import { Button, useToast, Skeleton } from "../ui";
+import { IncomeService } from "../services/IncomeService";
+import type { Income, UpdateIncomeRequest } from "../types/Income";
+
+export const EditIncomePage = () => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const toast = useToast();
+ const [income, setIncome] = useState
(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchIncome = async () => {
+ if (!id) return;
+ try {
+ const incomeData = await IncomeService.getIncomeById(id);
+ setIncome(incomeData);
+ } catch {
+ toast.error("Failed to load income");
+ navigate("/incomes");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchIncome();
+ }, [id, navigate, toast]);
+
+ const handleSave = async (formData: UpdateIncomeRequest) => {
+ if (!id) return;
+ try {
+ await IncomeService.updateIncome(id, formData);
+ toast.success("Income updated successfully");
+ navigate("/incomes");
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to update income";
+ toast.error(message);
+ throw error;
+ }
+ };
+
+ const handleCancel = () => {
+ navigate("/incomes");
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!income) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
Edit Income
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/IncomesPage.tsx
index a81c2fd..bc88eeb 100644
--- a/client/src/pages/IncomesPage.tsx
+++ b/client/src/pages/IncomesPage.tsx
@@ -1,21 +1,23 @@
-import React, { useState } from 'react';
-import type { Income } from '../types/Income';
-import { IncomeList } from '../components/IncomeList';
-import { IncomeForm } from '../components/IncomeForm';
-import { Button, TextField, Dialog, useToast } from '../ui';
-import { IncomeService } from '../services/IncomeService';
+import { useState, useRef } from "react";
+import type { Income } from "../types/Income";
+import { IncomeList } from "../components/IncomeList";
+import { Button, TextField, Dialog, useToast } from "../ui";
+import { IncomeService } from "../services/IncomeService";
+import { useNavigate } from "react-router-dom";
-export const IncomesPage: React.FC = () => {
- const [editingIncome, setEditingIncome] = useState();
- const [showForm, setShowForm] = useState(false);
- const [dateFilter, setDateFilter] = useState<{ start?: string; end?: string }>({});
+export const IncomesPage = () => {
+ const [dateFilter, setDateFilter] = useState<{
+ start?: string;
+ end?: string;
+ }>({});
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [incomeToDelete, setIncomeToDelete] = useState(null);
const toast = useToast();
+ const incomeListRef = useRef<{ refetch: () => void }>(null);
+ const navigate = useNavigate();
const handleEdit = (income: Income) => {
- setEditingIncome(income);
- setShowForm(true);
+ navigate(`/incomes/${income.income_id}/edit`);
};
const handleDeleteClick = (income: Income) => {
@@ -28,35 +30,30 @@ export const IncomesPage: React.FC = () => {
try {
await IncomeService.deleteIncome(incomeToDelete.income_id.toString());
- toast.success('Income deleted successfully');
+ toast.success("Income deleted successfully");
setDeleteConfirmOpen(false);
setIncomeToDelete(null);
+ //refresh after every operations
+ incomeListRef.current?.refetch();
} catch (error) {
- const message = error instanceof Error ? error.message : 'Failed to delete income';
+ const message =
+ error instanceof Error ? error.message : "Failed to delete income";
toast.error(message);
}
};
- const handleSave = () => {
- setEditingIncome(undefined);
- setShowForm(false);
- };
-
- const handleCancel = () => {
- setEditingIncome(undefined);
- setShowForm(false);
- };
-
const handleNewIncome = () => {
- setEditingIncome(undefined);
- setShowForm(true);
+ navigate("/incomes/new");
};
return (
-
-
-
Income Management
-
-
-
);
-};
\ No newline at end of file
+};
diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts
index dcc94db..1a11bb1 100644
--- a/client/src/types/Income.ts
+++ b/client/src/types/Income.ts
@@ -10,6 +10,14 @@ export interface Income {
category?: IncomeCategory;
}
+export interface IncomeFormData {
+ amount: number;
+ date: string;
+ source: string;
+ description: string;
+ category_id: number;
+}
+
export interface IncomeCategory {
category_id: number;
category_name: string;
@@ -34,4 +42,4 @@ export interface UpdateIncomeRequest {
source?: string;
description?: string;
category_id?: number;
-}
\ No newline at end of file
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..04141b7
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "expense-tracker",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js
index f7cf0c9..2226afa 100644
--- a/server/controllers/income.controller.js
+++ b/server/controllers/income.controller.js
@@ -6,7 +6,7 @@ import { asyncHandler } from "../utils/asyncHandler.js";
export const createIncome = asyncHandler(async (req, res) => {
const { amount, date, source, description, category_id } = req.body;
- if (!amount || !category_id) {
+ if (!amount) {
return res.status(400).json({ error: "Missing required fields" });
}
@@ -14,52 +14,131 @@ export const createIncome = asyncHandler(async (req, res) => {
amount: parseFloat(amount),
source: source || "",
description: description || "",
- category: { connect: { category_id: parseInt(category_id) } },
};
+ if (category_id) {
+ incomeData.category = { connect: { category_id: parseInt(category_id) } };
+ } else {
+ incomeData.category = { connect: { category_id: 1 } };
+ }
+
if (date) {
const parsedDate = new Date(date);
if (!isNaN(parsedDate.getTime())) {
incomeData.date = parsedDate;
+ } else {
+ return res.status(400).json({ error: "Invalid date format" });
}
+ } else {
+ incomeData.date = new Date();
}
- const income = await incomeService.createIncome(req.user.user_id, incomeData);
- res.status(201).json(income);
+ try {
+ const income = await incomeService.createIncome(
+ req.user.user_id,
+ incomeData
+ );
+ res.status(201).json(income);
+ } catch (error) {
+ console.error("Error creating income:", error);
+
+ if (
+ error.message.includes("category") ||
+ error.message.includes("Category")
+ ) {
+ return res.status(400).json({ error: "Invalid category specified" });
+ }
+
+ res.status(500).json({ error: "Failed to create income" });
+ }
});
export const getIncomes = asyncHandler(async (req, res) => {
const { start, end } = req.query;
const filters = {};
- if (start) filters.start = new Date(start);
- if (end) filters.end = new Date(end);
+ if (start) {
+ const parsedStart = new Date(start);
+ if (!isNaN(parsedStart.getTime())) {
+ filters.start = parsedStart;
+ } else {
+ return res.status(400).json({ error: "Invalid start date format" });
+ }
+ }
- const incomes = await incomeService.getIncomes(req.user.user_id, filters);
- res.json(incomes);
+ if (end) {
+ const parsedEnd = new Date(end);
+ if (!isNaN(parsedEnd.getTime())) {
+ filters.end = parsedEnd;
+ } else {
+ return res.status(400).json({ error: "Invalid end date format" });
+ }
+ }
+
+ try {
+ const incomes = await incomeService.getIncomes(req.user.user_id, filters);
+ res.json(incomes);
+ } catch (error) {
+ console.error("Error fetching incomes:", error);
+ res.status(500).json({ error: "Failed to fetch incomes" });
+ }
});
export const getIncome = asyncHandler(async (req, res) => {
const { id } = req.params;
- const income = await incomeService.getIncomeById(id, req.user.user_id);
- if (!income) {
- return res.status(404).json({ error: "Income not found" });
+ if (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid income ID" });
}
- res.json(income);
+ try {
+ const income = await incomeService.getIncomeById(id, req.user.user_id);
+
+ if (!income) {
+ return res.status(404).json({ error: "Income not found" });
+ }
+
+ res.json(income);
+ } catch (error) {
+ console.error("Error fetching income:", error);
+ res.status(500).json({ error: "Failed to fetch income" });
+ }
});
export const updateIncome = asyncHandler(async (req, res) => {
const { id } = req.params;
+
+ if (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid income ID" });
+ }
+
const updates = req.body;
- if (updates.date) updates.date = new Date(updates.date);
- if (updates.amount) updates.amount = parseFloat(updates.amount);
- if (updates.category_id) {
- updates.category = {
- connect: { category_id: parseInt(updates.category_id) },
- };
+ if (updates.amount !== undefined) {
+ if (isNaN(parseFloat(updates.amount))) {
+ return res.status(400).json({ error: "Invalid amount" });
+ }
+ updates.amount = parseFloat(updates.amount);
+ }
+
+ if (updates.date) {
+ const parsedDate = new Date(updates.date);
+ if (isNaN(parsedDate.getTime())) {
+ return res.status(400).json({ error: "Invalid date format" });
+ }
+ updates.date = parsedDate;
+ }
+
+ if (updates.category_id !== undefined) {
+ if (updates.category_id === null || updates.category_id === "") {
+ updates.category = { connect: { category_id: 1 } };
+ } else {
+ const categoryId = parseInt(updates.category_id);
+ if (isNaN(categoryId)) {
+ return res.status(400).json({ error: "Invalid category ID" });
+ }
+ updates.category = { connect: { category_id: categoryId } };
+ }
delete updates.category_id;
}
@@ -71,43 +150,88 @@ export const updateIncome = asyncHandler(async (req, res) => {
);
res.json(income);
} catch (error) {
- res.status(404).json({ error: error.message });
+ console.error("Error updating income:", error);
+
+ if (
+ error.message.includes("not found") ||
+ error.message.includes("not authorized")
+ ) {
+ return res
+ .status(404)
+ .json({ error: "Income not found or not authorized" });
+ }
+
+ if (
+ error.message.includes("category") ||
+ error.message.includes("Category")
+ ) {
+ return res.status(400).json({ error: "Invalid category specified" });
+ }
+
+ res.status(500).json({ error: "Failed to update income" });
}
});
export const deleteIncome = asyncHandler(async (req, res) => {
const { id } = req.params;
+ if (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid income ID" });
+ }
+
try {
await incomeService.deleteIncome(id, req.user.user_id);
res.status(204).send();
} catch (error) {
- res.status(404).json({ error: error.message });
+ console.error("Error deleting income:", error);
+
+ if (
+ error.message.includes("not found") ||
+ error.message.includes("not authorized")
+ ) {
+ return res
+ .status(404)
+ .json({ error: "Income not found or not authorized" });
+ }
+
+ res.status(500).json({ error: "Failed to delete income" });
}
});
//---------------INCOME CATEGORIES---------------//
export const getIncomeCategories = asyncHandler(async (req, res) => {
- const categories = await incomeService.getIncomeCategories(req.user.user_id);
- res.json(categories);
+ try {
+ const categories = await incomeService.getIncomeCategories(
+ req.user.user_id
+ );
+ res.json(categories);
+ } catch (error) {
+ console.error("Error fetching income categories:", error);
+ res.status(500).json({ error: "Failed to fetch income categories" });
+ }
});
export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => {
- const categories = await incomeService.getIncomeCategoriesByUser(
- req.user.user_id
- );
- res.json(categories);
+ try {
+ const categories = await incomeService.getIncomeCategoriesByUser(
+ req.user.user_id
+ );
+ res.json(categories);
+ } catch (error) {
+ console.error("Error fetching user income categories:", error);
+ res.status(500).json({ error: "Failed to fetch user income categories" });
+ }
});
export const createIncomeCategory = asyncHandler(async (req, res) => {
- const { category_name, icon_url, icon_emoji } = req.body;
+ try {
+ const { category_name, icon_url, icon_emoji } = req.body;
- if (!category_name) {
- return res.status(400).json({ error: "Category name is required" });
- }
+ if (!category_name) {
+ return res.status(400).json({ error: "Category name is required" });
+ }
- try {
const category = await incomeService.createIncomeCategory(
req.user.user_id,
{
@@ -118,19 +242,29 @@ export const createIncomeCategory = asyncHandler(async (req, res) => {
);
res.status(201).json(category);
} catch (error) {
- res.status(400).json({ error: error.message });
+ console.error("Error creating income category:", error);
+
+ if (error.message.includes("already exists")) {
+ return res.status(409).json({ error: error.message });
+ }
+
+ res.status(500).json({ error: "Failed to create income category" });
}
});
export const updateIncomeCategory = asyncHandler(async (req, res) => {
- const { id } = req.params;
- const { category_name, icon_url, icon_emoji } = req.body;
+ try {
+ const { id } = req.params;
+ const { category_name, icon_url, icon_emoji } = req.body;
- if (!category_name) {
- return res.status(400).json({ error: "Category name is required" });
- }
+ if (!category_name) {
+ return res.status(400).json({ error: "Category name is required" });
+ }
+
+ if (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid category ID" });
+ }
- try {
const category = await incomeService.updateIncomeCategory(
id,
req.user.user_id,
@@ -138,17 +272,47 @@ export const updateIncomeCategory = asyncHandler(async (req, res) => {
);
res.json(category);
} catch (error) {
- res.status(400).json({ error: error.message });
+ console.error("Error updating income category:", error);
+
+ if (
+ error.message.includes("not found") ||
+ error.message.includes("not authorized")
+ ) {
+ return res.status(404).json({ error: error.message });
+ }
+
+ if (error.message.includes("already exists")) {
+ return res.status(409).json({ error: error.message });
+ }
+
+ res.status(500).json({ error: "Failed to update income category" });
}
});
export const deleteIncomeCategory = asyncHandler(async (req, res) => {
- const { id } = req.params;
-
try {
+ const { id } = req.params;
+
+ if (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid category ID" });
+ }
+
await incomeService.deleteIncomeCategory(id, req.user.user_id);
res.status(204).send();
} catch (error) {
- res.status(400).json({ error: error.message });
+ console.error("Error deleting income category:", error);
+
+ if (
+ error.message.includes("not found") ||
+ error.message.includes("not authorized")
+ ) {
+ return res.status(404).json({ error: error.message });
+ }
+
+ if (error.message.includes("being used")) {
+ return res.status(409).json({ error: error.message });
+ }
+
+ res.status(500).json({ error: "Failed to delete income category" });
}
});
diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml
index ce5bf0d..48843a0 100644
--- a/server/docs/Expense Tracker API.yaml
+++ b/server/docs/Expense Tracker API.yaml
@@ -22,6 +22,7 @@ paths:
password:
type: string
responses:
+ "201":
"201":
description: User created
/auth/login:
@@ -40,6 +41,7 @@ paths:
password:
type: string
responses:
+ "200":
"200":
description: Token returned
/expenses:
@@ -66,6 +68,7 @@ paths:
type: string
enum: [recurring, one-time]
responses:
+ "200":
"200":
description: List of expenses
post:
@@ -96,6 +99,7 @@ paths:
type: string
format: binary
responses:
+ "201":
"201":
description: Expense created
/expenses/{id}:
@@ -105,9 +109,15 @@ paths:
required: true
schema:
type: string
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
get:
summary: Get a single expense
responses:
+ "200":
"200":
description: Expense data
put:
@@ -137,11 +147,13 @@ paths:
type: string
format: binary
responses:
+ "200":
"200":
description: Expense updated
delete:
summary: Delete an expense
responses:
+ "204":
"204":
description: Expense deleted
/incomes/categories:
@@ -202,6 +214,64 @@ paths:
responses:
"204":
description: Income category deleted
+ /incomes/categories:
+ get:
+ summary: Get system income categories
+ responses:
+ "200":
+ description: List of system income categories"
+ /incomes/custom-categories:
+ get:
+ summary: Get user's custom income categories
+ responses:
+ "200":
+ description: List of user's custom income categories
+ post:
+ summary: Create a new custom income category
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [category_name]
+ properties:
+ category_name:
+ type: string
+ icon_url:
+ type: string
+ responses:
+ "201":
+ description: Income category created
+ /incomes/custom-categories/{id}:
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ put:
+ summary: Update an income category
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [category_name]
+ properties:
+ category_name:
+ type: string
+ icon_url:
+ type: string
+ responses:
+ "200":
+ description: Income category updated
+ delete:
+ summary: Delete an income category
+ responses:
+ "204":
+ description: Income category deleted
/incomes:
get:
summary: List all incomes
@@ -215,6 +285,7 @@ paths:
schema:
type: string
responses:
+ "200":
"200":
description: List of incomes
post:
@@ -224,7 +295,7 @@ paths:
application/json:
schema:
type: object
- required: [amount, category_id]
+ required: [amount]
properties:
amount:
type: number
@@ -236,7 +307,10 @@ paths:
type: string
category_id:
type: number
+ category_id:
+ type: number
responses:
+ "201":
"201":
description: Income created
/incomes/{id}:
@@ -246,9 +320,15 @@ paths:
required: true
schema:
type: string
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
get:
summary: Get an income entry
responses:
+ "200":
"200":
description: Income data
put:
@@ -268,17 +348,20 @@ paths:
description:
type: string
responses:
+ "200":
"200":
description: Income updated
delete:
summary: Delete an income entry
responses:
+ "204":
"204":
description: Income deleted
/categories:
get:
summary: List user categories
responses:
+ "200":
"200":
description: List of categories
post:
@@ -293,6 +376,7 @@ paths:
name:
type: string
responses:
+ "201":
"201":
description: Category created
/categories/{id}:
@@ -313,6 +397,7 @@ paths:
name:
type: string
responses:
+ "200":
"200":
description: Category updated
delete:
@@ -324,6 +409,7 @@ paths:
schema:
type: string
responses:
+ "204":
"204":
description: Category deleted
/summary/monthly:
@@ -335,6 +421,7 @@ paths:
schema:
type: string
responses:
+ "200":
"200":
description: Monthly summary
/summary:
@@ -350,12 +437,14 @@ paths:
schema:
type: string
responses:
+ "200":
"200":
description: Summary
/summary/alerts:
get:
summary: Budget overrun alert
responses:
+ "200":
"200":
description: Alert info
/receipts/{id}:
@@ -368,11 +457,14 @@ paths:
schema:
type: string
responses:
+ "200":
"200":
description: Receipt file
/user/profile:
get:
summary: Get user profile
responses:
+ "200":
"200":
description: Profile info
+
diff --git a/server/services/income.service.js b/server/services/income.service.js
index 10c959e..d285487 100644
--- a/server/services/income.service.js
+++ b/server/services/income.service.js
@@ -26,7 +26,10 @@ const getIncomes = async (userId, filters = {}) => {
return await prisma.income.findMany({
where,
include: { category: true },
- orderBy: { date: "desc" },
+ orderBy: [
+ { date: "desc" },
+ { creation_date: "desc" }, // might change { income_id: "desc" } if that doesnt work
+ ],
});
};