Compare commits
10 Commits
148b1d00c4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ed37298a74 | |||
| 950090c762 | |||
| 3268f065d3 | |||
| c8eb7cd9bf | |||
| a81c4fbd7d | |||
| cd325154da | |||
| 4a823b7115 | |||
| a83711315f | |||
| 699009d21a | |||
| 440401049a |
@@ -12,8 +12,6 @@ from account.models import Account
|
||||
from account.resource import AccountResource
|
||||
|
||||
from db import SessionDep
|
||||
from transaction.models import TransactionRead
|
||||
from transaction.resource import TransactionResource
|
||||
from user.manager import get_current_user
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlmodel import select
|
||||
from account.models import Account
|
||||
from account.schemas import OpeningTransaction
|
||||
from transaction.models import Split, Transaction
|
||||
from transaction.resource import TransactionResource
|
||||
|
||||
|
||||
class AccountResource:
|
||||
@@ -30,6 +31,7 @@ class AccountResource:
|
||||
equity_account = cls.get_by_path(session, "/Equity/")
|
||||
t = Transaction()
|
||||
t.transaction_date = schema.opening_date
|
||||
t.sequence = TransactionResource.get_sequence_number(session)
|
||||
split_opening = Split()
|
||||
split_opening.id = 0
|
||||
split_opening.transaction = t
|
||||
|
||||
@@ -2,6 +2,8 @@ import main
|
||||
|
||||
from account.resource import AccountResource
|
||||
from account.fixtures import inject_fixtures as account_inject_fixtures
|
||||
from payee.fixtures import inject_fixtures as payee_inject_fixtures
|
||||
from transaction.fixtures import inject_fixtures as transaction_inject_fixtures
|
||||
from db import create_db_and_tables, get_session, drop_tables
|
||||
from user import create_admin_user
|
||||
|
||||
@@ -12,3 +14,5 @@ session = get_session().__next__()
|
||||
create_admin_user(session)
|
||||
AccountResource.create_equity_account(session)
|
||||
account_inject_fixtures(session)
|
||||
payee_inject_fixtures(session)
|
||||
transaction_inject_fixtures(session)
|
||||
|
||||
0
api/app/ledger/__init__.py
Normal file
0
api/app/ledger/__init__.py
Normal file
26
api/app/ledger/resource.py
Normal file
26
api/app/ledger/resource.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import and_, select, func
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from transaction.models import Split, Transaction
|
||||
|
||||
class LedgerResource:
|
||||
@classmethod
|
||||
def get_ledger(cls, account_id, filters):
|
||||
split_account = aliased(Split)
|
||||
split_balance = aliased(Split)
|
||||
transaction_balance = aliased(Transaction)
|
||||
|
||||
balance_stmt = select(func.sum(split_balance.amount)) \
|
||||
.join(transaction_balance) \
|
||||
.where(and_(
|
||||
split_balance.account_id == split_account.account_id,
|
||||
transaction_balance.sequence <= Transaction.sequence)
|
||||
).scalar_subquery()
|
||||
|
||||
stmt = select(Transaction,split_account,balance_stmt.label('balance')) \
|
||||
.join(
|
||||
split_account,
|
||||
and_(Transaction.id == split_account.transaction_id, split_account.account_id == account_id)
|
||||
)
|
||||
|
||||
return filters.filter(stmt)
|
||||
31
api/app/ledger/routes.py
Normal file
31
api/app/ledger/routes.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi_filter import FilterDepends
|
||||
from fastapi_filter.contrib.sqlalchemy import Filter
|
||||
from fastapi_pagination import Page
|
||||
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||
from fastapi_pagination.utils import disable_installed_extensions_check
|
||||
|
||||
from db import SessionDep
|
||||
from ledger.resource import LedgerResource
|
||||
from ledger.schema import TransactionLedgerRead
|
||||
from transaction.models import Transaction
|
||||
from user.manager import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class LedgerFilters(Filter):
|
||||
|
||||
class Constants(Filter.Constants):
|
||||
model = Transaction
|
||||
search_model_fields = ["id", "sequence"]
|
||||
disable_installed_extensions_check()
|
||||
|
||||
@router.get("/{account_id}")
|
||||
def read_ledger(account_id: UUID, session: SessionDep, filters: LedgerFilters = FilterDepends(LedgerFilters), current_user=Depends(get_current_user)) -> Page[TransactionLedgerRead]:
|
||||
return paginate(
|
||||
session,
|
||||
LedgerResource.get_ledger(account_id, filters),
|
||||
transformer=lambda items: [TransactionLedgerRead(transaction=transaction, account_split=split, balance=balance) for transaction, split, balance in items],
|
||||
)
|
||||
10
api/app/ledger/schema.py
Normal file
10
api/app/ledger/schema.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from transaction.models import TransactionRead, SplitRead
|
||||
|
||||
class TransactionLedgerRead(BaseModel):
|
||||
transaction: TransactionRead = Field()
|
||||
account_split: SplitRead = Field()
|
||||
balance: decimal.Decimal = Field()
|
||||
@@ -5,15 +5,14 @@ from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi_pagination import add_pagination
|
||||
|
||||
|
||||
from db import create_db_and_tables
|
||||
from user import user_router, auth_router, create_admin_user
|
||||
from account.account_routes import router as account_router
|
||||
from account.category_routes import router as category_router
|
||||
from ledger.routes import router as ledger_router
|
||||
from payee.routes import router as payee_router
|
||||
from transaction.routes import router as transaction_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
create_db_and_tables()
|
||||
@@ -41,6 +40,7 @@ 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(ledger_router, prefix="/ledgers", tags=["ledgers"])
|
||||
app.include_router(payee_router, prefix="/payees", tags=["payees"])
|
||||
app.include_router(transaction_router, prefix="/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
94
api/app/payee/fixtures.py
Normal file
94
api/app/payee/fixtures.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from account.resource import AccountResource
|
||||
from account.schemas import AccountCreate, CategoryCreate
|
||||
from payee.models import PayeeCreate, Payee
|
||||
|
||||
|
||||
def inject_fixtures(session):
|
||||
for f in fixtures_payee:
|
||||
# f = prepare_dict(session, f)
|
||||
schema = PayeeCreate(**f)
|
||||
Payee.create(schema, session)
|
||||
|
||||
def prepare_dict(session, entry):
|
||||
if entry['parent_path']:
|
||||
parent = AccountResource.get_by_path(session, entry['parent_path'])
|
||||
entry['parent_account_id'] = parent.id
|
||||
else:
|
||||
entry['parent_account_id'] = None
|
||||
del entry['parent_path']
|
||||
return entry
|
||||
|
||||
fixtures_payee = [
|
||||
{
|
||||
"name": "PayeeEmployer",
|
||||
},
|
||||
{
|
||||
"name": "PayeeSocialSecurity",
|
||||
},
|
||||
{
|
||||
"name": "PayeeAssurance1",
|
||||
},
|
||||
{
|
||||
"name": "PayeeAssurance2",
|
||||
},
|
||||
{
|
||||
"name": "PayeeSupermarket1",
|
||||
},
|
||||
{
|
||||
"name": "PayeeSupermarket2",
|
||||
},
|
||||
{
|
||||
"name": "PayeeTaxes",
|
||||
},
|
||||
{
|
||||
"name": "PayeeRent",
|
||||
},
|
||||
]
|
||||
|
||||
fixtures_account = [
|
||||
{
|
||||
"name": "Current Assets",
|
||||
"parent_path": None,
|
||||
"type": "Asset",
|
||||
"opening_date": date(1970, 1, 2),
|
||||
"opening_balance": Decimal("0.00"),
|
||||
},
|
||||
{
|
||||
"name": "Cash in Wallet",
|
||||
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||
"type": "Asset",
|
||||
"opening_date": date(1970, 1, 3),
|
||||
"opening_balance": Decimal("0.00"),
|
||||
},
|
||||
{
|
||||
"name": "Checking Account",
|
||||
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||
"type": "Asset",
|
||||
"opening_date": date(1970, 1, 4),
|
||||
"opening_balance": Decimal("0.00"),
|
||||
},
|
||||
{
|
||||
"name": "Savings Account",
|
||||
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||
"type": "Asset",
|
||||
"opening_date": date(1970, 1, 5),
|
||||
"opening_balance": Decimal("0.00"),
|
||||
},
|
||||
{
|
||||
"name": "Debt Accounts",
|
||||
"parent_path": None,
|
||||
"type": "Liability",
|
||||
"opening_date": date(1970, 1, 6),
|
||||
"opening_balance": Decimal("0.00"),
|
||||
},
|
||||
{
|
||||
"name": "Credit Card",
|
||||
"parent_path": "/Accounts/Liability/Debt Accounts/",
|
||||
"type": "Liability",
|
||||
"opening_date": date(1970, 1, 7),
|
||||
"opening_balance": Decimal("0.00"),
|
||||
},
|
||||
]
|
||||
36
api/app/transaction/fixtures.py
Normal file
36
api/app/transaction/fixtures.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
|
||||
from account.resource import AccountResource
|
||||
from transaction.models import TransactionCreate, Split
|
||||
from transaction.resource import TransactionResource
|
||||
|
||||
|
||||
def inject_fixtures(session):
|
||||
for f in fixtures_transaction:
|
||||
for i in range(f["count"]):
|
||||
data = prepare_dict(session, f, i)
|
||||
schema = TransactionCreate(**data)
|
||||
schema.splits.append(Split(**data))
|
||||
TransactionResource.create(session, schema)
|
||||
|
||||
def prepare_dict(session, entry, iteration):
|
||||
account = AccountResource.get_by_path(session, entry['account_path'])
|
||||
result = entry.copy()
|
||||
|
||||
result["account_id"] = account.id
|
||||
result["payee_id"] = None
|
||||
result["splits"] = []
|
||||
result["transaction_date"] = entry["start_date"] + timedelta(days=(iteration / 2))
|
||||
result["amount"] = [10, 100, 1000][random.Random().randint(0,2)] * [1, -1][random.Random().randint(0,1)]
|
||||
result["id"] = 0
|
||||
|
||||
return result
|
||||
|
||||
fixtures_transaction = [
|
||||
{
|
||||
"account_path": "/Accounts/Asset/Current Assets/Checking Account/",
|
||||
"start_date": date(1970, 1, 5),
|
||||
"count": 200
|
||||
},
|
||||
]
|
||||
@@ -3,7 +3,7 @@ from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlmodel import Field, SQLModel, select, Relationship
|
||||
from sqlmodel import Field, SQLModel, Relationship
|
||||
from pydantic import Field as PydField
|
||||
|
||||
from account.models import Account
|
||||
@@ -20,36 +20,7 @@ class TransactionBaseId(TransactionBase):
|
||||
|
||||
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(cls.model_validate(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()
|
||||
sequence: Optional[int] = Field(default=None)
|
||||
|
||||
class SplitBase(SQLModel):
|
||||
account_id: UUID = Field(foreign_key="account.id")
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import select
|
||||
from sqlmodel import select, func
|
||||
|
||||
from account.models import Account
|
||||
from account.schemas import OpeningTransaction
|
||||
from transaction.models import Transaction, Split
|
||||
|
||||
class TransactionResource:
|
||||
@classmethod
|
||||
def create(cls, transaction, session):
|
||||
def get_sequence_number(cls, session):
|
||||
stmt = select(func.max(Transaction.sequence)).select_from(Transaction)
|
||||
|
||||
sequence = session.execute(stmt).first()
|
||||
|
||||
return sequence[0] + 1 if sequence[0] else 1
|
||||
|
||||
@classmethod
|
||||
def create(cls, session, transaction):
|
||||
transaction_db = Transaction.model_validate(transaction)
|
||||
transaction_db.sequence = cls.get_sequence_number(session)
|
||||
session.add(transaction_db)
|
||||
session.commit()
|
||||
session.refresh(transaction_db)
|
||||
|
||||
@@ -4,43 +4,44 @@ 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 transaction.models import TransactionCreate, TransactionRead, TransactionUpdate
|
||||
from transaction.resource import TransactionResource
|
||||
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)
|
||||
result = TransactionResource.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())
|
||||
return paginate(session, TransactionResource.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)
|
||||
transaction = TransactionResource.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)
|
||||
db_transaction = TransactionResource.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)
|
||||
transaction = TransactionResource.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)
|
||||
transaction = TransactionResource.get(session, transaction_id)
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
|
||||
Transaction.delete(session, transaction)
|
||||
TransactionResource.delete(session, transaction)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AccountList, AccountCreate, AccountEdit } from "./pages/accounts";
|
||||
import { CategoryList, CategoryCreate, CategoryEdit } from "./pages/categories";
|
||||
import { PayeeCreate, PayeeEdit, PayeeList } from "./pages/payees";
|
||||
import { TransactionCreate, TransactionEdit, TransactionList } from "./pages/transactions";
|
||||
import {AccountLedger} from "./pages/accounts/ledger";
|
||||
|
||||
/**
|
||||
* mock auth credentials to simulate authentication
|
||||
@@ -135,6 +136,7 @@ const App: React.FC = () => {
|
||||
<Route index element={<AccountList />} />
|
||||
<Route path="create" element={<AccountCreate />} />
|
||||
<Route path="edit/:id" element={<AccountEdit />} />
|
||||
<Route path="ledger/:id" element={<AccountLedger />} />
|
||||
</Route>
|
||||
<Route path="/categories">
|
||||
<Route index element={<CategoryList />} />
|
||||
|
||||
69
gui/app/src/pages/accounts/ledger.tsx
Normal file
69
gui/app/src/pages/accounts/ledger.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Paper from '@mui/material/Paper';
|
||||
|
||||
|
||||
import { useParams } from "react-router";
|
||||
import {useList, useOne} from "@refinedev/core";
|
||||
|
||||
|
||||
|
||||
export const AccountLedger: React.FC = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { data, isLoading, isError } = useList({
|
||||
resource: `ledgers/${id}`,
|
||||
});
|
||||
if (isLoading) {
|
||||
return <div>Loading</div>
|
||||
}
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell></TableCell>
|
||||
<TableCell align="right">Date</TableCell>
|
||||
<TableCell align="right">Deposit</TableCell>
|
||||
<TableCell align="right">Payment</TableCell>
|
||||
<TableCell align="right">Balance</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data?.data.map((row: any) => {
|
||||
const is_expense: boolean = row.account_split.amount < 0
|
||||
const destination_accounts = []
|
||||
for (const split of row.transaction.splits) {
|
||||
if ((is_expense && split.amount >= 0) || (!is_expense && split.amount < 0)) {
|
||||
destination_accounts.push(split)
|
||||
}
|
||||
}
|
||||
let is_split = false;
|
||||
if (destination_accounts) {
|
||||
if (destination_accounts.length > 1) {
|
||||
is_split = true;
|
||||
}
|
||||
}
|
||||
return(
|
||||
<TableRow
|
||||
key={row.name}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell component="th" scope="row"></TableCell>
|
||||
<TableCell align="right">{row.transaction.transaction_date}</TableCell>
|
||||
<TableCell align="right">{ is_expense ? "" : row.account_split.amount }</TableCell>
|
||||
<TableCell align="right">{ is_expense ? row.account_split.amount : "" }</TableCell>
|
||||
<TableCell align="right">{ row.balance }</TableCell>
|
||||
</TableRow>)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import { ButtonGroup } from "@mui/material";
|
||||
import { Button, ButtonGroup } from "@mui/material";
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||
|
||||
import { DeleteButton, EditButton, List, useDataGrid} from "@refinedev/mui";
|
||||
|
||||
@@ -15,7 +16,7 @@ export const AccountList: React.FC = () => {
|
||||
{ field: "id", headerName: "ID" },
|
||||
{ field: "name", headerName: "Name", flex: 1 },
|
||||
{ field: "path", headerName: "path", flex: 1 },
|
||||
{ field: "type", headerName: "Type", flex: 0.3 },
|
||||
{ field: "type", headerName: "Type", flex: 1 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Actions",
|
||||
@@ -23,6 +24,7 @@ export const AccountList: React.FC = () => {
|
||||
renderCell: function render({ row }) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button href={`/accounts/ledger/${row.id}`}><RequestQuoteIcon /></Button>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText recordItemId={row.id} />
|
||||
</ButtonGroup>
|
||||
@@ -30,6 +32,7 @@ export const AccountList: React.FC = () => {
|
||||
},
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
[],
|
||||
|
||||
@@ -67,11 +67,7 @@ export const authProvider: AuthProvider = {
|
||||
onError: async (error) => {
|
||||
if (error?.status === 401) {
|
||||
localStorage.removeItem("access_token");
|
||||
return Promise<{
|
||||
redirectTo: "/login",
|
||||
logout: true,
|
||||
error: { message: "Unauthorized" },
|
||||
}>;
|
||||
return {redirectTo: "/login"}
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user