Compare commits

...

21 Commits

Author SHA1 Message Date
ed37298a74 Adapting transaction to the resource archi 2025-02-21 16:08:59 +01:00
950090c762 Authentication finaly redirect to auth page 2025-02-20 22:15:28 +01:00
3268f065d3 Adding filters et pagination to ledger 2025-02-18 23:19:07 +01:00
c8eb7cd9bf Adding first draft of ledger component 2025-02-17 21:00:19 +01:00
a81c4fbd7d Adding balance to ledger record 2025-02-17 19:07:04 +01:00
cd325154da Adding sequence number for transactions and fixtures as well 2025-02-17 17:36:38 +01:00
4a823b7115 Moving ledger route conversion to resource 2025-02-16 23:18:11 +01:00
a83711315f Cleaning transaction resource file 2025-02-16 22:31:59 +01:00
699009d21a Adding ledger module 2025-02-16 22:31:35 +01:00
440401049a Adding fixtures for payees 2025-02-16 16:54:52 +01:00
148b1d00c4 Fully functional opening transactions 2025-02-16 03:17:29 +01:00
b5039f6468 Adding read action for opening transaction 2025-02-14 17:27:17 +01:00
65ecfcd919 Removing opening info for read and update, puting in another resource 2025-02-14 16:56:32 +01:00
afe89cfb03 Correcting typo in category list routes 2025-02-14 16:25:56 +01:00
ff9b59f38c Migrating resource.get to new select join 2025-02-14 15:18:19 +01:00
aff702156d Adding fetching of account opening data for AccountRead 2025-02-14 14:15:20 +01:00
171ad9ea4e Upgrading IDE to python3.13 2025-02-12 23:38:53 +01:00
f4bf1a2b30 Adding auto creation of opening transaction on create 2025-02-12 23:13:26 +01:00
825fa41bd9 Updating admin_user_create name 2025-02-12 23:11:10 +01:00
2fa5e04dca Removing opening info from category 2025-02-12 21:35:39 +01:00
b6bef1f775 Adding a common moneratary amount field type 2025-02-12 20:35:13 +01:00
29 changed files with 543 additions and 96 deletions

View File

@@ -6,7 +6,7 @@
<excludeFolder url="file://$MODULE_DIR$/api/.venv" />
<excludeFolder url="file://$MODULE_DIR$/api/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (budget-forecast)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.13 (budget-forecast)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.12 (budget_forecast)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (budget-forecast)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (budget-forecast)" project-jdk-type="Python SDK" />
</project>

View File

