Compare commits

...

34 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
fb7e46efdb Adding Parent Account validation 2025-02-02 22:55:57 +01:00
778bdc2c74 Adding family concept and routes 2025-02-02 22:09:00 +01:00
0b150abae4 Adding empty value on foreign key 2025-02-02 22:08:38 +01:00
1e8731d78b Functional foreign key account selector and path generator 2025-02-02 20:12:55 +01:00
fd92c57eb5 [WIP] trying to validate empty parent account 2025-01-30 08:19:10 +01:00
c1a6c0f572 Left Outer Joining Payee 2025-01-27 00:45:51 +01:00
36 changed files with 1116 additions and 220 deletions

View File

@@ -6,7 +6,7 @@
<excludeFolder url="file://$MODULE_DIR$/api/.venv" /> <excludeFolder url="file://$MODULE_DIR$/api/.venv" />
<excludeFolder url="file://$MODULE_DIR$/api/venv" /> <excludeFolder url="file://$MODULE_DIR$/api/venv" />
</content> </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" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.12 (budget_forecast)" /> <option name="sdkName" value="Python 3.12 (budget_forecast)" />
</component> </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> </project>

View File

@@ -1,46 +1,91 @@
from typing import Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends 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 import Page
from fastapi_pagination.ext.sqlmodel import paginate 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 db import SessionDep
from user.manager import get_current_user 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 = APIRouter()
@router.post("") @router.post("")
def create_account(account: AccountCreate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead: 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 return result
@router.get("") @router.get("")
def read_accounts(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]: def read_accounts(session: SessionDep,
return paginate(session, Account.list_accounts()) filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[AccountRead]:
return paginate(session, AccountResource.list_accounts(filters))
@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("/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}") @router.get("/{account_id}")
def read_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead: 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: if not account:
raise HTTPException(status_code=404, detail="Account not found") raise HTTPException(status_code=404, detail="Account not found")
return account return account
@router.put("/{account_id}") @router.put("/{account_id}")
def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead: 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: if not db_account:
raise HTTPException(status_code=404, detail="Account not found") raise HTTPException(status_code=404, detail="Account not found")
account_data = account.model_dump(exclude_unset=True) 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 return account
@router.delete("/{account_id}") @router.get("/{account_id}/opening_state")
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)): def read_account_opening_state(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> OpeningTransaction:
account = Account.get(session, account_id) 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: if not account:
raise HTTPException(status_code=404, detail="Account not found") 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} return {"ok": True}

View File

