Compare commits

..

28 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
539410c18b Moving requests to resource 2025-02-12 20:11:59 +01:00
171875f915 Implemented fixtures & Implementing Resource pattern 2025-02-11 22:57:11 +01:00
ed6be838fe Adding fixture system 2025-02-10 22:25:54 +01:00
39c4ab9102 Adding opening dates and amount 2025-02-04 23:23:41 +01:00
f26bd9846a Separating Enums, Schemas and Models in account 2025-02-04 22:59:59 +01:00
a4be703713 Adding Equity Account Family 2025-02-03 22:10:23 +01:00
a33f84c5b4 Adding sorting to Accounts 2025-02-02 23:40:19 +01:00
33 changed files with 1066 additions and 336 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

@@ -1,54 +1,91 @@
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends
from fastapi_filter import FilterDepends
from fastapi_filter.contrib.sqlalchemy import Filter
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate
from account.models import Account, AccountCreate, AccountRead, AccountUpdate
from account.schemas import AccountCreate, AccountRead, AccountUpdate, OpeningTransaction, OpeningTransactionUpdate
from account.models import Account
from account.resource import AccountResource
from db import SessionDep
from user.manager import get_current_user
class AccountFilters(Filter):
name__like: Optional[str] = None
order_by: Optional[list[str]] = None
class Constants(Filter.Constants):
model = Account
search_model_fields = ["name"]
router = APIRouter()
@router.post("")
def create_account(account: AccountCreate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
result = Account.create(account, session)
result = AccountResource.create(account, session)
return result
@router.get("")
def read_accounts(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, Account.list_accounts())
def read_accounts(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, AccountResource.list_accounts(filters))
@router.get("")
def read_assets(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, Account.list_assets())
@router.get("/assets")
def read_assets(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, AccountResource.list_assets(filters))
@router.get("")
def read_liabilities(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, Account.list_liabilities())
@router.get("/liabilities")
def read_liabilities(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, AccountResource.list_liabilities(filters))
@router.get("/{account_id}")
def read_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
account = Account.get(session, account_id)
account = AccountResource.get(session, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return account
@router.put("/{account_id}")
def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
db_account = Account.get(session, account_id)
db_account = AccountResource.get(session, account_id)
if not db_account:
raise HTTPException(status_code=404, detail="Account not found")
account_data = account.model_dump(exclude_unset=True)
account = Account.update(session, db_account, account_data)
account = AccountResource.update(session, db_account, account_data)
return account
@router.delete("/{account_id}")
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
account = Account.get(session, account_id)
@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")
Account.delete(session, account)
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)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
AccountResource.delete(session, account)
return {"ok": True}

View File

@@ -1,54 +1,65 @@
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 account.models import Account, AccountRead, CategoryCreate, CategoryUpdate
from account.account_routes import AccountFilters
from account.schemas import CategoryRead, CategoryCreate, CategoryUpdate
from account.models import Account
from account.resource import AccountResource
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)) -> AccountRead:
result = Account.create(category, session)
def create_category(category: CategoryCreate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
result = AccountResource.create(category, session)
return result
@router.get("")
def read_categories(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, Account.list_categories())
def read_categories(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[CategoryRead]:
return paginate(session, AccountResource.list_categories(filters))
@router.get("expenses")
def read_expenses(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, Account.list_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")
def read_incomes(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, Account.list_incomes())
@router.get("/incomes")
def read_incomes(session: SessionDep,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[CategoryRead]:
return paginate(session, AccountResource.list_incomes(filters))
@router.get("/{category_id}")
def read_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
category = Account.get(session, category_id)
def read_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
category = AccountResource.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)) -> AccountRead:
db_category = Account.get(session, category_id)
def update_category(category_id: UUID, category: CategoryUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
db_category = AccountResource.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 = Account.update(session, db_category, category_data)
category = AccountResource.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 = Account.get(session, category_id)
category = AccountResource.get(session, category_id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
Account.delete(session, category)
AccountResource.delete(session, category)
return {"ok": True}

43
api/app/account/enums.py Normal file
View File

@@ -0,0 +1,43 @@
from enum import Enum
class AccountType(Enum):
Asset = "Asset" # < Denotes a generic asset account.
Checkings = "Checkings" # < Standard checking account
Savings = "Savings" # < Typical savings account
Cash = "Cash" # < Denotes a shoe-box or pillowcase stuffed with cash
Liability = "Liability" # < Denotes a generic liability account.
CreditCard = "CreditCard" # < Credit card accounts
Loan = "Loan" # < Loan and mortgage accounts (liability)
CertificateDep = "CertificateDep" # < Certificates of Deposit
Investment = "Investment" # < Investment account
MoneyMarket = "MoneyMarket" # < Money Market Account
Currency = "Currency" # < Denotes a currency trading 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
Income = "Income" # < Denotes an income account
Expense = "Expense" # < Denotes an expense account
Equity = "Equity" # < Denotes an equity account e.g. opening/closing balance
class AccountFamily(Enum):
Asset = "Asset"
Liability = "Liability"
class CategoryFamily(Enum):
Income = "Income"
Expense = "Expense"
class Asset(Enum):
Asset = "Asset"
Checking = "Checking"
Savings = "Savings"
Cash = "Cash"
Investment = "Investment"
class Liability(Enum):
Liability = "Liability"
CreditCard = "CreditCard"
Loan = "Loan"

195
api/app/account/fixtures.py Normal file
View File

@@ -0,0 +1,195 @@
from datetime import date
from decimal import Decimal
from account.resource import AccountResource
from account.schemas import AccountCreate, CategoryCreate
def inject_fixtures(session):
for f in fixtures_account:
f = prepare_dict(session, f)
schema = AccountCreate(**f)
AccountResource.create(schema, session)
for f in fixtures_category:
f = prepare_dict(session, f)
schema = CategoryCreate(**f)
AccountResource.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_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"),
},
]
fixtures_category = [
{
"name": "Salary",
"parent_path": None,
"type": "Income",
},
{
"name": "Other Income",
"parent_path": None,
"type": "Income",
},
{
"name": "Auto",
"parent_path": None,
"type": "Expense",
},
{
"name": "Home",
"parent_path": None,
"type": "Expense",
},
{
"name": "Rent",
"parent_path": "/Categories/Expense/Home/",
"type": "Expense",
},
{
"name": "Electricity",
"parent_path": "/Categories/Expense/Home/",
"type": "Expense",
},
{
"name": "Entertainment",
"parent_path": None,
"type": "Expense",
},
{
"name": "Groceries",
"parent_path": None,
"type": "Expense",
}
]
"""
<accounts>
<account type="9" name="">
<account type="9" name="Current Assets">
<account type="3" name="Cash in Wallet"/>
<account type="1" name="Checking Account"/>
<account type="1" name="Savings Account"/>
</account>
</account>
<account type="16" name="">
<account type="16" name="Opening Balances">
<flag name="OpeningBalanceAccount" value="Yes"/>
</account>
</account>
<account type="13" name="">
<account type="13" name="Adjustment"/>
<account type="13" name="Auto">
<account type="13" name="Fees"/>
<account type="13" name="Fuel"/>
<account type="13" name="Parking"/>
<account type="13" name="Repair and Maintenance"/>
</account>
<account type="13" name="Bank Service Charge"/>
<account type="13" name="Books"/>
<account type="13" name="Cable"/>
<account type="13" name="Charity"/>
<account type="13" name="Clothes"/>
<account type="13" name="Computer"/>
<account type="13" name="Dining"/>
<account type="13" name="Education"/>
<account type="13" name="Entertainment">
<account type="13" name="Music/Movies"/>
<account type="13" name="Recreation"/>
<account type="13" name="Travel"/>
</account>
<account type="13" name="Gifts"/>
<account type="13" name="Groceries"/>
<account type="13" name="Hobbies"/>
<account type="13" name="Insurance">
<account type="13" name="Auto Insurance"/>
<account type="13" name="Health Insurance"/>
<account type="13" name="Life Insurance"/>
</account>
<account type="13" name="Laundry/Dry Cleaning"/>
<account type="13" name="Medical Expenses"/>
<account type="13" name="Miscellaneous"/>
<account type="13" name="Online Services"/>
<account type="13" name="Phone"/>
<account type="13" name="Public Transportation"/>
<account type="13" name="Subscriptions"/>
<account type="13" name="Supplies"/>
<account type="13" name="Taxes">
<account type="13" name="Federal"/>
<account type="13" name="Medicare"/>
<account type="13" name="Other Tax"/>
<account type="13" name="Social Security"/>
<account type="13" name="State/Province"/>
</account>
<account type="13" name="Utilities">
<account type="13" name="Electric"/>
<account type="13" name="Garbage collection"/>
<account type="13" name="Gas"/>
<account type="13" name="Water"/>
</account>
</account>
<account type="12" name="">
<account type="12" name="Bonus"/>
<account type="12" name="Gifts Received"/>
<account type="12" name="Interest Income">
<account type="12" name="Checking Interest"/>
<account type="12" name="Other Interest"/>
<account type="12" name="Savings Interest"/>
</account>
<account type="12" name="Other Income"/>
<account type="12" name="Salary"/>
</account>
<account type="10" name="">
<account type="4" name="Credit Card"/>
</account>
</accounts>
"""

View File

@@ -1,23 +1,9 @@
from typing import Optional
from uuid import UUID, uuid4
from enum import Enum
from fastapi_filter.contrib.sqlalchemy import Filter
from pydantic.json_schema import SkipJsonSchema
from sqlmodel import Field, SQLModel, select, Relationship
from pydantic import Field as PydField
from sqlalchemy.sql import text
from sqlmodel import Relationship
class AccountBase(SQLModel):
name: str = Field(index=True)
parent_account_id: Optional[UUID] = Field(default=None, foreign_key="account.id")
class AccountBaseId(AccountBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
family: str = Field(index=True)
type: str = Field(index=True)
path: str = Field(index=True, unique=True)
from account.enums import CategoryFamily, Asset, Liability, AccountFamily
from account.schemas import AccountBaseId
class Account(AccountBaseId, table=True):
parent_account: Optional["Account"] = Relationship(
@@ -25,211 +11,30 @@ class Account(AccountBaseId, table=True):
sa_relationship_kwargs=dict(remote_side='Account.id')
)
children_accounts: list["Account"] = Relationship(back_populates='parent_account')
transaction_splits: list["Split"] = Relationship(back_populates='account')
def get_child_path(self):
return f"{self.path}{self.name}/"
def is_category(self):
return self.family in [v.value for v in CategoryFamily]
def get_child_path(self, child):
return f"{self.path}{child.name}/"
def get_root_path(self):
root = "/Categories" if self.is_category() else "/Accounts"
return f"{root}/{self.family}/"
return f"{root}/{self.family}/{self.name}/"
def update_children_path(self, session, old_path):
request = f"UPDATE {self.__tablename__} SET path=REPLACE(path, '{old_path}', '{self.path}') WHERE path LIKE '{old_path}{self.name }/%'"
session.exec(text(request))
def compute_path(self):
if self.parent_account is None:
self.path = self.get_root_path()
else:
self.path = self.parent_account.get_child_path(self)
return self.path
def is_category(self):
return self.type in [v.value for v in CategoryType]
def get_parent(self, session):
if self.parent_account_id is None:
return None
self.parent_account = self.get(session, self.parent_account_id)
return self.parent_account
def get_path(self, session):
if self.parent_account_id is None:
return self.get_root_path()
self.parent_account = self.get(session, self.parent_account_id)
return self.parent_account.get_child_path()
def get_family(self):
def compute_family(self):
if self.type in Asset:
return "Asset"
if self.type in Liability:
return "Liability"
return self.type
@classmethod
def schema_to_model(cls, session, schema, model=None):
try:
if model:
model = cls.model_validate(model, update=schema)
else:
schema.path = ""
schema.family = ""
model = cls.model_validate(schema)
except Exception as e:
print(e)
model.family = model.get_family()
cls.validate_parent(session, model)
model.path = model.get_path(session)
return model
@classmethod
def validate_parent(cls, session, model):
if model.parent_account_id is None:
return True
parent = model.get_parent(session)
if not parent:
raise ValueError("Parent account not found.")
if parent.family != model.family:
raise ValueError("Account family mismatch with parent account..")
if parent.path.startswith(model.path):
raise ValueError("Parent Account is descendant")
return True
@classmethod
def create(cls, account, session):
account_db = cls.schema_to_model(session, account)
session.add(account_db)
session.commit()
session.refresh(account_db)
return account_db
@classmethod
def list(cls):
return select(Account)
@classmethod
def list_accounts(cls):
return cls.list().where(
Account.type.not_in([v.value for v in CategoryType])
)
@classmethod
def list_assets(cls):
return cls.list().where(Account.family == "Asset")
@classmethod
def list_liabilities(cls):
return cls.list().where(Account.family == "Liability")
@classmethod
def list_categories(cls):
return cls.list().where(
Account.type.in_([v.value for v in CategoryType])
)
@classmethod
def list_expenses(cls):
return cls.list().where(Account.family == "Expense")
@classmethod
def list_incomes(cls):
return cls.list().where(Account.family == "Income")
@classmethod
def get(cls, session, account_id):
return session.get(Account, account_id)
@classmethod
def update(cls, session, account_db, account_data):
previous_path = account_db.path
account_db.sqlmodel_update(cls.schema_to_model(session, account_data, account_db))
if previous_path != account_db.path or account_data['name'] != account_db.name:
account_db.update_children_path(session, previous_path)
session.add(account_db)
session.commit()
session.refresh(account_db)
return account_db
@classmethod
def delete(cls, session, account):
session.delete(account)
session.commit()
class AccountRead(AccountBaseId):
pass
class AccountType(Enum):
Asset = "Asset" # < Denotes a generic asset account.
Checkings = "Checkings" # < Standard checking account
Savings = "Savings" # < Typical savings account
Cash = "Cash" # < Denotes a shoe-box or pillowcase stuffed with cash
Liability = "Liability" # < Denotes a generic liability account.
CreditCard = "CreditCard" # < Credit card accounts
Loan = "Loan" # < Loan and mortgage accounts (liability)
CertificateDep = "CertificateDep" # < Certificates of Deposit
Investment = "Investment" # < Investment account
MoneyMarket = "MoneyMarket" # < Money Market Account
Currency = "Currency" # < Denotes a currency trading 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
Income = "Income" # < Denotes an income account
Expense = "Expense" # < Denotes an expense account
class Asset(Enum):
Asset = "Asset"
Checkings = "Checkings"
Savings = "Savings"
Cash = "Cash"
Investment = "Investment"
class Liability(Enum):
Liability = "Liability"
CreditCard = "CreditCard"
Loan = "Loan"
class BaseAccountWrite(AccountBase):
path: SkipJsonSchema[str] = Field(default="")
family: SkipJsonSchema[str] = Field(default="")
class AccountWrite(BaseAccountWrite):
type: Asset | Liability = Field()
parent_account_id: UUID | None = PydField(default=None, json_schema_extra={
"foreign_key": {
"reference": {
"resource": "accounts",
"schema": "AccountRead",
"label": "name"
}
}
})
class AccountCreate(AccountWrite):
pass
class AccountUpdate(AccountWrite):
pass
class CategoryType(Enum):
Income = "Income"
Expense = "Expense"
class CategoryWrite(BaseAccountWrite):
type: CategoryType = Field()
class CategoryCreate(CategoryWrite):
pass
class CategoryUpdate(CategoryWrite):
pass
class AccountFilter(Filter):
name__like: Optional[str] = None
class Constants(Filter.Constants):
model = Account
search_model_fields = ["name"]
self.family = AccountFamily.Asset.value
elif self.type in Liability:
self.family = AccountFamily.Liability.value
else:
self.family = self.type
return self.family

212
api/app/account/resource.py Normal file
View File

@@ -0,0 +1,212 @@
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
def get_by_path(cls, session, path):
if not path:
return None
return session.exec(select(Account).where(Account.path == path)).first()
@classmethod
def get_parent(cls, session, model):
if model.parent_account_id is None:
return None
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:
if model:
model = Account.model_validate(model, update=schema)
else:
schema.path = ""
schema.family = ""
model = Account.model_validate(schema)
cls.create_opening_transaction(session, model, schema)
except Exception as e:
print(e)
raise
model.compute_family()
cls.validate_parent(session, model)
model.compute_path()
return model
@classmethod
def validate_parent(cls, session, model):
if model.parent_account_id is None:
return True
parent = cls.get_parent(session, model)
if not parent:
raise KeyError("Parent account not found.")
if parent.family != model.family:
raise ValueError("Account family mismatch with parent account..")
if model.path and parent.path.startswith(model.path):
raise ValueError("Parent Account is descendant")
model.parent_account = parent
return model
@classmethod
def create(cls, account, session):
account_db = cls.schema_to_model(session, account)
session.add(account_db)
session.flush()
session.refresh(account_db)
session.commit()
session.refresh(account_db)
return account_db
@classmethod
def create_equity_account(cls, session):
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):
return select(Account)
@classmethod
def list(cls, filters):
return filters.sort(filters.filter(
cls.select()
))
@classmethod
def list_accounts(cls, filters):
return cls.list(filters).where(
Account.family.in_(["Asset", "Liability"])
)
@classmethod
def list_assets(cls, filters):
return cls.list(filters).where(Account.family == "Asset")
@classmethod
def list_liabilities(cls, filters):
return cls.list(filters).where(Account.family == "Liability")
@classmethod
def list_categories(cls, filters):
return cls.list(filters).where(
Account.type.in_(["Expense", "Income"])
)
@classmethod
def list_expenses(cls, filters):
return cls.list(filters).where(Account.family == "Expense")
@classmethod
def list_incomes(cls, filters):
return cls.list(filters).where(Account.family == "Income")
@classmethod
def get(cls, session, account_id):
return session.exec(cls.select().where(Account.id == account_id)).first()
@classmethod
def update(cls, session, account_db, account_data):
previous_path = account_db.path
account_db.sqlmodel_update(cls.schema_to_model(session, account_data, account_db))
if previous_path != account_db.path or account_data['name'] != account_db.name:
account_db.update_children_path(session, previous_path)
session.add(account_db)
session.commit()
session.refresh(account_db)
return account_db
@classmethod
def delete(cls, session, account):
session.delete(account)
session.commit()

View File

@@ -0,0 +1,76 @@
from datetime import date
from decimal import Decimal
from typing import Optional
from uuid import UUID, uuid4
from sqlmodel import Field, SQLModel
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)
parent_account_id: Optional[UUID] = Field(default=None, foreign_key="account.id")
class AccountBaseId(AccountBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
family: str = Field(index=True)
type: str = Field(index=True)
path: str = Field(index=True)
class AccountRead(AccountBaseId):
pass
class BaseAccountWrite(AccountBase):
path: SkipJsonSchema[str] = Field(default="")
family: SkipJsonSchema[str] = Field(default="")
class AccountWrite(BaseAccountWrite):
type: Asset | Liability = Field()
parent_account_id: UUID | None = PydField(default=None, json_schema_extra={
"foreign_key": {
"reference": {
"resource": "accounts",
"schema": "AccountRead",
"label": "name"
}
}
})
class AccountCreate(AccountWrite):
opening_date: date = Field()
opening_balance: MonetaryAmount = Field()
class AccountUpdate(AccountWrite):
pass
class CategoryRead(AccountBaseId):
pass
class CategoryWrite(BaseAccountWrite):
type: CategoryFamily = Field()
parent_account_id: UUID | None = PydField(default=None, json_schema_extra={
"foreign_key": {
"reference": {
"resource": "categories",
"schema": "CategoryRead",
"label": "name"
}
}
})
class CategoryCreate(CategoryWrite):
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

@@ -14,6 +14,9 @@ engine = create_engine(sqlite_url, connect_args=connect_args)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def drop_tables():
SQLModel.metadata.drop_all(engine)
def get_session() -> Session:
with Session(engine) as session:
yield session

18
api/app/initialize_db.py Normal file
View File

@@ -0,0 +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_user
drop_tables()
create_db_and_tables()
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)

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

@@ -5,7 +5,8 @@ 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
from account.models import Account
from account.schemas import AccountRead
class PayeeBase(SQLModel):

View File

@@ -17,7 +17,7 @@ def create_payee(payee: PayeeCreate, session: SessionDep, current_user=Depends(g
return Payee.get(session, result.id)
@router.get("")
def read_categories(session: SessionDep,
def read_payees(session: SessionDep,
filters: PayeeFilters = FilterDepends(PayeeFilters),
current_user=Depends(get_current_user)) -> Page[PayeeRead]:
return paginate(session, Payee.list(filters))

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,55 +1,30 @@
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, AccountRead
from account.models import Account
from account.schemas import AccountRead
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):
@@ -58,7 +33,7 @@ class SplitBaseId(SplitBase):
class SplitRead(SplitBaseId):
account: AccountRead
payee: PayeeRead
payee: PayeeRead | None
class TransactionRead(TransactionBaseId):
splits: list[SplitRead]
@@ -73,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",
@@ -95,7 +70,7 @@ class TransactionUpdate(TransactionWrite):
class Split(SplitBaseId, table=True):
transaction: Transaction = Relationship(back_populates="splits")
account: Account | None = Relationship()
account: Account | None = Relationship(back_populates="transaction_splits")
payee: Payee | None = Relationship()
@classmethod

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

@@ -2,11 +2,11 @@ import uuid
from sqlmodel import select
from fastapi import Depends
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models, exceptions, schemas
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import BearerTransport, AuthenticationBackend
from fastapi_users.authentication.strategy.db import AccessTokenDatabase, DatabaseStrategy
from user.models import User, get_user_db, AccessToken, get_access_token_db, UserRead, UserUpdate, UserCreate
from user.models import User, get_user_db, AccessToken, get_access_token_db
from db import get_session
SECRET = "SECRET"
@@ -17,7 +17,6 @@ bearer_transport = BearerTransport(tokenUrl="auth/login")
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
pass
async def get_user_manager(user_db=Depends(get_user_db)) -> UserManager:
yield UserManager(user_db)
@@ -37,20 +36,14 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](
[auth_backend],
)
get_current_user = fastapi_users.current_user(active=True)
get_current_superuser = fastapi_users.current_user(active=True, superuser=True)
#user_router = fastapi_users.get_users_router(UserRead, UserUpdate)
#user_router.include_router(fastapi_users.get_reset_password_router())
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,12 +1,12 @@
import { useMany } from "@refinedev/core";
import {DeleteButton, EditButton, List, useDataGrid} from "@refinedev/mui";
import React from "react";
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";
import type { IAccount } from "../../interfaces";
import {AccountCreate} from "./create";
import {ButtonGroup} from "@mui/material";
export const AccountList: React.FC = () => {
const { dataGridProps } = useDataGrid<IAccount>();
@@ -16,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",
@@ -24,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>
@@ -31,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();
@@ -48,8 +47,7 @@ export const dataProvider: DataProvider = {
}
if (sorters && sorters.length > 0) {
params.append("sort", sorters.map((sorter) => sorter.field).join(","));
params.append("order", sorters.map((sorter) => sorter.order).join(","));
params.append("order_by", sorters.map((sorter) => (sorter.order == "asc" ? "+" : "-") + sorter.field).join(","));
}
if (filters && filters.length > 0) {