diff --git a/client/index.html b/client/index.html
index e4b78ea..9d68165 100644
--- a/client/index.html
+++ b/client/index.html
@@ -1,8 +1,12 @@
-
+
-
-
- Edit src/App.tsx and save to test HMR
-
+
+
+
+ } />
+ } />
+
-
- Click on the Vite and React logos to learn more
-
- >
- )
-}
+
+ );
+};
-export default App
+export default App;
diff --git a/client/src/api/core/ApiError.ts b/client/src/api/core/ApiError.ts
index ec7b16a..df9f43e 100644
--- a/client/src/api/core/ApiError.ts
+++ b/client/src/api/core/ApiError.ts
@@ -22,4 +22,4 @@ export class ApiError extends Error {
this.body = response.body;
this.request = request;
}
-}
+}
diff --git a/client/src/api/core/ApiRequestOptions.ts b/client/src/api/core/ApiRequestOptions.ts
index 93143c3..a93ee2a 100644
--- a/client/src/api/core/ApiRequestOptions.ts
+++ b/client/src/api/core/ApiRequestOptions.ts
@@ -14,4 +14,4 @@ export type ApiRequestOptions = {
readonly mediaType?: string;
readonly responseHeader?: string;
readonly errors?: Record
;
-};
+};
diff --git a/client/src/api/core/ApiResult.ts b/client/src/api/core/ApiResult.ts
index ee1126e..e23d6c8 100644
--- a/client/src/api/core/ApiResult.ts
+++ b/client/src/api/core/ApiResult.ts
@@ -8,4 +8,4 @@ export type ApiResult = {
readonly status: number;
readonly statusText: string;
readonly body: any;
-};
+};
diff --git a/client/src/api/core/CancelablePromise.ts b/client/src/api/core/CancelablePromise.ts
index d70de92..9ee9569 100644
--- a/client/src/api/core/CancelablePromise.ts
+++ b/client/src/api/core/CancelablePromise.ts
@@ -128,4 +128,4 @@ export class CancelablePromise implements Promise {
public get isCancelled(): boolean {
return this.#isCancelled;
}
-}
+}
diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts
index 03da729..77fa96a 100644
--- a/client/src/api/core/OpenAPI.ts
+++ b/client/src/api/core/OpenAPI.ts
@@ -29,4 +29,4 @@ export const OpenAPI: OpenAPIConfig = {
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
-};
+};
diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts
index 1dc6fef..319b00a 100644
--- a/client/src/api/core/request.ts
+++ b/client/src/api/core/request.ts
@@ -320,4 +320,4 @@ export const request = (config: OpenAPIConfig, options: ApiRequestOptions, ax
reject(error);
}
});
-};
+};
diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts
index 50ff9f3..4ceef98 100644
--- a/client/src/api/services/DefaultService.ts
+++ b/client/src/api/services/DefaultService.ts
@@ -1,408 +1,465 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { CancelablePromise } from '../core/CancelablePromise';
-import { OpenAPI } from '../core/OpenAPI';
-import { request as __request } from '../core/request';
-export class DefaultService {
- /**
- * Register a new user
- * @param requestBody
- * @returns any User created
- * @throws ApiError
- */
- public static postAuthSignup(
- requestBody: {
- email: string;
- password: string;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/auth/signup',
- body: requestBody,
- mediaType: 'application/json',
- });
- }
- /**
- * Login and receive JWT token
- * @param requestBody
- * @returns any Token returned
- * @throws ApiError
- */
- public static postAuthLogin(
- requestBody: {
- email: string;
- password: string;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/auth/login',
- body: requestBody,
- mediaType: 'application/json',
- });
- }
- /**
- * List all user expenses
- * @param start Start date
- * @param end End date
- * @param category
- * @param type
- * @returns any List of expenses
- * @throws ApiError
- */
- public static getExpenses(
- start?: string,
- end?: string,
- category?: string,
- type?: 'recurring' | 'one-time',
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/expenses',
- query: {
- 'start': start,
- 'end': end,
- 'category': category,
- 'type': type,
- },
- });
- }
- /**
- * Create a new expense
- * @param formData
- * @returns any Expense created
- * @throws ApiError
- */
- public static postExpenses(
- formData?: {
- amount: number;
- date: string;
- categoryId: string;
- description?: string;
- type?: 'one-time' | 'recurring';
- startDate?: string;
- endDate?: string;
- receipt?: Blob;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/expenses',
- formData: formData,
- mediaType: 'multipart/form-data',
- });
- }
- /**
- * Get a single expense
- * @param id
- * @returns any Expense data
- * @throws ApiError
- */
- public static getExpenses1(
- id: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/expenses/{id}',
- path: {
- 'id': id,
- },
- });
- }
- /**
- * Update an expense
- * @param id
- * @param formData
- * @returns any Expense updated
- * @throws ApiError
- */
- public static putExpenses(
- id: string,
- formData?: {
- amount?: number;
- date?: string;
- categoryId?: string;
- description?: string;
- type?: 'one-time' | 'recurring';
- startDate?: string;
- endDate?: string;
- receipt?: Blob;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'PUT',
- url: '/expenses/{id}',
- path: {
- 'id': id,
- },
- formData: formData,
- mediaType: 'multipart/form-data',
- });
- }
- /**
- * Delete an expense
- * @param id
- * @returns void
- * @throws ApiError
- */
- public static deleteExpenses(
- id: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'DELETE',
- url: '/expenses/{id}',
- path: {
- 'id': id,
- },
- });
- }
- /**
- * List all incomes
- * @param start
- * @param end
- * @returns any List of incomes
- * @throws ApiError
- */
- public static getIncomes(
- start?: string,
- end?: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/incomes',
- query: {
- 'start': start,
- 'end': end,
- },
- });
- }
- /**
- * Create new income
- * @param requestBody
- * @returns any Income created
- * @throws ApiError
- */
- public static postIncomes(
- requestBody?: {
- amount: number;
- date: string;
- source?: string;
- description?: string;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/incomes',
- body: requestBody,
- mediaType: 'application/json',
- });
- }
- /**
- * Get an income entry
- * @param id
- * @returns any Income data
- * @throws ApiError
- */
- public static getIncomes1(
- id: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/incomes/{id}',
- path: {
- 'id': id,
- },
- });
- }
- /**
- * Update an income entry
- * @param id
- * @param requestBody
- * @returns any Income updated
- * @throws ApiError
- */
- public static putIncomes(
- id: string,
- requestBody?: {
- amount?: number;
- date?: string;
- source?: string;
- description?: string;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'PUT',
- url: '/incomes/{id}',
- path: {
- 'id': id,
- },
- body: requestBody,
- mediaType: 'application/json',
- });
- }
- /**
- * Delete an income entry
- * @param id
- * @returns void
- * @throws ApiError
- */
- public static deleteIncomes(
- id: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'DELETE',
- url: '/incomes/{id}',
- path: {
- 'id': id,
- },
- });
- }
- /**
- * List user categories
- * @returns any List of categories
- * @throws ApiError
- */
- public static getCategories(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/categories',
- });
- }
- /**
- * Create new category
- * @param requestBody
- * @returns any Category created
- * @throws ApiError
- */
- public static postCategories(
- requestBody?: {
- name: string;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/categories',
- body: requestBody,
- mediaType: 'application/json',
- });
- }
- /**
- * Rename a category
- * @param id
- * @param requestBody
- * @returns any Category updated
- * @throws ApiError
- */
- public static putCategories(
- id: string,
- requestBody?: {
- name?: string;
- },
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'PUT',
- url: '/categories/{id}',
- path: {
- 'id': id,
- },
- body: requestBody,
- mediaType: 'application/json',
- });
- }
- /**
- * Delete a category
- * @param id
- * @returns void
- * @throws ApiError
- */
- public static deleteCategories(
- id: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'DELETE',
- url: '/categories/{id}',
- path: {
- 'id': id,
- },
- });
- }
- /**
- * Get current month summary
- * @param month
- * @returns any Monthly summary
- * @throws ApiError
- */
- public static getSummaryMonthly(
- month?: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/summary/monthly',
- query: {
- 'month': month,
- },
- });
- }
- /**
- * Get summary for custom range
- * @param start
- * @param end
- * @returns any Summary
- * @throws ApiError
- */
- public static getSummary(
- start?: string,
- end?: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/summary',
- query: {
- 'start': start,
- 'end': end,
- },
- });
- }
- /**
- * Budget overrun alert
- * @returns any Alert info
- * @throws ApiError
- */
- public static getSummaryAlerts(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/summary/alerts',
- });
- }
- /**
- * Download/view receipt
- * @param id
- * @returns any Receipt file
- * @throws ApiError
- */
- public static getReceipts(
- id: string,
- ): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/receipts/{id}',
- path: {
- 'id': id,
- },
- });
- }
- /**
- * Get user profile
- * @returns any Profile info
- * @throws ApiError
- */
- public static getUserProfile(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/user/profile',
- });
- }
-}
+/* generated using openapi-typescript-codegen -- do not edit */
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+import type { CancelablePromise } from "../core/CancelablePromise";
+import { OpenAPI } from "../core/OpenAPI";
+import { request as __request } from "../core/request";
+export class DefaultService {
+ /**
+ * Register a new user
+ * @param requestBody
+ * @returns any User created
+ * @throws ApiError
+ */
+ public static postAuthSignup(requestBody: {
+ email: string;
+ password: string;
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "POST",
+ url: "/auth/signup",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Login and receive JWT token
+ * @param requestBody
+ * @returns any Token returned
+ * @throws ApiError
+ */
+ public static postAuthLogin(requestBody: {
+ email: string;
+ password: string;
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "POST",
+ url: "/auth/login",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * List all user expenses
+ * @param start Start date
+ * @param end End date
+ * @param category
+ * @param type
+ * @returns any List of expenses
+ * @throws ApiError
+ */
+ public static getExpenses(
+ start?: string,
+ end?: string,
+ category?: string,
+ type?: "recurring" | "one-time"
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/expenses",
+ query: {
+ start: start,
+ end: end,
+ category: category,
+ type: type,
+ },
+ });
+ }
+ /**
+ * Create a new expense
+ * @param formData
+ * @returns any Expense created
+ * @throws ApiError
+ */
+ public static postExpenses(formData?: {
+ amount: number;
+ date: string;
+ categoryId: string;
+ description?: string;
+ type?: "one-time" | "recurring";
+ startDate?: string;
+ endDate?: string;
+ receipt?: Blob;
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "POST",
+ url: "/expenses",
+ formData: formData,
+ mediaType: "multipart/form-data",
+ });
+ }
+ /**
+ * Get a single expense
+ * @param id
+ * @returns any Expense data
+ * @throws ApiError
+ */
+ public static getExpenses1(id: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/expenses/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * Update an expense
+ * @param id
+ * @param formData
+ * @returns any Expense updated
+ * @throws ApiError
+ */
+ public static putExpenses(
+ id: string,
+ formData?: {
+ amount?: number;
+ date?: string;
+ categoryId?: string;
+ description?: string;
+ type?: "one-time" | "recurring";
+ startDate?: string;
+ endDate?: string;
+ receipt?: Blob;
+ }
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "PUT",
+ url: "/expenses/{id}",
+ path: {
+ id: id,
+ },
+ formData: formData,
+ mediaType: "multipart/form-data",
+ });
+ }
+ /**
+ * Delete an expense
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteExpenses(id: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "DELETE",
+ url: "/expenses/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * Get system income categories
+ * @returns any List of system income categories"
+ * @throws ApiError
+ */
+ public static getIncomesCategories(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/incomes/categories",
+ });
+ }
+ /**
+ * Get user's custom income categories
+ * @returns any List of user's custom income categories
+ * @throws ApiError
+ */
+ public static getIncomesCustomCategories(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/incomes/custom-categories",
+ });
+ }
+ /**
+ * Create a new custom income category
+ * @param requestBody
+ * @returns any Income category created
+ * @throws ApiError
+ */
+ public static postIncomesCustomCategories(requestBody: {
+ category_name: string;
+ icon_url?: string;
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "POST",
+ url: "/incomes/custom-categories",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Update an income category
+ * @param id
+ * @param requestBody
+ * @returns any Income category updated
+ * @throws ApiError
+ */
+ public static putIncomesCustomCategories(
+ id: string,
+ requestBody: {
+ category_name: string;
+ icon_url?: string;
+ }
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "PUT",
+ url: "/incomes/custom-categories/{id}",
+ path: {
+ id: id,
+ },
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Delete an income category
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteIncomesCustomCategories(
+ id: string
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "DELETE",
+ url: "/incomes/custom-categories/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * List all incomes
+ * @param start
+ * @param end
+ * @returns any List of incomes
+ * @throws ApiError
+ */
+ public static getIncomes(
+ start?: string,
+ end?: string
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/incomes",
+ query: {
+ start: start,
+ end: end,
+ },
+ });
+ }
+ /**
+ * Create new income
+ * @param requestBody
+ * @returns any Income created
+ * @throws ApiError
+ */
+ public static postIncomes(requestBody?: {
+ amount: number;
+ date?: string;
+ source?: string;
+ description?: string;
+ category_id?: number;
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "POST",
+ url: "/incomes",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Get an income entry
+ * @param id
+ * @returns any Income data
+ * @throws ApiError
+ */
+ public static getIncomes1(id: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/incomes/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * Update an income entry
+ * @param id
+ * @param requestBody
+ * @returns any Income updated
+ * @throws ApiError
+ */
+ public static putIncomes(
+ id: string,
+ requestBody?: {
+ amount?: number;
+ date?: string;
+ source?: string;
+ description?: string;
+ }
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "PUT",
+ url: "/incomes/{id}",
+ path: {
+ id: id,
+ },
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Delete an income entry
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteIncomes(id: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "DELETE",
+ url: "/incomes/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * List user categories
+ * @returns any List of categories
+ * @throws ApiError
+ */
+ public static getCategories(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/categories",
+ });
+ }
+ /**
+ * Create new category
+ * @param requestBody
+ * @returns any Category created
+ * @throws ApiError
+ */
+ public static postCategories(requestBody?: {
+ name: string;
+ }): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "POST",
+ url: "/categories",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Rename a category
+ * @param id
+ * @param requestBody
+ * @returns any Category updated
+ * @throws ApiError
+ */
+ public static putCategories(
+ id: string,
+ requestBody?: {
+ name?: string;
+ }
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "PUT",
+ url: "/categories/{id}",
+ path: {
+ id: id,
+ },
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Delete a category
+ * @param id
+ * @returns void
+ * @throws ApiError
+ */
+ public static deleteCategories(id: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "DELETE",
+ url: "/categories/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * Get current month summary
+ * @param month
+ * @returns any Monthly summary
+ * @throws ApiError
+ */
+ public static getSummaryMonthly(month?: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/summary/monthly",
+ query: {
+ month: month,
+ },
+ });
+ }
+ /**
+ * Get summary for custom range
+ * @param start
+ * @param end
+ * @returns any Summary
+ * @throws ApiError
+ */
+ public static getSummary(
+ start?: string,
+ end?: string
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/summary",
+ query: {
+ start: start,
+ end: end,
+ },
+ });
+ }
+ /**
+ * Budget overrun alert
+ * @returns any Alert info
+ * @throws ApiError
+ */
+ public static getSummaryAlerts(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/summary/alerts",
+ });
+ }
+ /**
+ * Download/view receipt
+ * @param id
+ * @returns any Receipt file
+ * @throws ApiError
+ */
+ public static getReceipts(id: string): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/receipts/{id}",
+ path: {
+ id: id,
+ },
+ });
+ }
+ /**
+ * Get user profile
+ * @returns any Profile info
+ * @throws ApiError
+ */
+ public static getUserProfile(): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "GET",
+ url: "/user/profile",
+ });
+ }
+}
diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx
new file mode 100644
index 0000000..efa83d9
--- /dev/null
+++ b/client/src/components/IncomeForm.tsx
@@ -0,0 +1,167 @@
+import React, { useState, useEffect } from "react";
+import type {
+ Income,
+ CreateIncomeRequest,
+ UpdateIncomeRequest,
+} from "../types/Income";
+import { IncomeService } from "../services/IncomeService";
+import { useIncomeCategories } from "../hooks/useIncomeCategories";
+import { Button, TextField, Select, Dialog, useToast, Skeleton } from "../ui";
+
+interface IncomeFormProps {
+ income?: Income;
+ onSave: () => void;
+ onCancel: () => void;
+ open: boolean;
+}
+
+export const IncomeForm: React.FC = ({
+ income,
+ onSave,
+ onCancel,
+ open,
+}) => {
+ const [formData, setFormData] = useState<
+ CreateIncomeRequest | UpdateIncomeRequest
+ >({
+ amount: income?.amount || 0,
+ date: income?.date
+ ? new Date(income.date).toISOString().split("T")[0]
+ : new Date().toISOString().split("T")[0],
+ source: income?.source || "",
+ description: income?.description || "",
+ category_id: income?.category_id || 0,
+ });
+
+ const { categories, loading: categoriesLoading } = useIncomeCategories();
+ const [saving, setSaving] = useState(false);
+ const toast = useToast();
+
+ useEffect(() => {
+ if (income) {
+ setFormData({
+ amount: income.amount,
+ date: new Date(income.date).toISOString().split("T")[0],
+ source: income.source,
+ description: income.description || "",
+ category_id: income.category_id,
+ });
+ } else {
+ setFormData({
+ amount: 0,
+ date: new Date().toISOString().split("T")[0],
+ source: "",
+ description: "",
+ category_id: 0,
+ });
+ }
+ }, [income, open]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSaving(true);
+
+ try {
+ if (income) {
+ await IncomeService.updateIncome(income.income_id.toString(), formData);
+ toast.success("Income updated successfully");
+ } else {
+ await IncomeService.createIncome(formData as CreateIncomeRequest);
+ toast.success("Income created successfully");
+ }
+ onSave();
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to save income";
+ toast.error(message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleChange = (
+ field: keyof typeof formData,
+ value: string | number
+ ) => {
+ setFormData((prev) => ({
+ ...prev,
+ [field]:
+ field === "amount" || field === "category_id" ? Number(value) : value,
+ }));
+ };
+
+ return (
+
+ );
+};
diff --git a/client/src/components/IncomeList.tsx b/client/src/components/IncomeList.tsx
new file mode 100644
index 0000000..7baef49
--- /dev/null
+++ b/client/src/components/IncomeList.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import type { Income } from "../types/Income";
+import { useIncomes } from "../hooks/useIncomes";
+import { Button, Skeleton, Chip } from "../ui";
+
+interface IncomeListProps {
+ startDate?: string;
+ endDate?: string;
+ onEdit: (income: Income) => void;
+ onDelete: (income: Income) => void;
+}
+
+export const IncomeList: React.FC = ({
+ startDate,
+ endDate,
+ onEdit,
+ onDelete,
+}) => {
+ const { incomes, loading, error, refetch } = useIncomes(startDate, endDate);
+
+ if (loading) {
+ return (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Error: {error}
+
+
+ );
+ }
+
+ return (
+
+
+
Incomes
+
+
+
+ {incomes.length === 0 ? (
+
+ No incomes found for the selected period
+
+ ) : (
+
+ {incomes.map((income) => (
+
+
+
+
+
+ ${income.amount.toFixed(2)}
+
+ {income.category && (
+
+ )}
+
+
{income.source}
+ {income.description && (
+
+ {income.description}
+
+ )}
+
+ {new Date(income.date).toLocaleDateString()}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/client/src/hooks/useIncomeCategories.ts b/client/src/hooks/useIncomeCategories.ts
new file mode 100644
index 0000000..7684f7e
--- /dev/null
+++ b/client/src/hooks/useIncomeCategories.ts
@@ -0,0 +1,45 @@
+import type { IncomeCategory } from "../types/Income";
+import { IncomeService } from "../services/IncomeService";
+import { useState, useEffect } from "react";
+
+export const useIncomeCategories = () => {
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchCategories = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await IncomeService.getIncomeCategories();
+ setCategories(data);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to fetch categories"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchCategories();
+ }, []);
+
+ const refetch = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await IncomeService.getIncomeCategories();
+ setCategories(data);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to refetch categories"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return { categories, loading, error, refetch };
+};
diff --git a/client/src/hooks/useIncomes.ts b/client/src/hooks/useIncomes.ts
new file mode 100644
index 0000000..fa86774
--- /dev/null
+++ b/client/src/hooks/useIncomes.ts
@@ -0,0 +1,47 @@
+import { useState, useEffect } from "react";
+import type { Income } from "../types/Income";
+import { IncomeService } from "../services/IncomeService";
+
+export const useIncomes = (startDate?: string, endDate?: string) => {
+ const [incomes, setIncomes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchIncomes = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await IncomeService.getIncomes(startDate, endDate);
+ setIncomes(data);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to fetch incomes"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchIncomes();
+ }, [startDate, endDate]);
+
+ const refetch = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const data = await IncomeService.getIncomes(startDate, endDate);
+ setIncomes(data);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to refetch incomes"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return { incomes, loading, error, refetch };
+};
+
+
diff --git a/client/src/index.css b/client/src/index.css
index 7cc56a6..e075c7a 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -1,5 +1,14 @@
@import "tailwindcss";
+@theme {
+ --font-sans: "Clash Grotesk", system-ui, sans-serif;
+ --color-primary-dark: #010c19;
+ --color-primary: #01153e;
+ --color-primary-light: #052261;
+ --color-light: #e0e9f5;
+}
+
+
@layer base {
button {
border-radius: 8px;
diff --git a/client/src/main.tsx b/client/src/main.tsx
index bef5202..c3cbdd0 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -1,10 +1,10 @@
-import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
+import { BrowserRouter } from 'react-router-dom'
createRoot(document.getElementById('root')!).render(
-
+
- ,
+ ,
)
diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/IncomesPage.tsx
new file mode 100644
index 0000000..a81c2fd
--- /dev/null
+++ b/client/src/pages/IncomesPage.tsx
@@ -0,0 +1,121 @@
+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';
+
+export const IncomesPage: React.FC = () => {
+ const [editingIncome, setEditingIncome] = useState();
+ const [showForm, setShowForm] = useState(false);
+ const [dateFilter, setDateFilter] = useState<{ start?: string; end?: string }>({});
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
+ const [incomeToDelete, setIncomeToDelete] = useState(null);
+ const toast = useToast();
+
+ const handleEdit = (income: Income) => {
+ setEditingIncome(income);
+ setShowForm(true);
+ };
+
+ const handleDeleteClick = (income: Income) => {
+ setIncomeToDelete(income);
+ setDeleteConfirmOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!incomeToDelete) return;
+
+ try {
+ await IncomeService.deleteIncome(incomeToDelete.income_id.toString());
+ toast.success('Income deleted successfully');
+ setDeleteConfirmOpen(false);
+ setIncomeToDelete(null);
+ } catch (error) {
+ 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);
+ };
+
+ return (
+
+
+
Income Management
+
+
+
+
+
+ setDateFilter(prev => ({ ...prev, start: e.target.value || undefined }))}
+ />
+ setDateFilter(prev => ({ ...prev, end: e.target.value || undefined }))}
+ />
+
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/client/src/services/IncomeService.ts b/client/src/services/IncomeService.ts
new file mode 100644
index 0000000..5937bf8
--- /dev/null
+++ b/client/src/services/IncomeService.ts
@@ -0,0 +1,134 @@
+import { DefaultService } from "../api/services/DefaultService";
+import type {
+ Income,
+ IncomeCategory,
+ CreateIncomeRequest,
+ UpdateIncomeRequest,
+} from "../types/Income";
+
+export class IncomeService {
+ //GET all incomes with optional filtering
+ static async getIncomes(start?: string, end?: string) {
+ try {
+ const response = await DefaultService.getIncomes(start, end);
+ return response as Income[];
+ } catch (error) {
+ console.error("Error fetching incomes:", error);
+ throw new Error("Failed to fetch incomes");
+ }
+ }
+
+ //GET income by id
+ static async getIncomeById(id: string) {
+ try {
+ const response = await DefaultService.getIncomes1(id);
+ return response as Income;
+ } catch (error) {
+ console.error(`Error fetching income ${id}:`, error);
+ throw new Error("Failed to fetch income");
+ }
+ }
+
+ //POST income
+ static async createIncome(incomeData: CreateIncomeRequest) {
+ try {
+ const requestData = {
+ ...incomeData,
+ amount: Number(incomeData.amount),
+ };
+
+ if (incomeData.date) {
+ requestData.date = incomeData.date;
+ }
+ const response = await DefaultService.postIncomes(requestData);
+ return response as Income;
+ } catch (error) {
+ console.error("Error creating income:", error);
+ throw new Error("Failed to create income");
+ }
+ }
+
+ //UPDATE income
+ static async updateIncome(id: string, incomeData: UpdateIncomeRequest) {
+ try {
+ const requestData = { ...incomeData };
+ if (incomeData.amount !== undefined) {
+ requestData.amount = Number(incomeData.amount);
+ }
+
+ const response = await DefaultService.putIncomes(id, requestData);
+ return response as Income;
+ } catch (error) {
+ console.error(`Error updating income ${id}:`, error);
+ throw new Error("Failed to update income");
+ }
+ }
+
+ //DELETE income
+ static async deleteIncome(id: string) {
+ try {
+ await DefaultService.deleteIncomes(id);
+ } catch (error) {
+ console.error(`Error deleting income ${id}:`, error);
+ throw new Error("Failed to delete income");
+ }
+ }
+
+ //GET ALL income categories (custom and system)
+ static async getIncomeCategories() {
+ try {
+ const response = await DefaultService.getIncomesCategories();
+
+ return response as IncomeCategory[];
+ } catch (error) {
+ console.error("Error fetching income categories:", error);
+ throw new Error("Failed to fetch income categories");
+ }
+ }
+
+ //GET custom categories
+ static async getCustomIncomeCategories() {
+ try {
+ const response = await DefaultService.getIncomesCustomCategories();
+ return response as IncomeCategory[];
+ } catch (error) {
+ console.error("Error fetching custom income categories:", error);
+ throw new Error("Failed to fetch custom income categories");
+ }
+ }
+
+ //POST new category
+ static async createIncomeCategory(name: string) {
+ try {
+ const response = await DefaultService.postIncomesCustomCategories({ category_name : name });
+ return response as IncomeCategory;
+ } catch (error) {
+ console.error("Error creating income category:", error);
+ throw new Error("Failed to create income category");
+ }
+ }
+
+ //UPDATE a category
+ static async updateIncomeCategory(
+ id: string,
+ name: string
+ ): Promise {
+ try {
+ const response = await DefaultService.putIncomesCustomCategories(id, { category_name: name });
+ return response as IncomeCategory;
+ } catch (error) {
+ console.error(`Error updating income category ${id}:`, error);
+ throw new Error("Failed to update income category");
+ }
+ }
+
+ //DELETE category
+ static async deleteIncomeCategory(id: string) {
+ try {
+ await DefaultService.deleteIncomesCustomCategories(id);
+ } catch (error) {
+ console.error(`Error deleting income category ${id}:`, error);
+ throw new Error("Failed to delete income category");
+ }
+ }
+}
diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts
new file mode 100644
index 0000000..dcc94db
--- /dev/null
+++ b/client/src/types/Income.ts
@@ -0,0 +1,37 @@
+export interface Income {
+ income_id: number;
+ amount: number;
+ date: string;
+ source: string;
+ description?: string;
+ creation_date: string;
+ user_id: number;
+ category_id: number;
+ category?: IncomeCategory;
+}
+
+export interface IncomeCategory {
+ category_id: number;
+ category_name: string;
+ icon_url?: string;
+ is_custom: boolean;
+ user_id?: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateIncomeRequest {
+ amount: number;
+ date?: string;
+ source: string;
+ description?: string;
+ category_id: number;
+}
+
+export interface UpdateIncomeRequest {
+ amount?: number;
+ date?: string;
+ source?: string;
+ description?: string;
+ category_id?: number;
+}
\ No newline at end of file
diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js
new file mode 100644
index 0000000..2226afa
--- /dev/null
+++ b/server/controllers/income.controller.js
@@ -0,0 +1,318 @@
+import incomeService from "../services/income.service.js";
+import { asyncHandler } from "../utils/asyncHandler.js";
+
+//---------------INCOME---------------//
+
+export const createIncome = asyncHandler(async (req, res) => {
+ const { amount, date, source, description, category_id } = req.body;
+
+ if (!amount) {
+ return res.status(400).json({ error: "Missing required fields" });
+ }
+
+ const incomeData = {
+ amount: parseFloat(amount),
+ source: source || "",
+ description: description || "",
+ };
+
+ 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();
+ }
+
+ 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) {
+ const parsedStart = new Date(start);
+ if (!isNaN(parsedStart.getTime())) {
+ filters.start = parsedStart;
+ } else {
+ return res.status(400).json({ error: "Invalid start date format" });
+ }
+ }
+
+ 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;
+
+ if (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid income ID" });
+ }
+
+ 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.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;
+ }
+
+ try {
+ const income = await incomeService.updateIncome(
+ id,
+ req.user.user_id,
+ updates
+ );
+ res.json(income);
+ } catch (error) {
+ 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) {
+ 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) => {
+ 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) => {
+ 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) => {
+ try {
+ const { category_name, icon_url, icon_emoji } = req.body;
+
+ if (!category_name) {
+ return res.status(400).json({ error: "Category name is required" });
+ }
+
+ const category = await incomeService.createIncomeCategory(
+ req.user.user_id,
+ {
+ category_name,
+ icon_url,
+ icon_emoji,
+ }
+ );
+ res.status(201).json(category);
+ } catch (error) {
+ 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) => {
+ 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 (!id || isNaN(parseInt(id))) {
+ return res.status(400).json({ error: "Invalid category ID" });
+ }
+
+ const category = await incomeService.updateIncomeCategory(
+ id,
+ req.user.user_id,
+ { category_name, icon_url, icon_emoji }
+ );
+ res.json(category);
+ } catch (error) {
+ 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) => {
+ 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) {
+ 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/src/lib/prisma.js b/server/db/prisma.js
similarity index 100%
rename from server/src/lib/prisma.js
rename to server/db/prisma.js
diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml
index 10fe973..89a535e 100644
--- a/server/docs/Expense Tracker API.yaml
+++ b/server/docs/Expense Tracker API.yaml
@@ -22,7 +22,7 @@ paths:
password:
type: string
responses:
- '201':
+ "201":
description: User created
/auth/login:
post:
@@ -40,7 +40,7 @@ paths:
password:
type: string
responses:
- '200':
+ "200":
description: Token returned
/expenses:
get:
@@ -66,7 +66,7 @@ paths:
type: string
enum: [recurring, one-time]
responses:
- '200':
+ "200":
description: List of expenses
post:
summary: Create a new expense
@@ -96,19 +96,19 @@ paths:
type: string
format: binary
responses:
- '201':
+ "201":
description: Expense created
/expenses/{id}:
parameters:
- - in: path
- name: id
- 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:
summary: Update an expense
@@ -137,13 +137,71 @@ paths:
type: string
format: binary
responses:
- '200':
+ "200":
description: Expense updated
delete:
summary: Delete an expense
responses:
- '204':
+ "204":
description: Expense 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
@@ -157,7 +215,7 @@ paths:
schema:
type: string
responses:
- '200':
+ "200":
description: List of incomes
post:
summary: Create new income
@@ -166,7 +224,7 @@ paths:
application/json:
schema:
type: object
- required: [amount, date]
+ required: [amount]
properties:
amount:
type: number
@@ -176,20 +234,22 @@ paths:
type: string
description:
type: string
+ category_id:
+ type: number
responses:
- '201':
+ "201":
description: Income created
/incomes/{id}:
parameters:
- - in: path
- name: id
- 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:
summary: Update an income entry
@@ -208,18 +268,18 @@ 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:
summary: Create new category
@@ -233,7 +293,7 @@ paths:
name:
type: string
responses:
- '201':
+ "201":
description: Category created
/categories/{id}:
put:
@@ -253,7 +313,7 @@ paths:
name:
type: string
responses:
- '200':
+ "200":
description: Category updated
delete:
summary: Delete a category
@@ -264,7 +324,7 @@ paths:
schema:
type: string
responses:
- '204':
+ "204":
description: Category deleted
/summary/monthly:
get:
@@ -275,7 +335,7 @@ paths:
schema:
type: string
responses:
- '200':
+ "200":
description: Monthly summary
/summary:
get:
@@ -290,13 +350,13 @@ paths:
schema:
type: string
responses:
- '200':
+ "200":
description: Summary
/summary/alerts:
get:
summary: Budget overrun alert
responses:
- '200':
+ "200":
description: Alert info
/receipts/{id}:
get:
@@ -308,11 +368,11 @@ paths:
schema:
type: string
responses:
- '200':
+ "200":
description: Receipt file
/user/profile:
get:
summary: Get user profile
responses:
- '200':
- description: Profile info
\ No newline at end of file
+ "200":
+ description: Profile info
diff --git a/server/package.json b/server/package.json
index 8fe6e2f..d79bff0 100644
--- a/server/package.json
+++ b/server/package.json
@@ -27,8 +27,7 @@
"express": "^4.18.2",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
- "pg": "^8.11.3",
- "@prisma/client": "^5.18.0"
+ "pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.2",
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 1c871fb..7f9dd94 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -6,7 +6,6 @@
generator client {
provider = "prisma-client-js"
- output = "../generated/prisma"
}
datasource db {
@@ -49,7 +48,6 @@ model IncomeCategory {
category_id Int @id @default(autoincrement())
category_name String @db.VarChar(50)
icon_url String? @db.VarChar(500)
- icon_emoji String? @db.VarChar(10)
is_custom Boolean @default(false)
user_id Int?
user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade)
diff --git a/server/routes/income.route.js b/server/routes/income.route.js
new file mode 100644
index 0000000..32e8cc8
--- /dev/null
+++ b/server/routes/income.route.js
@@ -0,0 +1,22 @@
+import { Router } from 'express';
+import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/income.controller.js';
+
+const router = Router();
+
+//INCOME CATEGORY
+router.get('/categories', getIncomeCategories);
+router.get('/custom-categories', getIncomeCategoriesByUser)
+router.post('/custom-categories', createIncomeCategory);
+router.put('/custom-categories/:id', updateIncomeCategory);
+router.delete('/custom-categories/:id', deleteIncomeCategory);
+
+//INCOME
+router.get('/', getIncomes);
+router.get('/:id', getIncome);
+router.post('/', createIncome);
+router.put('/:id', updateIncome);
+router.delete('/:id', deleteIncome);
+
+
+
+export default router;
\ No newline at end of file
diff --git a/server/server.js b/server/server.js
index dd6bced..476fb04 100644
--- a/server/server.js
+++ b/server/server.js
@@ -2,6 +2,7 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import { PrismaClient } from '@prisma/client';
+import incomeRoutes from './routes/income.route.js';
dotenv.config();
@@ -11,6 +12,15 @@ const PORT = process.env.PORT || 8080;
app.use(cors());
app.use(express.json());
+//temporary middleware for testing
+app.use((req, res, next) => {
+ req.user = { user_id: 4 }; // fake user, but you have to create a fake user with id 4 as well in your local database
+ next();
+});
+
+//Routes
+app.use('/api/incomes', incomeRoutes);
+
// Initialize a single Prisma client instance
const prisma = new PrismaClient();
diff --git a/server/services/income.service.js b/server/services/income.service.js
new file mode 100644
index 0000000..10c959e
--- /dev/null
+++ b/server/services/income.service.js
@@ -0,0 +1,181 @@
+import { prisma } from "../db/prisma.js";
+
+//---------------INCOME---------------//
+
+//POST
+const createIncome = async (userId, data) => {
+ return await prisma.income.create({
+ data: {
+ ...data,
+ user: { connect: { user_id: userId } },
+ },
+ include: { category: true },
+ });
+};
+
+//GET ALL (with start and end date query params)
+const getIncomes = async (userId, filters = {}) => {
+ const where = { user_id: userId };
+
+ if (filters.start || filters.end) {
+ where.date = {};
+ if (filters.start) where.date.gte = new Date(filters.start);
+ if (filters.end) where.date.lte = new Date(filters.end);
+ }
+
+ return await prisma.income.findMany({
+ where,
+ include: { category: true },
+ orderBy: { date: "desc" },
+ });
+};
+
+//GET BY ID
+const getIncomeById = async (id, userId) => {
+ return await prisma.income.findFirst({
+ where: { income_id: parseInt(id), user_id: userId },
+ include: { category: true },
+ });
+};
+
+//UPDATE
+const updateIncome = async (id, userId, data) => {
+ const income = await prisma.income.findFirst({
+ where: { income_id: parseInt(id), user_id: userId },
+ });
+
+ if (!income) {
+ throw new Error("Income not found or not authorized");
+ }
+
+ return await prisma.income.update({
+ where: { income_id: parseInt(id) },
+ data,
+ include: { category: true },
+ });
+};
+
+//DELETE
+const deleteIncome = async (id, userId) => {
+ const income = await prisma.income.findFirst({
+ where: { income_id: parseInt(id), user_id: userId },
+ });
+
+ if (!income) {
+ throw new Error("Income not found or not authorized");
+ }
+
+ await prisma.income.delete({
+ where: { income_id: parseInt(id) },
+ });
+};
+
+//---------------INCOME CATEGORY---------------//
+
+//GET ALL
+const getIncomeCategories = async (userId) => {
+ return await prisma.incomeCategory.findMany({
+ where: {
+ OR: [{ user_id: null }, { user_id: userId }],
+ },
+ orderBy: [{ is_custom: "asc" }, { category_name: "asc" }],
+ });
+};
+
+//GET ALL BY USER
+const getIncomeCategoriesByUser = async (userId) => {
+ return await prisma.incomeCategory.findMany({
+ where: {
+ user_id: userId,
+ },
+ orderBy: [{ is_custom: "asc" }, { category_name: "asc" }],
+ });
+};
+
+//POST
+const createIncomeCategory = async (userId, data) => {
+ const existingCategory = await prisma.incomeCategory.findFirst({
+ where: {
+ category_name: data.category_name,
+ user_id: userId,
+ },
+ });
+
+ if (existingCategory) {
+ throw new Error("Category already exists");
+ }
+
+ return await prisma.incomeCategory.create({
+ data: {
+ ...data,
+ is_custom: true,
+ user: { connect: { user_id: userId } },
+ },
+ });
+};
+
+//UPDATE
+const updateIncomeCategory = async (id, userId, data) => {
+ const category = await prisma.incomeCategory.findFirst({
+ where: { category_id: parseInt(id), user_id: userId },
+ });
+
+ if (!category) {
+ throw new Error("Category not found or not authorized");
+ }
+
+ if (data.category_name) {
+ const duplicateCategory = await prisma.incomeCategory.findFirst({
+ where: {
+ category_name: data.category_name,
+ user_id: userId,
+ NOT: { category_id: parseInt(id) },
+ },
+ });
+
+ if (duplicateCategory) {
+ throw new Error("Category name already exists");
+ }
+ }
+
+ return await prisma.incomeCategory.update({
+ where: { category_id: parseInt(id) },
+ data,
+ });
+};
+
+//DELETE
+const deleteIncomeCategory = async (id, userId) => {
+ const category = await prisma.incomeCategory.findFirst({
+ where: { category_id: parseInt(id), user_id: userId },
+ });
+
+ if (!category) {
+ throw new Error("Category not found or not authorized");
+ }
+
+ const incomesUsingCategory = await prisma.income.count({
+ where: { category_id: parseInt(id) },
+ });
+
+ if (incomesUsingCategory > 0) {
+ throw new Error("Cannot delete category that is being used by incomes");
+ }
+
+ await prisma.incomeCategory.delete({
+ where: { category_id: parseInt(id) },
+ });
+};
+
+export default {
+ createIncome,
+ getIncomes,
+ getIncomeById,
+ updateIncome,
+ deleteIncome,
+ getIncomeCategories,
+ createIncomeCategory,
+ getIncomeCategoriesByUser,
+ updateIncomeCategory,
+ deleteIncomeCategory,
+};
diff --git a/server/utils/asyncHandler.js b/server/utils/asyncHandler.js
new file mode 100644
index 0000000..bb1b35f
--- /dev/null
+++ b/server/utils/asyncHandler.js
@@ -0,0 +1,3 @@
+export const asyncHandler = (fn) => (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+};