@@ -1,46 +1,65 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from fastapi_filter import FilterDepends
from fastapi_pagination import Page from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate 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 db import SessionDep
from user.manager import get_current_user from user.manager import get_current_user
router = APIRouter() router = APIRouter()
@router.post("") @router.post("")
def create_category(category: CategoryCreate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead: def create_category(category: CategoryCreate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
result = Account.create(category, session) result = AccountResource.create(category, session)
return result return result
@router.get("") @router.get("")
def read_categorys(session: SessionDep, current_user=Depends(get_current_user)) -> Page[AccountRead]: def read_categories(session: SessionDep,
return paginate(session, Account.list_categories()) 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,
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,
filters: AccountFilters = FilterDepends(AccountFilters),
current_user=Depends(get_current_user)) -> Page[CategoryRead]:
return paginate(session, AccountResource.list_incomes(filters))
@router.get("/{category_id}") @router.get("/{category_id}")
def read_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead: def read_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
category = Account.get(session, category_id) category = AccountResource.get(session, category_id)
if not category: if not category:
raise HTTPException(status_code=404, detail="Category not found") raise HTTPException(status_code=404, detail="Category not found")
return category return category
@router.put("/{category_id}") @router.put("/{category_id}")
def update_category(category_id: UUID, category: CategoryUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead: def update_category(category_id: UUID, category: CategoryUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> CategoryRead:
db_category = Account.get(session, category_id) db_category = AccountResource.get(session, category_id)
if not db_category: if not db_category:
raise HTTPException(status_code=404, detail="Category not found") raise HTTPException(status_code=404, detail="Category not found")
category_data = category.model_dump(exclude_unset=True) 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 return category
@router.delete("/{category_id}") @router.delete("/{category_id}")
def delete_category(category_id: UUID, session: SessionDep, current_user=Depends(get_current_user)): 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: if not category:
raise HTTPException(status_code=404, detail="Category not found") raise HTTPException(status_code=404, detail="Category not found")
Account.delete(session, category) AccountResource.delete(session, category)
return {"ok": True} 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,114 +1,40 @@
from uuid import UUID, uuid4 from typing import Optional
from enum import Enum
from sqlmodel import Field, SQLModel, select from sqlmodel import Relationship
from account.enums import CategoryFamily, Asset, Liability, AccountFamily
class AccountBase(SQLModel): from account.schemas import AccountBaseId
name: str = Field(index=True)
type: str = Field(index=True)
class AccountBaseId(AccountBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
class Account(AccountBaseId, table=True): class Account(AccountBaseId, table=True):
parent_account: Optional["Account"] = Relationship(
@classmethod back_populates="children_accounts",
def create(cls, account, session): sa_relationship_kwargs=dict(remote_side='Account.id')
account_db = cls.model_validate(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])
) )
children_accounts: list["Account"] = Relationship(back_populates='parent_account')
transaction_splits: list["Split"] = Relationship(back_populates='account')
@classmethod def is_category(self):
def list_categories(cls): return self.family in [v.value for v in CategoryFamily]
return cls.list().where(
Account.type.in_([v.value for v in CategoryType])
)
@classmethod def get_child_path(self, child):
def get(cls, session, account_id): return f"{self.path}{child.name}/"
return session.get(Account, account_id)
@classmethod def get_root_path(self):
def update(cls, session, account_db, account_data): root = "/Categories" if self.is_category() else "/Accounts"
account_db.sqlmodel_update(cls.model_validate(account_data)) return f"{root}/{self.family}/{self.name}/"
session.add(account_db)
session.commit()
session.refresh(account_db)
return account_db
@classmethod def compute_path(self):
def delete(cls, session, account): if self.parent_account is None:
session.delete(account) self.path = self.get_root_path()
session.commit() else:
self.path = self.parent_account.get_child_path(self)
return self.path
class AccountRead(AccountBaseId): def compute_family(self):
pass if self.type in Asset:
self.family = AccountFamily.Asset.value
class AccountType(Enum): elif self.type in Liability:
Asset = "Asset" # < Denotes a generic asset account. self.family = AccountFamily.Liability.value
Checkings = "Checkings" # < Standard checking account else:
Savings = "Savings" # < Typical savings account self.family = self.type
Cash = "Cash" # < Denotes a shoe-box or pillowcase stuffed with cash return self.family
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"
class Liability(Enum):
Liability = "Liability"
CreditCard = "CreditCard"
Loan = "Loan"
Investment = "Investment"
class AccountWrite(AccountBase):
type: Asset | Liability = Field()
class AccountCreate(AccountWrite):
pass
class AccountUpdate(AccountWrite):
pass
class CategoryType(Enum):
Income = "Income"
Expense = "Expense"
class CategoryWrite(AccountBase):
type: CategoryType = Field()
class CategoryCreate(CategoryWrite):
pass
class CategoryUpdate(CategoryWrite):
pass

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(): def create_db_and_tables():
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
def drop_tables():
SQLModel.metadata.drop_all(engine)
def get_session() -> Session: def get_session() -> Session:
with Session(engine) as session: with Session(engine) as session:
yield 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.middleware.cors import CORSMiddleware
from fastapi_pagination import add_pagination from fastapi_pagination import add_pagination
from db import create_db_and_tables 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.account_routes import router as account_router
from account.category_routes import router as category_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 payee.routes import router as payee_router
from transaction.routes import router as transaction_router from transaction.routes import router as transaction_router
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
create_db_and_tables() create_db_and_tables()
create_admin_account() create_admin_user()
yield yield
#do something before end #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(user_router, prefix="/users", tags=["users"])
app.include_router(account_router, prefix="/accounts", tags=["accounts"]) app.include_router(account_router, prefix="/accounts", tags=["accounts"])
app.include_router(category_router, prefix="/categories", tags=["categories"]) 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(payee_router, prefix="/payees", tags=["payees"])
app.include_router(transaction_router, prefix="/transactions", tags=["transactions"]) 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 sqlmodel import Field, SQLModel, select, Relationship
from pydantic import Field as PydField 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): class PayeeBase(SQLModel):
@@ -16,7 +17,7 @@ class PayeeBaseId(PayeeBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True) id: UUID | None = Field(default_factory=uuid4, primary_key=True)
class Payee(PayeeBaseId, table=True): class Payee(PayeeBaseId, table=True):
default_account: Account | None = Relationship() default_account: Optional[Account] = Relationship()
@classmethod @classmethod
def create(cls, payee, session): def create(cls, payee, session):
@@ -29,7 +30,7 @@ class Payee(PayeeBaseId, table=True):
@classmethod @classmethod
def list(cls, filters): def list(cls, filters):
return filters.filter(select(cls)).join(Account) return filters.filter(select(cls)).join(Account, isouter=True)
@classmethod @classmethod
def get(cls, session, payee_id): def get(cls, session, payee_id):
@@ -49,7 +50,7 @@ class Payee(PayeeBaseId, table=True):
session.commit() session.commit()
class PayeeRead(PayeeBaseId): class PayeeRead(PayeeBaseId):
default_account: AccountRead default_account: AccountRead | None = PydField(default=None)
class PayeeWrite(PayeeBase): class PayeeWrite(PayeeBase):
default_account_id: UUID = PydField(default=None, json_schema_extra={ default_account_id: UUID = PydField(default=None, json_schema_extra={

View File

@@ -17,7 +17,7 @@ def create_payee(payee: PayeeCreate, session: SessionDep, current_user=Depends(g
return Payee.get(session, result.id) return Payee.get(session, result.id)
@router.get("") @router.get("")
def read_categories(session: SessionDep, def read_payees(session: SessionDep,
filters: PayeeFilters = FilterDepends(PayeeFilters), filters: PayeeFilters = FilterDepends(PayeeFilters),
current_user=Depends(get_current_user)) -> Page[PayeeRead]: current_user=Depends(get_current_user)) -> Page[PayeeRead]:
return paginate(session, Payee.list(filters)) 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 decimal import Decimal
from typing import Optional
from uuid import UUID, uuid4 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 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 from payee.models import Payee, PayeeRead
class TransactionBase(SQLModel): class TransactionBase(SQLModel):
pass transaction_date: date = Field()
payment_date: Optional[date] = Field(default=None)
class TransactionBaseId(TransactionBase): class TransactionBaseId(TransactionBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True) id: UUID | None = Field(default_factory=uuid4, primary_key=True)
class Transaction(TransactionBaseId, table=True): class Transaction(TransactionBaseId, table=True):
splits: list["Split"] = Relationship(back_populates="transaction") splits: list["Split"] = Relationship(back_populates="transaction")
sequence: Optional[int] = Field(default=None)
@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()
class SplitBase(SQLModel): class SplitBase(SQLModel):
account_id: UUID = Field(foreign_key="account.id") 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) amount: Decimal = Field(decimal_places=2)
class SplitBaseId(SplitBase): class SplitBaseId(SplitBase):
@@ -58,7 +33,7 @@ class SplitBaseId(SplitBase):
class SplitRead(SplitBaseId): class SplitRead(SplitBaseId):
account: AccountRead account: AccountRead
payee: PayeeRead payee: PayeeRead | None
class TransactionRead(TransactionBaseId): class TransactionRead(TransactionBaseId):
splits: list[SplitRead] 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": { "foreign_key": {
"reference": { "reference": {
"resource": "payees", "resource": "payees",
@@ -95,7 +70,7 @@ class TransactionUpdate(TransactionWrite):
class Split(SplitBaseId, table=True): class Split(SplitBaseId, table=True):
transaction: Transaction = Relationship(back_populates="splits") transaction: Transaction = Relationship(back_populates="splits")
account: Account | None = Relationship() account: Account | None = Relationship(back_populates="transaction_splits")
payee: Payee | None = Relationship() payee: Payee | None = Relationship()
@classmethod @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 import Page
from fastapi_pagination.ext.sqlmodel import paginate from fastapi_pagination.ext.sqlmodel import paginate
from transaction.models import Transaction, TransactionCreate, TransactionRead, TransactionUpdate
from db import SessionDep from db import SessionDep
from transaction.models import TransactionCreate, TransactionRead, TransactionUpdate
from transaction.resource import TransactionResource
from user.manager import get_current_user from user.manager import get_current_user
router = APIRouter() router = APIRouter()
@router.post("") @router.post("")
def create_transaction(transaction: TransactionCreate, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead: 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 return result
@router.get("") @router.get("")
def read_transactions(session: SessionDep, current_user=Depends(get_current_user)) -> Page[TransactionRead]: 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}") @router.get("/{transaction_id}")
def read_transaction(transaction_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead: 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: if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found") raise HTTPException(status_code=404, detail="Transaction not found")
return transaction return transaction
@router.put("/{transaction_id}") @router.put("/{transaction_id}")
def update_transaction(transaction_id: UUID, transaction: TransactionUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead: 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: if not db_transaction:
raise HTTPException(status_code=404, detail="Transaction not found") raise HTTPException(status_code=404, detail="Transaction not found")
transaction_data = transaction.model_dump(exclude_unset=True) 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 return transaction
@router.delete("/{transaction_id}") @router.delete("/{transaction_id}")
def delete_transaction(transaction_id: UUID, session: SessionDep, current_user=Depends(get_current_user)): 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: if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found") raise HTTPException(status_code=404, detail="Transaction not found")
Transaction.delete(session, transaction) TransactionResource.delete(session, transaction)
return {"ok": True} 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 from .routes import router as user_router
user_router.include_router(reset_password_router) user_router.include_router(reset_password_router)

View File

@@ -2,11 +2,11 @@ import uuid
from sqlmodel import select from sqlmodel import select
from fastapi import Depends 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 import BearerTransport, AuthenticationBackend
from fastapi_users.authentication.strategy.db import AccessTokenDatabase, DatabaseStrategy 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 from db import get_session
SECRET = "SECRET" SECRET = "SECRET"
@@ -17,7 +17,6 @@ bearer_transport = BearerTransport(tokenUrl="auth/login")
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
pass pass
async def get_user_manager(user_db=Depends(get_user_db)) -> UserManager: async def get_user_manager(user_db=Depends(get_user_db)) -> UserManager:
yield UserManager(user_db) yield UserManager(user_db)
@@ -37,20 +36,14 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](
[auth_backend], [auth_backend],
) )
get_current_user = fastapi_users.current_user(active=True) get_current_user = fastapi_users.current_user(active=True)
get_current_superuser = fastapi_users.current_user(active=True, superuser=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() reset_password_router = fastapi_users.get_reset_password_router()
auth_router = fastapi_users.get_auth_router(auth_backend) auth_router = fastapi_users.get_auth_router(auth_backend)
def create_admin_account(): def create_admin_user(session=get_session().__next__()):
session = get_session().__next__()
admin_email = 'root@root.fr' admin_email = 'root@root.fr'
statement = select(User).where(User.email == admin_email).limit(1) statement = select(User).where(User.email == admin_email).limit(1)
admin_user = session.exec(statement).first() 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 { CategoryList, CategoryCreate, CategoryEdit } from "./pages/categories";
import { PayeeCreate, PayeeEdit, PayeeList } from "./pages/payees"; import { PayeeCreate, PayeeEdit, PayeeList } from "./pages/payees";
import { TransactionCreate, TransactionEdit, TransactionList } from "./pages/transactions"; import { TransactionCreate, TransactionEdit, TransactionList } from "./pages/transactions";
import {AccountLedger} from "./pages/accounts/ledger";
/** /**
* mock auth credentials to simulate authentication * mock auth credentials to simulate authentication
@@ -135,6 +136,7 @@ const App: React.FC = () => {
<Route index element={<AccountList />} /> <Route index element={<AccountList />} />
<Route path="create" element={<AccountCreate />} /> <Route path="create" element={<AccountCreate />} />
<Route path="edit/:id" element={<AccountEdit />} /> <Route path="edit/:id" element={<AccountEdit />} />
<Route path="ledger/:id" element={<AccountLedger />} />
</Route> </Route>
<Route path="/categories"> <Route path="/categories">
<Route index element={<CategoryList />} /> <Route index element={<CategoryList />} />

View File

@@ -23,6 +23,21 @@ const UnionEnumField = (props: FieldProps) => {
} = props; } = props;
const enumOptions: any[] = [] const enumOptions: any[] = []
if (options.length == 2 && (options[0].type == "null" || options[1].type == "null")) {
const { SchemaField: _SchemaField } = registry.fields;
let opt_schema = {...schema}
delete(opt_schema.anyOf)
if (options[0].type == "null") {
opt_schema = {...opt_schema, ...options[1]}
} else if (options[1].type == "null") {
opt_schema = {...opt_schema, ...options[0]}
}
return <_SchemaField {...props} schema={opt_schema} uiSchema={uiSchema} />
}
for (let opt of options) { for (let opt of options) {
if (!opt.hasOwnProperty('enum')) { if (!opt.hasOwnProperty('enum')) {
return (<AnyOfField {...props} />) return (<AnyOfField {...props} />)

View File

@@ -2,7 +2,7 @@ import { WidgetProps } from '@rjsf/utils';
import { Autocomplete } from "@mui/material"; import { Autocomplete } from "@mui/material";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useList, useOne } from "@refinedev/core"; import {BaseRecord, useList, useOne} from "@refinedev/core";
export const ForeignKeyWidget = (props: WidgetProps) => { export const ForeignKeyWidget = (props: WidgetProps) => {
const resource = props.schema.foreign_key.reference.resource const resource = props.schema.foreign_key.reference.resource
@@ -10,9 +10,14 @@ export const ForeignKeyWidget = (props: WidgetProps) => {
const valueResult = useOne({ const valueResult = useOne({
resource: resource, resource: resource,
id: props.value id: props.value != null ? props.value : undefined
}); });
const empty_option: BaseRecord = {
id: null
}
empty_option[labelField] = "(None)"
const [inputValue, setInputValue] = useState<string>(""); const [inputValue, setInputValue] = useState<string>("");
const [selectedValue, setSelectedValue] = useState(valueResult.data?.data || null); const [selectedValue, setSelectedValue] = useState(valueResult.data?.data || null);
const [debouncedInputValue, setDebouncedInputValue] = useState<string>(inputValue); const [debouncedInputValue, setDebouncedInputValue] = useState<string>(inputValue);
@@ -30,6 +35,9 @@ export const ForeignKeyWidget = (props: WidgetProps) => {
}); });
const options = listResult.data?.data || []; const options = listResult.data?.data || [];
if (! props.required) {
options.unshift(empty_option);
}
const isLoading = listResult.isLoading || valueResult.isLoading; const isLoading = listResult.isLoading || valueResult.isLoading;
if(! selectedValue && valueResult.data) { if(! selectedValue && valueResult.data) {
@@ -40,8 +48,9 @@ export const ForeignKeyWidget = (props: WidgetProps) => {
<Autocomplete <Autocomplete
value={selectedValue} value={selectedValue}
onChange={(event, newValue) => { onChange={(event, newValue) => {
setSelectedValue(newValue) setSelectedValue(newValue ? newValue : empty_option);
props.onChange(newValue ? newValue.id : "") props.onChange(newValue ? newValue.id : null);
return true;
}} }}
//inputValue={inputValue} //inputValue={inputValue}
onInputChange={(event, newInputValue) => setInputValue(newInputValue)} onInputChange={(event, newInputValue) => setInputValue(newInputValue)}

View File

@@ -6,10 +6,17 @@ export const AccountEdit: React.FC = () => {
const { id } = useParams() const { id } = useParams()
return ( return (
<div>
<CrudForm <CrudForm
schemaName={"AccountUpdate"} schemaName={"AccountUpdate"}
resource={"accounts"} resource={"accounts"}
id={id} 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 React from "react";
import { Button, ButtonGroup } from "@mui/material";
import { DataGrid, type GridColDef } from "@mui/x-data-grid"; 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 type { IAccount } from "../../interfaces";
import {AccountCreate} from "./create";
import {ButtonGroup} from "@mui/material";
export const AccountList: React.FC = () => { export const AccountList: React.FC = () => {
const { dataGridProps } = useDataGrid<IAccount>(); const { dataGridProps } = useDataGrid<IAccount>();
@@ -15,7 +15,8 @@ export const AccountList: React.FC = () => {
() => [ () => [
{ field: "id", headerName: "ID" }, { field: "id", headerName: "ID" },
{ field: "name", headerName: "Name", flex: 1 }, { field: "name", headerName: "Name", flex: 1 },
{ field: "type", headerName: "Type", flex: 0.3 }, { field: "path", headerName: "path", flex: 1 },
{ field: "type", headerName: "Type", flex: 1 },
{ {
field: "actions", field: "actions",
headerName: "Actions", headerName: "Actions",
@@ -23,6 +24,7 @@ export const AccountList: React.FC = () => {
renderCell: function render({ row }) { renderCell: function render({ row }) {
return ( return (
<ButtonGroup> <ButtonGroup>
<Button href={`/accounts/ledger/${row.id}`}><RequestQuoteIcon /></Button>
<EditButton hideText recordItemId={row.id} /> <EditButton hideText recordItemId={row.id} />
<DeleteButton hideText recordItemId={row.id} /> <DeleteButton hideText recordItemId={row.id} />
</ButtonGroup> </ButtonGroup>
@@ -30,6 +32,7 @@ export const AccountList: React.FC = () => {
}, },
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
flex: 1,
}, },
], ],
[], [],

View File

@@ -67,11 +67,7 @@ export const authProvider: AuthProvider = {
onError: async (error) => { onError: async (error) => {
if (error?.status === 401) { if (error?.status === 401) {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
return Promise<{ return {redirectTo: "/login"}
redirectTo: "/login",
logout: true,
error: { message: "Unauthorized" },
}>;
} }
return {}; return {};
}, },

View File

@@ -14,8 +14,7 @@ const fetcher = async (url: string, options?: RequestInit) => {
export const dataProvider: DataProvider = { export const dataProvider: DataProvider = {
getOne: async ({ resource, id, meta }) => { 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; if (response.status < 200 || response.status > 299) throw response;
const data = await response.json(); const data = await response.json();
@@ -48,8 +47,7 @@ export const dataProvider: DataProvider = {
} }
if (sorters && sorters.length > 0) { if (sorters && sorters.length > 0) {
params.append("sort", sorters.map((sorter) => sorter.field).join(",")); params.append("order_by", sorters.map((sorter) => (sorter.order == "asc" ? "+" : "-") + sorter.field).join(","));
params.append("order", sorters.map((sorter) => sorter.order).join(","));
} }
if (filters && filters.length > 0) { if (filters && filters.length > 0) {

View File

@@ -33,12 +33,16 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
} else if (is_union(prop)) { } else if (is_union(prop)) {
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf; const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
for (let i in union) { for (let i in union) {
if (is_reference(union[i])) {
resolveReference(rawSchemas, resource, union[i]); resolveReference(rawSchemas, resource, union[i]);
} }
}
} else if (is_enum(prop)) { } else if (is_enum(prop)) {
for (let i in prop.allOf) { for (let i in prop.allOf) {
if (is_reference(prop.allOf[i])) {
resolveReference(rawSchemas, resource, prop.allOf[i]); resolveReference(rawSchemas, resource, prop.allOf[i]);
} }
}
} else if (is_array(prop) && is_reference(prop.items)) { } else if (is_array(prop) && is_reference(prop.items)) {
resolveReference(rawSchemas, resource, prop.items); resolveReference(rawSchemas, resource, prop.items);
} }