@@ -7,7 +7,7 @@ from fastapi_filter.contrib.sqlalchemy import Filter
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate
from account.schemas import AccountCreate, AccountRead, AccountUpdate
from account.schemas import AccountCreate, AccountRead, AccountUpdate, OpeningTransaction, OpeningTransactionUpdate
from account.models import Account
from account.resource import AccountResource
@@ -65,6 +65,22 @@ def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep
account = AccountResource.update(session, db_account, account_data)
return account
@router.get("/{account_id}/opening_state")
def read_account_opening_state(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> OpeningTransaction:
transaction = AccountResource.get_opening_transaction(session, account_id)
if not transaction:
raise HTTPException(status_code=404, detail="Account not found")
return transaction
@router.put("/{account_id}/opening_state")
def update_account_opening_state(account_id: UUID, opening_transaction: OpeningTransactionUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> OpeningTransaction:
account = AccountResource.get(session, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
transaction = AccountResource.update_opening_transaction(session, account, opening_transaction)
return transaction
@router.delete("/{account_id}")
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
account = AccountResource.get(session, account_id)

View File

@@ -26,13 +26,13 @@ def read_categories(session: SessionDep,
current_user=Depends(get_current_user)) -> Page[CategoryRead]:
return paginate(session, AccountResource.list_categories(filters))
@router.get("expenses")
@router.get("/expenses")
def read_expenses(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[CategoryRead]:
return paginate(session, AccountResource.list_expenses(filters))
@router.get("incomes")
@router.get("/incomes")
def read_incomes(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[CategoryRead]:

View File

@@ -1,4 +1,5 @@
from datetime import date
from decimal import Decimal
from account.resource import AccountResource
from account.schemas import AccountCreate, CategoryCreate
@@ -28,37 +29,43 @@ fixtures_account = [
"name": "Current Assets",
"parent_path": None,
"type": "Asset",
"opening_date": date(1970, 1, 1),
"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, 1),
"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, 1),
"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, 1),
"opening_date": date(1970, 1, 5),
"opening_balance": Decimal("0.00"),
},
{
"name": "Debt Accounts",
"parent_path": None,
"type": "Liability",
"opening_date": date(1970, 1, 1),
"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, 1),
"opening_date": date(1970, 1, 7),
"opening_balance": Decimal("0.00"),
},
]

View File

@@ -1,8 +1,6 @@
from typing import Optional, Any
from typing import Optional
from pydantic import computed_field
from sqlmodel import Relationship
from sqlalchemy.sql import text
from account.enums import CategoryFamily, Asset, Liability, AccountFamily
from account.schemas import AccountBaseId

View File

@@ -1,6 +1,14 @@
from datetime import date
from sqlalchemy import and_
from sqlalchemy.orm import aliased
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:
@classmethod
@@ -18,6 +26,76 @@ class AccountResource:
model.parent_account = cls.get(session, model.parent_account_id)
return model.parent_account
@classmethod
def create_opening_transaction(cls, session, account, schema):
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
split_opening.account = account
split_opening.amount = schema.opening_balance
split_equity = Split()
split_equity.id = 1
split_equity.transaction = t
split_equity.account = equity_account
split_equity.amount = - schema.opening_balance
account.transaction_splits.append(split_opening)
@classmethod
def fetch_opening_transaction(cls, session, account_id):
split_account = aliased(Split)
split_equity = aliased(Split)
account_equity = aliased(Account)
return session.execute(select(Transaction)
.join(split_account, and_(split_account.transaction_id == Transaction.id, split_account.account_id == account_id))
.join(split_equity, split_equity.transaction_id == Transaction.id)
.join(account_equity, and_(account_equity.id == split_equity.account_id, account_equity.path == "/Equity/"))
).first()[0]
@classmethod
def get_opening_transaction(cls, session, account_id):
transaction = cls.fetch_opening_transaction(session, account_id)
if transaction is None:
return None
return OpeningTransaction(
opening_date=transaction.transaction_date,
opening_balance=transaction.splits[0].amount
)
@classmethod
def update_opening_transaction(cls, session, account, schema):
opening_transaction = cls.fetch_opening_transaction(session, account.id)
stmt = select(Transaction).join(Split) \
.where(Transaction.id != opening_transaction.id) \
.where(Split.account_id == account.id) \
.order_by(Transaction.transaction_date.asc())
first_transaction = session.exec(stmt).first()
if first_transaction and schema.opening_date > first_transaction[0].transaction_date:
raise ValueError("Account opening date is posterior to its first transaction date")
opening_transaction = cls.fetch_opening_transaction(session, account.id)
opening_transaction.transaction_date = schema.opening_date
opening_transaction.splits[0].amount = schema.opening_balance
opening_transaction.splits[1].amount = - schema.opening_balance
session.commit()
session.refresh(opening_transaction)
return OpeningTransaction(
opening_date=opening_transaction.transaction_date,
opening_balance=opening_transaction.splits[0].amount
)
@classmethod
def schema_to_model(cls, session, schema, model=None):
try:
@@ -27,6 +105,7 @@ class AccountResource:
schema.path = ""
schema.family = ""
model = Account.model_validate(schema)
cls.create_opening_transaction(session, model, schema)
except Exception as e:
print(e)
raise
@@ -34,6 +113,7 @@ class AccountResource:
model.compute_family()
cls.validate_parent(session, model)
model.compute_path()
return model
@classmethod
@@ -68,12 +148,10 @@ class AccountResource:
@classmethod
def create_equity_account(cls, session):
account_db = Account(name="Equity", family="Equity", type="Equity", path="/Equity/")
session.add(account_db)
session.commit()
session.refresh(account_db)
return account_db
if cls.get_by_path(session, "/Equity/") is None:
account_db = Account(name="Equity", family="Equity", type="Equity", path="/Equity/")
session.add(account_db)
session.commit()
@classmethod
def select(cls):
@@ -115,7 +193,7 @@ class AccountResource:
@classmethod
def get(cls, session, account_id):
return session.get(Account, account_id)
return session.exec(cls.select().where(Account.id == account_id)).first()
@classmethod
def update(cls, session, account_db, account_data):

View File

@@ -3,11 +3,12 @@ from decimal import Decimal
from typing import Optional
from uuid import UUID, uuid4
from pydantic.json_schema import SkipJsonSchema
from sqlmodel import Field, SQLModel
from pydantic import Field as PydField
from pydantic import Field as PydField, BaseModel
from pydantic.json_schema import SkipJsonSchema
from account.enums import Asset, Liability, CategoryFamily
from core.types import MonetaryAmount
class AccountBase(SQLModel):
name: str = Field(index=True)
@@ -20,8 +21,7 @@ class AccountBaseId(AccountBase):
path: str = Field(index=True)
class AccountRead(AccountBaseId):
pass#opening_date: date = Field()
#opening_balance: Decimal = Field(decimal_places=2, default=0)
pass
class BaseAccountWrite(AccountBase):
path: SkipJsonSchema[str] = Field(default="")
@@ -38,11 +38,10 @@ class AccountWrite(BaseAccountWrite):
}
}
})
opening_date: date = Field()
opening_balance: Decimal = Field(decimal_places=2, default=0)
class AccountCreate(AccountWrite):
pass
opening_date: date = Field()
opening_balance: MonetaryAmount = Field()
class AccountUpdate(AccountWrite):
pass
@@ -61,11 +60,17 @@ class CategoryWrite(BaseAccountWrite):
}
}
})
opening_date: date = date(1970, 1, 1)
opening_balance: Decimal = 0
class CategoryCreate(CategoryWrite):
pass
opening_date: SkipJsonSchema[date] = Field(default=date(1970, 1, 1))
opening_balance: SkipJsonSchema[Decimal] = Field(default=0)
class CategoryUpdate(CategoryWrite):
pass
class OpeningTransaction(BaseModel):
opening_date: date = Field()
opening_balance: MonetaryAmount = Field(default=0)
class OpeningTransactionUpdate(OpeningTransaction):
pass

