diff --git a/api/app/account/models.py b/api/app/account/models.py index 3af720a..d1cdbc0 100644 --- a/api/app/account/models.py +++ b/api/app/account/models.py @@ -1,11 +1,7 @@ from uuid import UUID, uuid4 from enum import Enum -from sqlmodel import Field, SQLModel, select, Relationship -from pydantic import Field as PydField - -from category.models import CategoryRead, Category - +from sqlmodel import Field, SQLModel, select class AccountType(Enum): Asset = "Asset" # < Denotes a generic asset account. @@ -21,24 +17,21 @@ class AccountType(Enum): MoneyMarket = "MoneyMarket" # < Money Market Account Currency = "Currency" # < Denotes a currency trading account. - Income = "Income" # < Denotes an income account - Expense = "Expense" # < Denotes an expense account AssetLoan = "AssetLoan" # < Denotes a loan (asset of the owner of this object) Stock = "Stock" # < Denotes an security account as sub-account for an investment Equity = "Equity" # < Denotes an equity account e.g. opening/closing balance - Payee = "Payee" + Income = "Income" # < Denotes an income account + Expense = "Expense" # < Denotes an expense account class AccountBase(SQLModel): name: str = Field(index=True) type: AccountType = Field(index=True) - default_category_id: UUID | None = Field(default=None, foreign_key="category.id") class AccountBaseId(AccountBase): id: UUID | None = Field(default_factory=uuid4, primary_key=True) class Account(AccountBaseId, table=True): - default_category: Category | None = Relationship() @classmethod def create(cls, account, session): @@ -51,7 +44,7 @@ class Account(AccountBaseId, table=True): @classmethod def list(cls): - return select(Account).join(Category) + return select(Account) @classmethod def get(cls, session, account_id): @@ -71,18 +64,10 @@ class Account(AccountBaseId, table=True): session.commit() class AccountRead(AccountBaseId): - default_category: CategoryRead + pass class AccountWrite(AccountBase): - default_category_id: UUID = PydField(default=None, json_schema_extra={ - "foreign_key": { - "reference": { - "resource": "categories", - "schema": "CategoryRead", - "label": "name" - } - } - }) + pass class AccountCreate(AccountWrite): pass diff --git a/api/app/category/models.py b/api/app/category/models.py deleted file mode 100644 index 7a8edaa..0000000 --- a/api/app/category/models.py +++ /dev/null @@ -1,60 +0,0 @@ -from uuid import UUID, uuid4 -from enum import Enum -from typing import Optional - -from fastapi_filter.contrib.sqlalchemy import Filter -from sqlmodel import Field, SQLModel, select - -class CategoryBase(SQLModel): - name: str = Field(index=True) - -class CategoryRead(CategoryBase): - id: UUID | None = Field(default_factory=uuid4, primary_key=True) - -class Category(CategoryRead, table=True): - - @classmethod - def create(cls, category, session): - category_db = cls.model_validate(category) - session.add(category_db) - session.commit() - session.refresh(category_db) - - return category_db - - @classmethod - def list(cls, filters): - return filters.filter(select(cls)) - - @classmethod - def get(cls, session, category_id): - return session.get(Category, category_id) - - @classmethod - def update(cls, session, category_db, category_data): - category_db.sqlmodel_update(category_data) - session.add(category_db) - session.commit() - session.refresh(category_db) - return category_db - - @classmethod - def delete(cls, session, category): - session.delete(category) - session.commit() - -class CategoryWrite(CategoryBase): - pass - -class CategoryCreate(CategoryWrite): - pass - -class CategoryUpdate(CategoryWrite): - pass - -class CategoryFilters(Filter): - name__like: Optional[str] = None - - class Constants(Filter.Constants): - model = Category - search_model_fields = ["name"] diff --git a/api/app/category/routes.py b/api/app/category/routes.py deleted file mode 100644 index 0129007..0000000 --- a/api/app/category/routes.py +++ /dev/null @@ -1,49 +0,0 @@ -from uuid import UUID - -from fastapi import APIRouter, HTTPException, Depends -from fastapi_filter import FilterDepends -from fastapi_pagination import Page -from fastapi_pagination.ext.sqlmodel import paginate - -from category.models import Category, CategoryCreate, CategoryRead, CategoryUpdate, CategoryFilters -from db import SessionDep -from user.manager import get_current_user - -router = APIRouter() - -@router.post("") -def create_category(category: CategoryCreate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead: - Category.create(category, session) - return category - -@router.get("") -def read_categories(session: SessionDep, - filters: CategoryFilters = FilterDepends(CategoryFilters), - current_user=Depends(get_current_user)) -> Page[CategoryRead]: - return paginate(session, Category.list(filters)) - -@router.get("/{category_id}") -def read_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead: - category = Category.get(session, category_id) - if not category: - raise HTTPException(status_code=404, detail="Category not found") - return category - -@router.put("/{category_id}") -def update_category(category_id: UUID, category: CategoryUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead: - db_category = Category.get(session, category_id) - if not db_category: - raise HTTPException(status_code=404, detail="Category not found") - - category_data = category.model_dump(exclude_unset=True) - category = Category.update(session, db_category, category_data) - return category - -@router.delete("/{category_id}") -def delete_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)): - category = Category.get(session, category_id) - if not category: - raise HTTPException(status_code=404, detail="Category not found") - - Category.delete(session, category) - return {"ok": True} diff --git a/api/app/main.py b/api/app/main.py index 59c37c9..ceb480c 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -9,7 +9,8 @@ from fastapi_pagination import add_pagination from db import create_db_and_tables from user import user_router, auth_router, create_admin_account from account.routes import router as account_router -from category.routes import router as category_router +from payee.routes import router as payee_router +from transaction.routes import router as transaction_router @asynccontextmanager @@ -38,7 +39,8 @@ app.add_middleware( app.include_router(auth_router, prefix="/auth", tags=["auth"], ) app.include_router(user_router, prefix="/users", tags=["users"]) app.include_router(account_router, prefix="/accounts", tags=["accounts"]) -app.include_router(category_router, prefix="/categories", tags=["categories"]) +app.include_router(payee_router, prefix="/payees", tags=["payees"]) +app.include_router(transaction_router, prefix="/transactions", tags=["transactions"]) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") diff --git a/api/app/category/__init__.py b/api/app/payee/__init__.py similarity index 100% rename from api/app/category/__init__.py rename to api/app/payee/__init__.py diff --git a/api/app/payee/models.py b/api/app/payee/models.py new file mode 100644 index 0000000..405d450 --- /dev/null +++ b/api/app/payee/models.py @@ -0,0 +1,76 @@ +from uuid import UUID, uuid4 +from typing import Optional + +from fastapi_filter.contrib.sqlalchemy import Filter +from sqlmodel import Field, SQLModel, select, Relationship +from pydantic import Field as PydField + +from account.models import Account, AccountRead + + +class PayeeBase(SQLModel): + name: str = Field(index=True) + default_account_id: UUID | None = Field(default=None, foreign_key="account.id") + +class PayeeBaseId(PayeeBase): + id: UUID | None = Field(default_factory=uuid4, primary_key=True) + +class Payee(PayeeBaseId, table=True): + default_account: Account | None = Relationship() + + @classmethod + def create(cls, payee, session): + payee_db = cls.model_validate(payee) + session.add(payee_db) + session.commit() + session.refresh(payee_db) + + return payee_db + + @classmethod + def list(cls, filters): + return filters.filter(select(cls)).join(Account) + + @classmethod + def get(cls, session, payee_id): + return session.get(Payee, payee_id) + + @classmethod + def update(cls, session, payee_db, payee_data): + payee_db.sqlmodel_update(payee_data) + session.add(payee_db) + session.commit() + session.refresh(payee_db) + return payee_db + + @classmethod + def delete(cls, session, payee): + session.delete(payee) + session.commit() + +class PayeeRead(PayeeBaseId): + default_account: AccountRead + +class PayeeWrite(PayeeBase): + default_account_id: UUID = PydField(default=None, json_schema_extra={ + "foreign_key": { + "reference": { + "resource": "accounts", + "schema": "AccountRead", + "label": "name" + } + } + }) + +class PayeeCreate(PayeeWrite): + pass + +class PayeeUpdate(PayeeWrite): + pass + +class PayeeFilters(Filter): + name__like: Optional[str] = None + + class Constants(Filter.Constants): + model = Payee + search_model_fields = ["name"] diff --git a/api/app/payee/routes.py b/api/app/payee/routes.py new file mode 100644 index 0000000..912e02e --- /dev/null +++ b/api/app/payee/routes.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Depends +from fastapi_filter import FilterDepends +from fastapi_pagination import Page +from fastapi_pagination.ext.sqlmodel import paginate + +from payee.models import Payee, PayeeCreate, PayeeRead, PayeeUpdate, PayeeFilters +from db import SessionDep +from user.manager import get_current_user + +router = APIRouter() + +@router.post("") +def create_payee(payee: PayeeCreate, session: SessionDep, current_user=Depends(get_current_user)) -> PayeeRead: + result = Payee.create(payee, session) + return Payee.get(session, result.id) + +@router.get("") +def read_categories(session: SessionDep, + filters: PayeeFilters = FilterDepends(PayeeFilters), + current_user=Depends(get_current_user)) -> Page[PayeeRead]: + return paginate(session, Payee.list(filters)) + +@router.get("/{payee_id}") +def read_payee(payee_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> PayeeRead: + payee = Payee.get(session, payee_id) + if not payee: + raise HTTPException(status_code=404, detail="Payee not found") + return payee + +@router.put("/{payee_id}") +def update_payee(payee_id: UUID, payee: PayeeUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> PayeeRead: + db_payee = Payee.get(session, payee_id) + if not db_payee: + raise HTTPException(status_code=404, detail="Payee not found") + + payee_data = payee.model_dump(exclude_unset=True) + payee = Payee.update(session, db_payee, payee_data) + return payee + +@router.delete("/{payee_id}") +def delete_payee(payee_id: UUID, session: SessionDep, current_user=Depends(get_current_user)): + payee = Payee.get(session, payee_id) + if not payee: + raise HTTPException(status_code=404, detail="Payee not found") + + Payee.delete(session, payee) + return {"ok": True} diff --git a/api/app/transaction/__init__.py b/api/app/transaction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/transaction/models.py b/api/app/transaction/models.py new file mode 100644 index 0000000..5e4034e --- /dev/null +++ b/api/app/transaction/models.py @@ -0,0 +1,127 @@ +from decimal import Decimal +from uuid import UUID, uuid4 + +from sqlmodel import Field, SQLModel, select, Relationship +from pydantic import Field as PydField + +from account.models import Account, AccountRead +from payee.models import Payee, PayeeRead + + +class TransactionBase(SQLModel): + pass + +class TransactionBaseId(TransactionBase): + id: UUID | None = Field(default_factory=uuid4, primary_key=True) + +class Transaction(TransactionBaseId, table=True): + splits: list["Split"] = Relationship(back_populates="transaction") + + @classmethod + def create(cls, transaction, session): + transaction_db = cls.model_validate(transaction) + session.add(transaction_db) + session.commit() + session.refresh(transaction_db) + + return transaction_db + + @classmethod + def list(cls): + return select(Transaction).join(Split).join(Account) + + @classmethod + def get(cls, session, transaction_id): + return session.get(Transaction, transaction_id) + + @classmethod + def update(cls, session, transaction_db, transaction_data): + transaction_db.sqlmodel_update(transaction_data) + session.add(transaction_db) + session.commit() + session.refresh(transaction_db) + return transaction_db + + @classmethod + def delete(cls, session, transaction): + session.delete(transaction) + session.commit() + +class SplitBase(SQLModel): + account_id: UUID = Field(foreign_key="account.id") + payee_id: UUID = Field(foreign_key="payee.id") + amount: Decimal = Field(decimal_places=2) + +class SplitBaseId(SplitBase): + transaction_id: UUID = Field(primary_key=True, foreign_key="transaction.id") + id: int = Field(primary_key=True) + +class SplitRead(SplitBaseId): + account: AccountRead + payee: PayeeRead + +class TransactionRead(TransactionBaseId): + splits: list[SplitRead] + +class SplitWrite(SplitBase): + account_id: UUID = PydField(json_schema_extra={ + "foreign_key": { + "reference": { + "resource": "accounts", + "schema": "AccountRead", + "label": "name" + } + } + }) + payee_id: UUID = PydField(json_schema_extra={ + "foreign_key": { + "reference": { + "resource": "payees", + "schema": "PayeeRead", + "label": "name" + } + } + }) + +class TransactionWrite(TransactionBase): + splits: list[SplitWrite] = Field() + +class TransactionCreate(TransactionWrite): + pass + +class TransactionUpdate(TransactionWrite): + pass + +class Split(SplitBaseId, table=True): + transaction: Transaction = Relationship(back_populates="splits") + account: Account | None = Relationship() + payee: Payee | None = Relationship() + + @classmethod + def create(cls, transaction_split, session): + transaction_split_db = cls.model_validate(transaction_split) + session.add(transaction_split_db) + session.commit() + session.refresh(transaction_split_db) + + return transaction_split_db + + @classmethod + def update(cls, session, transaction_db, transaction_data): + transaction_db.sqlmodel_update(transaction_data) + session.add(transaction_db) + session.commit() + session.refresh(transaction_db) + return transaction_db + + @classmethod + def delete(cls, session, transaction): + session.delete(transaction) + session.commit() + + +class SplitCreate(SplitWrite): + pass + +class SplitUpdate(SplitWrite): + pass \ No newline at end of file diff --git a/api/app/transaction/routes.py b/api/app/transaction/routes.py new file mode 100644 index 0000000..9bb7098 --- /dev/null +++ b/api/app/transaction/routes.py @@ -0,0 +1,46 @@ +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Depends +from fastapi_pagination import Page +from fastapi_pagination.ext.sqlmodel import paginate + +from transaction.models import Transaction, TransactionCreate, TransactionRead, TransactionUpdate +from db import SessionDep +from user.manager import get_current_user + +router = APIRouter() + +@router.post("") +def create_transaction(transaction: TransactionCreate, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead: + result = Transaction.create(transaction, session) + return result + +@router.get("") +def read_transactions(session: SessionDep, current_user=Depends(get_current_user)) -> Page[TransactionRead]: + return paginate(session, Transaction.list()) + +@router.get("/{transaction_id}") +def read_transaction(transaction_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead: + transaction = Transaction.get(session, transaction_id) + if not transaction: + raise HTTPException(status_code=404, detail="Transaction not found") + return transaction + +@router.put("/{transaction_id}") +def update_transaction(transaction_id: UUID, transaction: TransactionUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead: + db_transaction = Transaction.get(session, transaction_id) + if not db_transaction: + raise HTTPException(status_code=404, detail="Transaction not found") + + transaction_data = transaction.model_dump(exclude_unset=True) + transaction = Transaction.update(session, db_transaction, transaction_data) + return transaction + +@router.delete("/{transaction_id}") +def delete_transaction(transaction_id: UUID, session: SessionDep, current_user=Depends(get_current_user)): + transaction = Transaction.get(session, transaction_id) + if not transaction: + raise HTTPException(status_code=404, detail="Transaction not found") + + Transaction.delete(session, transaction) + return {"ok": True} diff --git a/gui/app/src/App.tsx b/gui/app/src/App.tsx index eb9cd5d..a378d41 100644 --- a/gui/app/src/App.tsx +++ b/gui/app/src/App.tsx @@ -1,4 +1,4 @@ -import { Refine, type AuthProvider, Authenticated } from "@refinedev/core"; +import { Refine, Authenticated } from "@refinedev/core"; import { ThemedLayoutV2, ErrorComponent, @@ -13,7 +13,6 @@ import FormControlLabel from "@mui/material/FormControlLabel"; import Checkbox from "@mui/material/Checkbox"; import { ThemeProvider } from "@mui/material/styles"; import { authProvider } from "./providers/auth-provider"; -//import dataProvider from "@refinedev/simple-rest"; import { dataProvider } from "./providers/data-provider"; import routerProvider, { NavigateToResource, @@ -24,9 +23,10 @@ import routerProvider, { import { BrowserRouter, Routes, Route, Outlet } from "react-router"; import { useFormContext } from "react-hook-form"; -import { PostList, PostCreate, PostEdit } from "../src/pages/posts"; -import { AccountList, AccountCreate, AccountEdit } from "../src/pages/accounts"; -import { CategoryList, CategoryCreate, CategoryEdit } from "../src/pages/categories"; +import { PostList, PostCreate, PostEdit } from "./pages/posts"; +import { AccountList, AccountCreate, AccountEdit } from "./pages/accounts"; +import { PayeeCreate, PayeeEdit, PayeeList } from "./pages/payees"; +import { TransactionCreate, TransactionEdit, TransactionList } from "./pages/transactions"; /** * mock auth credentials to simulate authentication @@ -82,10 +82,16 @@ const App: React.FC = () => { create: "/accounts/create", }, { - name: "categories", - list: "/categories", - edit: "/categories/edit/:id", - create: "/categories/create", + name: "payees", + list: "/payees", + edit: "/payees/edit/:id", + create: "/payees/create", + }, + { + name: "transactions", + list: "/transactions", + edit: "/transactions/edit/:id", + create: "/transactions/create", }, ]} options={{ @@ -123,10 +129,15 @@ const App: React.FC = () => { } /> } /> - - } /> - } /> - } /> + + } /> + } /> + } /> + + + } /> + } /> + } /> diff --git a/gui/app/src/pages/categories/create.tsx b/gui/app/src/pages/categories/create.tsx deleted file mode 100644 index b1b577a..0000000 --- a/gui/app/src/pages/categories/create.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {CrudForm} from "../../common/crud/crud-form"; - - -export const CategoryCreate: React.FC = () => { - return ( - - ); -}; diff --git a/gui/app/src/pages/payees/create.tsx b/gui/app/src/pages/payees/create.tsx new file mode 100644 index 0000000..3f4dc90 --- /dev/null +++ b/gui/app/src/pages/payees/create.tsx @@ -0,0 +1,11 @@ +import {CrudForm} from "../../common/crud/crud-form"; + + +export const PayeeCreate: React.FC = () => { + return ( + + ); +}; diff --git a/gui/app/src/pages/categories/edit.tsx b/gui/app/src/pages/payees/edit.tsx similarity index 62% rename from gui/app/src/pages/categories/edit.tsx rename to gui/app/src/pages/payees/edit.tsx index 831604b..bd0f119 100644 --- a/gui/app/src/pages/categories/edit.tsx +++ b/gui/app/src/pages/payees/edit.tsx @@ -2,13 +2,13 @@ import { CrudForm } from "../../common/crud/crud-form"; import { useParams } from "react-router" -export const CategoryEdit: React.FC = () => { +export const PayeeEdit: React.FC = () => { const { id } = useParams() return ( ); diff --git a/gui/app/src/pages/categories/index.tsx b/gui/app/src/pages/payees/index.tsx similarity index 100% rename from gui/app/src/pages/categories/index.tsx rename to gui/app/src/pages/payees/index.tsx diff --git a/gui/app/src/pages/categories/list.tsx b/gui/app/src/pages/payees/list.tsx similarity index 96% rename from gui/app/src/pages/categories/list.tsx rename to gui/app/src/pages/payees/list.tsx index 8c014e2..c21e409 100644 --- a/gui/app/src/pages/categories/list.tsx +++ b/gui/app/src/pages/payees/list.tsx @@ -6,7 +6,7 @@ import { DataGrid, type GridColDef } from "@mui/x-data-grid"; import type { IAccount } from "../../interfaces"; import {ButtonGroup} from "@mui/material"; -export const CategoryList: React.FC = () => { +export const PayeeList: React.FC = () => { const { dataGridProps } = useDataGrid(); const columns = React.useMemo[]>( diff --git a/gui/app/src/pages/transactions/create.tsx b/gui/app/src/pages/transactions/create.tsx new file mode 100644 index 0000000..3d157c2 --- /dev/null +++ b/gui/app/src/pages/transactions/create.tsx @@ -0,0 +1,11 @@ +import {CrudForm} from "../../common/crud/crud-form"; + + +export const TransactionCreate: React.FC = () => { + return ( + + ); +}; diff --git a/gui/app/src/pages/transactions/edit.tsx b/gui/app/src/pages/transactions/edit.tsx new file mode 100644 index 0000000..53ab8ec --- /dev/null +++ b/gui/app/src/pages/transactions/edit.tsx @@ -0,0 +1,15 @@ +import { CrudForm } from "../../common/crud/crud-form"; +import { useParams } from "react-router" + + +export const TransactionEdit: React.FC = () => { + const { id } = useParams() + + return ( + + ); +}; diff --git a/gui/app/src/pages/transactions/index.tsx b/gui/app/src/pages/transactions/index.tsx new file mode 100644 index 0000000..b9af745 --- /dev/null +++ b/gui/app/src/pages/transactions/index.tsx @@ -0,0 +1,3 @@ +export * from "./list"; +export * from "./create"; +export * from "./edit"; diff --git a/gui/app/src/pages/transactions/list.tsx b/gui/app/src/pages/transactions/list.tsx new file mode 100644 index 0000000..3297a2f --- /dev/null +++ b/gui/app/src/pages/transactions/list.tsx @@ -0,0 +1,48 @@ +import {DeleteButton, EditButton, List, useDataGrid} from "@refinedev/mui"; +import React from "react"; + +import { DataGrid, type GridColDef } from "@mui/x-data-grid"; + +import type { IAccount } from "../../interfaces"; +import {ButtonGroup} from "@mui/material"; + +export const TransactionList: React.FC = () => { + const { dataGridProps } = useDataGrid(); + + const columns = React.useMemo[]>( + () => [ + { field: "id", headerName: "ID" }, + { field: "name", headerName: "Name", flex: 1 }, + { + field: "actions", + headerName: "Actions", + display: "flex", + renderCell: function render({ row }) { + return ( + + + + + ); + }, + align: "center", + headerAlign: "center", + }, + ], + [], + ); + + return ( + +
+ +
+
+ ); +};