0
api/app/core/__init__.py Normal file
View File

45
api/app/core/types.py Normal file
View File

@@ -0,0 +1,45 @@
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
from pydantic_core import core_schema
from pydantic.json_schema import JsonSchemaValue
@dataclass
class MonetaryAmount:
@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
) -> JsonSchemaValue:
json_schema = handler(schema)
if "anyOf" in json_schema:
for key, value in json_schema["anyOf"][0].items():
json_schema[key] = value
del json_schema["anyOf"]
json_schema["format"] = "monetary"
return json_schema
@classmethod
def __get_pydantic_core_schema__(
cls, source: type[Any], handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
assert source is MonetaryAmount
return core_schema.decimal_schema(multiple_of=0.01)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@staticmethod
def _validate(value: Decimal) -> 'MonetaryAmount':
if value.as_tuple()[2] < -2:
raise ValueError(f'{value} has more than two decimal places.')
return value
@staticmethod
def _serialize(value: 'MonetaryAmount') -> str:
return value.amount

View File

@@ -1,12 +1,18 @@
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_account
from user import create_admin_user
drop_tables()
create_db_and_tables()
session = get_session().__next__()
create_admin_account(session)
create_admin_user(session)
AccountResource.create_equity_account(session)
account_inject_fixtures(session)
payee_inject_fixtures(session)
transaction_inject_fixtures(session)

View File

View 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
View 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
View 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()

View File

@@ -5,19 +5,18 @@ 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_account
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()
create_admin_account()
create_admin_user()
yield
#do something before end
@@ -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
View 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"),
},
]

View 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
},
]

View File

@@ -1,7 +1,9 @@
from datetime import date
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
@@ -10,47 +12,19 @@ from payee.models import Payee, PayeeRead
class TransactionBase(SQLModel):
pass
transaction_date: date = Field()
payment_date: Optional[date] = Field(default=None)
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(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")
payee_id: UUID = Field(foreign_key="payee.id")
payee_id: Optional[UUID] = Field(foreign_key="payee.id")
amount: Decimal = Field(decimal_places=2)
class SplitBaseId(SplitBase):
@@ -59,7 +33,7 @@ class SplitBaseId(SplitBase):
class SplitRead(SplitBaseId):
account: AccountRead
payee: PayeeRead
payee: PayeeRead | None
class TransactionRead(TransactionBaseId):
splits: list[SplitRead]
@@ -74,7 +48,7 @@ class SplitWrite(SplitBase):
}
}
})
payee_id: UUID = PydField(json_schema_extra={
payee_id: UUID | None = PydField(json_schema_extra={
"foreign_key": {
"reference": {
"resource": "payees",

View File

@@ -0,0 +1,44 @@
from sqlmodel import select, func
from account.models import Account
from transaction.models import Transaction, Split
class TransactionResource:
@classmethod
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)
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.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()

View File

@@ -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}

View File

@@ -1,4 +1,4 @@
from .manager import auth_router, reset_password_router, create_admin_account
from .manager import auth_router, reset_password_router, create_admin_user
from .routes import router as user_router
user_router.include_router(reset_password_router)

View File

@@ -43,7 +43,7 @@ reset_password_router = fastapi_users.get_reset_password_router()
auth_router = fastapi_users.get_auth_router(auth_backend)
def create_admin_account(session=get_session().__next__()):
def create_admin_user(session=get_session().__next__()):
admin_email = 'root@root.fr'
statement = select(User).where(User.email == admin_email).limit(1)
admin_user = session.exec(statement).first()

View File

@@ -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 />} />

View File

@@ -6,10 +6,17 @@ export const AccountEdit: React.FC = () => {
const { id } = useParams()
return (
<CrudForm
schemaName={"AccountUpdate"}
resource={"accounts"}
id={id}
/>
<div>
<CrudForm
schemaName={"AccountUpdate"}
resource={"accounts"}
id={id}
/>
<CrudForm
schemaName={"OpeningTransactionUpdate"}
resource={`accounts/${id}/opening_state`}
id=""
/>
</div>
);
};

View 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>
);
}

View File

@@ -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,
},
],
[],

View File

@@ -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 {};
},

View File

@@ -14,8 +14,7 @@ const fetcher = async (url: string, options?: RequestInit) => {
export const dataProvider: DataProvider = {
getOne: async ({ resource, id, meta }) => {
const response = await fetcher(`${API_URL}/${resource}/${id}`);
const response = id !== "" ? await fetcher(`${API_URL}/${resource}/${id}`) : await fetcher(`${API_URL}/${resource}`);
if (response.status < 200 || response.status > 299) throw response;
const data = await response.json();