Compare commits

..

39 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
0207672b69 Tools for database handling and design 2025-01-26 15:34:56 +01:00
52db0bb2f3 Implementing union enum when mutlifields are all enums 2025-01-26 15:34:29 +01:00
7ad85dfb34 Adding props to the price widget 2025-01-26 15:33:32 +01:00
716ba233f5 Correcting model update 2025-01-26 15:32:47 +01:00
5ac667f200 Seprating accounts and categories routes 2025-01-26 00:24:07 +01:00
47 changed files with 1703 additions and 222 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>

15
.idea/dataSources.xml generated
View File

@@ -8,5 +8,20 @@
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/api/app/database.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="kmm" uuid="581e59e2-3b7e-4f6a-a8d6-8f62c3386a4b">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/kmm.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/xerial/sqlite-jdbc/3.45.1.0/sqlite-jdbc-3.45.1.0.jar</url>
</library>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.45.1/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar</url>
</library>
</libraries>
</data-source>
</component>
</project>

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

@@ -0,0 +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.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 = AccountResource.create(account, session)
return result
@router.get("")
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("/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}")
def read_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
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 = 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 = AccountResource.update(session, db_account, account_data)
return account
@router.get("/{account_id}/opening_state")
def read_account_opening_state(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> OpeningTransaction:
transaction = AccountResource.get_opening_transaction(session, account_id)
if not transaction:
raise HTTPException(status_code=404, detail="Account not found")
return transaction
@router.put("/{account_id}/opening_state")
def update_account_opening_state(account_id: UUID, opening_transaction: OpeningTransactionUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> OpeningTransaction:
account = AccountResource.get(session, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
transaction = AccountResource.update_opening_transaction(session, account, opening_transaction)
return transaction
@router.delete("/{account_id}")
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
account = AccountResource.get(session, account_id)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
AccountResource.delete(session, account)
return {"ok": True}

View File

@@ -0,0 +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.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)) -> CategoryRead:
result = AccountResource.create(category, session)
return result
@router.get("")
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,
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}")
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)) -> 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 = 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 = AccountResource.get(session, category_id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
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,76 +1,40 @@
from uuid import UUID, uuid4
from enum import Enum
from typing import Optional
from sqlmodel import Field, SQLModel, select
from sqlmodel import Relationship
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 AccountBase(SQLModel):
name: str = Field(index=True)
type: AccountType = Field(index=True)
class AccountBaseId(AccountBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
from account.enums import CategoryFamily, Asset, Liability, AccountFamily
from account.schemas import AccountBaseId
class Account(AccountBaseId, table=True):
parent_account: Optional["Account"] = Relationship(
back_populates="children_accounts",
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')
@classmethod
def create(cls, account, session):
account_db = cls.model_validate(account)
session.add(account_db)
session.commit()
session.refresh(account_db)
def is_category(self):
return self.family in [v.value for v in CategoryFamily]
return account_db
def get_child_path(self, child):
return f"{self.path}{child.name}/"
@classmethod
def list(cls):
return select(Account)
def get_root_path(self):
root = "/Categories" if self.is_category() else "/Accounts"
return f"{root}/{self.family}/{self.name}/"
@classmethod
def get(cls, session, account_id):
return session.get(Account, account_id)
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
@classmethod
def update(cls, session, account_db, account_data):
account_db.sqlmodel_update(account_data)
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 AccountWrite(AccountBase):
pass
class AccountCreate(AccountWrite):
pass
class AccountUpdate(AccountWrite):
pass
def compute_family(self):
if self.type in Asset:
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

@@ -1,46 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate
from account.models import Account, AccountCreate, AccountRead, AccountUpdate
from db import SessionDep
from user.manager import get_current_user
router = APIRouter()
@router.post("")
def create_account(account: AccountCreate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
result = Account.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())
@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)
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)
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)
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)
if not account:
raise HTTPException(status_code=404, detail="Account not found")
Account.delete(session, account)
return {"ok": True}

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,18 +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 account.routes import router as account_router
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
@@ -39,6 +39,8 @@ app.add_middleware(
app.include_router(auth_router, prefix="/auth", tags=["auth"], )
app.include_router(user_router, prefix="/users", tags=["users"])
app.include_router(account_router, prefix="/accounts", tags=["accounts"])
app.include_router(category_router, prefix="/categories", tags=["categories"])
app.include_router(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):
@@ -16,7 +17,7 @@ class PayeeBaseId(PayeeBase):
id: UUID | None = Field(default_factory=uuid4, primary_key=True)
class Payee(PayeeBaseId, table=True):
default_account: Account | None = Relationship()
default_account: Optional[Account] = Relationship()
@classmethod
def create(cls, payee, session):
@@ -29,7 +30,7 @@ class Payee(PayeeBaseId, table=True):
@classmethod
def list(cls, filters):
return filters.filter(select(cls)).join(Account)
return filters.filter(select(cls)).join(Account, isouter=True)
@classmethod
def get(cls, session, payee_id):
@@ -37,7 +38,7 @@ class Payee(PayeeBaseId, table=True):
@classmethod
def update(cls, session, payee_db, payee_data):
payee_db.sqlmodel_update(payee_data)
payee_db.sqlmodel_update(cls.model_validate(payee_data))
session.add(payee_db)
session.commit()
session.refresh(payee_db)
@@ -49,7 +50,7 @@ class Payee(PayeeBaseId, table=True):
session.commit()
class PayeeRead(PayeeBaseId):
default_account: AccountRead
default_account: AccountRead | None = PydField(default=None)
class PayeeWrite(PayeeBase):
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)
@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(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

@@ -25,8 +25,10 @@ import { useFormContext } from "react-hook-form";
import { PostList, PostCreate, PostEdit } from "./pages/posts";
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
@@ -81,6 +83,12 @@ const App: React.FC = () => {
edit: "/accounts/edit/:id",
create: "/accounts/create",
},
{
name: "categories",
list: "/categories",
edit: "/categories/edit/:id",
create: "/categories/create",
},
{
name: "payees",
list: "/payees",
@@ -128,6 +136,12 @@ 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 />} />
<Route path="create" element={<CategoryCreate />} />
<Route path="edit/:id" element={<CategoryEdit />} />
</Route>
<Route path="/payees">
<Route index element={<PayeeList />} />

View File

@@ -1,11 +1,11 @@
import validator from "@rjsf/validator-ajv8";
import Form from "@rjsf/mui";
import { RegistryWidgetsType } from "@rjsf/utils";
import {RegistryFieldsType, RegistryWidgetsType} from "@rjsf/utils";
import { useEffect, useState } from "react";
import { jsonschemaProvider } from "../../providers/jsonschema-provider";
import { useForm } from "@refinedev/core";
//import TextWidget from "@rjsf/core/src/components/widgets/TextWidget";
import CrudTextWidget from "./widgets/crud-text-widget";
import UnionEnumField from "./fields/union-enum";
type Props = {
schemaName: string,
@@ -14,7 +14,13 @@ type Props = {
//onSubmit: (data: IChangeEvent, event: FormEvent<any>) => void
}
const customWidgets: RegistryWidgetsType = { TextWidget: CrudTextWidget };
const customWidgets: RegistryWidgetsType = {
TextWidget: CrudTextWidget
};
const customFields: RegistryFieldsType = {
AnyOfField: UnionEnumField
}
export const CrudForm: React.FC<Props> = ({schemaName, resource, id}) => {
const { onFinish, query, formLoading } = useForm({
@@ -53,6 +59,7 @@ export const CrudForm: React.FC<Props> = ({schemaName, resource, id}) => {
validator={validator}
omitExtraData={true}
widgets={customWidgets}
fields={customFields}
/>
)
}

View File

@@ -0,0 +1,93 @@
import { ERRORS_KEY, FieldProps, getUiOptions } from "@rjsf/utils";
import AnyOfField from "@rjsf/core/lib/components/fields/MultiSchemaField";
import { UnionEnumWidget } from "../widgets/union-enum";
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import omit from 'lodash/omit';
const UnionEnumField = (props: FieldProps) => {
const {
name,
disabled = false,
errorSchema = {},
formContext,
formData,
onChange,
onBlur,
onFocus,
registry,
schema,
uiSchema,
options,
idSchema,
} = props;
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) {
if (!opt.hasOwnProperty('enum')) {
return (<AnyOfField {...props} />)
}
for (let val of opt.enum) {
enumOptions.push({
title: val,
value: val,
type: opt.title
})
}
}
const { globalUiOptions, schemaUtils } = registry;
const {
placeholder,
autofocus,
autocomplete,
title = schema.title,
...uiOptions
} = getUiOptions(uiSchema, globalUiOptions);
const rawErrors = get(errorSchema, ERRORS_KEY, []);
const fieldErrorSchema = omit(errorSchema, [ERRORS_KEY]);
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);
return (
<UnionEnumWidget
id={`${idSchema.$id}${schema.oneOf ? '__oneof_select' : '__anyof_select'}`}
name={`${name}${schema.oneOf ? '__oneof_select' : '__anyof_select'}`}
schema={schema}
uiSchema={uiSchema}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
disabled={disabled || isEmpty(options)}
multiple={false}
rawErrors={rawErrors}
errorSchema={fieldErrorSchema}
value={formData}
options={{enumOptions}}
registry={registry}
formContext={formContext}
placeholder={placeholder}
autocomplete={autocomplete}
autofocus={autofocus}
label={title ?? name}
hideLabel={!displayLabel}
/>
);
}
export default UnionEnumField;

View File

@@ -8,6 +8,7 @@ export const BgfcPriceWidget = (props: WidgetProps) => {
return (
<NumericFormat
{...props}
customInput={TextField}
getInputRef={inputRef}
onValueChange={values => {
@@ -19,15 +20,15 @@ export const BgfcPriceWidget = (props: WidgetProps) => {
});
}}
slotProps={{
input: {
startAdornment: <InputAdornment position="end">$</InputAdornment>,
endAdornment: <InputAdornment position="start"></InputAdornment>
},
}}
input: {
//startAdornment: <InputAdornment position="end">$</InputAdornment>,
endAdornment: <InputAdornment position="start"></InputAdornment>
},
}}
valueIsNumericString={true}
fixedDecimalScale={true}
decimalScale={2}
defaultValue={0}
//defaultValue={0}
/>
);
}

View File

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

View File

@@ -0,0 +1,40 @@
import {FieldProps, WidgetProps} from "@rjsf/utils";
import AnyOfField from "@rjsf/core/lib/components/fields/MultiSchemaField";
import {ListSubheader, MenuItem, Select} from "@mui/material";
import {useState} from "react";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
export const UnionEnumWidget = (props: WidgetProps) => {
const {
options,
value,
} = props;
const [selectedValue, setSelectedValue] = useState(null);
if (! selectedValue && value && options.enumOptions) {
for (const opt of options.enumOptions){
if (opt.value == value) {
setSelectedValue(opt);
break;
}
}
}
return (
<Autocomplete
value={selectedValue}
onChange={(event, newValue) => {
setSelectedValue(newValue);
props.onChange(newValue.value);
}}
options={options.enumOptions}
groupBy={(option) => option.type}
getOptionLabel={(option) => option.title}
renderInput={(params) => (
<TextField {...params} label={ props.label } variant="outlined" />
)}
/>
);
}

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>();
@@ -15,7 +15,8 @@ export const AccountList: React.FC = () => {
() => [
{ field: "id", headerName: "ID" },
{ 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",
headerName: "Actions",
@@ -23,6 +24,7 @@ export const AccountList: React.FC = () => {
renderCell: function render({ row }) {
return (
<ButtonGroup>
<Button href={`/accounts/ledger/${row.id}`}><RequestQuoteIcon /></Button>
<EditButton hideText recordItemId={row.id} />
<DeleteButton hideText recordItemId={row.id} />
</ButtonGroup>
@@ -30,6 +32,7 @@ export const AccountList: React.FC = () => {
},
align: "center",
headerAlign: "center",
flex: 1,
},
],
[],

View File

@@ -0,0 +1,12 @@
import {CrudForm} from "../../common/crud/crud-form";
import {CategoryEdit} from "./edit";
export const CategoryCreate: React.FC = () => {
return (
<CrudForm
schemaName={"CategoryCreate"}
resource={"categories"}
/>
);
};

View File

@@ -0,0 +1,15 @@
import { CrudForm } from "../../common/crud/crud-form";
import { useParams } from "react-router"
export const CategoryEdit: React.FC = () => {
const { id } = useParams()
return (
<CrudForm
schemaName={"CategoryUpdate"}
resource={"categories"}
id={id}
/>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./list";
export * from "./create";
export * from "./edit";

View File

@@ -0,0 +1,49 @@
import {DeleteButton, EditButton, List, useDataGrid} from "@refinedev/mui";
import React from "react";
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import type { IAccount } from "../../interfaces";
import { ButtonGroup } from "@mui/material";
export const CategoryList: React.FC = () => {
const { dataGridProps } = useDataGrid<IAccount>();
const columns = React.useMemo<GridColDef<IAccount>[]>(
() => [
{ field: "id", headerName: "ID" },
{ field: "name", headerName: "Name", flex: 1 },
{ field: "type", headerName: "Type", flex: 0.3 },
{
field: "actions",
headerName: "Actions",
display: "flex",
renderCell: function render({ row }) {
return (
<ButtonGroup>
<EditButton hideText recordItemId={row.id} />
<DeleteButton hideText recordItemId={row.id} />
</ButtonGroup>
);
},
align: "center",
headerAlign: "center",
},
],
[],
);
return (
<List>
<div
style={{
display: "flex",
flexDirection: "column",
maxHeight: "calc(100vh - 320px)",
}}
>
<DataGrid {...dataGridProps} columns={columns} />
</div>
</List>
);
};

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

@@ -1,6 +1,5 @@
import type { DataProvider } from "@refinedev/core";
//const API_URL = "https://api.fake-rest.refine.dev";
const API_URL = "http://localhost:8000";
const fetcher = async (url: string, options?: RequestInit) => {
@@ -15,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();
@@ -49,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) {

View File

@@ -31,12 +31,17 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
if (is_reference(prop)) {
resolveReference(rawSchemas, resource, prop);
} else if (is_union(prop)) {
for (let i in prop.oneOf) {
resolveReference(rawSchemas, resource, prop.oneOf[i]);
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
for (let i in union) {
if (is_reference(union[i])) {
resolveReference(rawSchemas, resource, union[i]);
}
}
} else if (is_enum(prop)) {
for (let i in prop.allOf) {
resolveReference(rawSchemas, resource, prop.allOf[i]);
if (is_reference(prop.allOf[i])) {
resolveReference(rawSchemas, resource, prop.allOf[i]);
}
}
} else if (is_array(prop) && is_reference(prop.items)) {
resolveReference(rawSchemas, resource, prop.items);
@@ -92,11 +97,11 @@ function is_array(prop: any) {
}
function is_union(prop: any) {
return prop.hasOwnProperty('oneOf');
return prop.hasOwnProperty('oneOf') || prop.hasOwnProperty('anyOf');
}
function is_enum(prop: any) {
return prop.hasOwnProperty('allOf');
return prop.hasOwnProperty('enum');
}
function get_reference_name(prop: any) {
@@ -112,7 +117,8 @@ function has_descendant(rawSchemas:RJSFSchema, resource: RJSFSchema, property_na
let subresourceName = get_reference_name(resource);
return has_descendant(rawSchemas, buildResource(rawSchemas, subresourceName), property_name);
} else if (is_union(resource)) {
for (const ref of resource.oneOf!) {
const union = resource.hasOwnProperty("oneOf") ? resource.oneOf : resource.anyOf;
for (const ref of union) {
return has_descendant(rawSchemas, ref, property_name)
}
} else if (is_enum(resource)) {
@@ -183,4 +189,3 @@ function get_property_by_path(rawSchemas: RJSFSchema, resource: RJSFSchema, path
path.substring(pointFirstPosition + 1)
);
}

BIN
kmm.db Normal file

Binary file not shown.

267
kmm.sql Normal file
View File

@@ -0,0 +1,267 @@
CREATE TABLE kmmAccounts (
id varchar(32) NOT NULL,
institutionId varchar(32),
parentId varchar(32),
lastReconciled datetime,
lastModified datetime,
openingDate date,
accountNumber mediumtext,
accountType varchar(16) NOT NULL,
accountTypeString mediumtext,
isStockAccount char(1),
accountName mediumtext,
description mediumtext,
currencyId varchar(32),
balance mediumtext,
balanceFormatted mediumtext,
transactionCount bigint unsigned,
PRIMARY KEY (id)
);
CREATE TABLE kmmAccountsPayeeIdentifier (
accountId varchar(32) NOT NULL,
userOrder smallint unsigned NOT NULL,
identifierId varchar(32) NOT NULL,
PRIMARY KEY (accountId,userOrder)
);
CREATE TABLE kmmBudgetConfig (
id varchar(32) NOT NULL,
name text NOT NULL,
start date NOT NULL,
XML longtext,
PRIMARY KEY (id)
);
CREATE TABLE kmmCostCenter (
id varchar(32) NOT NULL,
name text NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE kmmCurrencies (
ISOcode char(3) NOT NULL,
name text NOT NULL,
type smallint unsigned,
typeString mediumtext,
symbol1 smallint unsigned,
symbol2 smallint unsigned,
symbol3 smallint unsigned,
symbolString varchar(255),
smallestCashFraction varchar(24),
smallestAccountFraction varchar(24),
pricePrecision smallint unsigned NOT NULL DEFAULT 4,
PRIMARY KEY (ISOcode)
);
CREATE TABLE kmmFileInfo (
version varchar(16),
created date,
lastModified date,
baseCurrency char(3),
institutions bigint unsigned,
accounts bigint unsigned,
payees bigint unsigned,
tags bigint unsigned,
transactions bigint unsigned,
splits bigint unsigned,
securities bigint unsigned,
prices bigint unsigned,
currencies bigint unsigned,
schedules bigint unsigned,
reports bigint unsigned,
kvps bigint unsigned,
dateRangeStart date,
dateRangeEnd date,
hiInstitutionId bigint unsigned,
hiPayeeId bigint unsigned,
hiTagId bigint unsigned,
hiAccountId bigint unsigned,
hiTransactionId bigint unsigned,
hiScheduleId bigint unsigned,
hiSecurityId bigint unsigned,
hiReportId bigint unsigned,
encryptData varchar(255),
updateInProgress char(1),
budgets bigint unsigned,
hiBudgetId bigint unsigned,
hiOnlineJobId bigint unsigned,
hiPayeeIdentifierId bigint unsigned,
logonUser varchar(255),
logonAt datetime,
fixLevel int unsigned
);
CREATE TABLE kmmInstitutions (
id varchar(32) NOT NULL,
name text NOT NULL,
manager mediumtext,
routingCode mediumtext,
addressStreet mediumtext,
addressCity mediumtext,
addressZipcode mediumtext,
telephone mediumtext,
PRIMARY KEY (id)
);
CREATE TABLE kmmKeyValuePairs (
kvpType varchar(16) NOT NULL,
kvpId varchar(32),
kvpKey varchar(255) NOT NULL,
kvpData mediumtext
);
CREATE INDEX kmmKeyValuePairs_type_id_idx ON kmmKeyValuePairs (kvpType,kvpId);
CREATE TABLE kmmOnlineJobs (
id varchar(32) NOT NULL,
type varchar(255) NOT NULL,
jobSend datetime,
bankAnswerDate datetime,
state varchar(15) NOT NULL,
locked char(1) NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE kmmPayeeIdentifier (
id varchar(32) NOT NULL,
type varchar(255),
PRIMARY KEY (id)
);
CREATE TABLE kmmPayees (
id varchar(32) NOT NULL,
name mediumtext,
reference mediumtext,
email mediumtext,
addressStreet mediumtext,
addressCity mediumtext,
addressZipcode mediumtext,
addressState mediumtext,
telephone mediumtext,
notes longtext,
defaultAccountId varchar(32),
matchData tinyint unsigned,
matchIgnoreCase char(1),
matchKeys mediumtext,
idPattern mediumtext,
urlTemplate mediumtext,
PRIMARY KEY (id)
);
CREATE TABLE kmmPayeesPayeeIdentifier (
payeeId varchar(32) NOT NULL,
userOrder smallint unsigned NOT NULL,
identifierId varchar(32) NOT NULL,
PRIMARY KEY (payeeId,userOrder)
);
CREATE TABLE kmmPluginInfo (
iid varchar(255) NOT NULL,
versionMajor tinyint unsigned NOT NULL,
versionMinor tinyint unsigned,
uninstallQuery longtext,
PRIMARY KEY (iid)
);
CREATE TABLE kmmPrices (
fromId varchar(32) NOT NULL,
toId varchar(32) NOT NULL,
priceDate date NOT NULL,
price text NOT NULL,
priceFormatted mediumtext,
priceSource mediumtext,
PRIMARY KEY (fromId,toId,priceDate)
);
CREATE TABLE kmmReportConfig (name varchar(255) NOT NULL,
XML longtext,
id varchar(32) NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE kmmSchedulePaymentHistory (schedId varchar(32) NOT NULL,
payDate date NOT NULL,
PRIMARY KEY (schedId,payDate)
);
CREATE TABLE kmmSchedules (id varchar(32) NOT NULL,
name text NOT NULL,
type tinyint unsigned NOT NULL,
typeString mediumtext,
occurence smallint unsigned NOT NULL,
occurenceMultiplier smallint unsigned NOT NULL,
occurenceString mediumtext,
paymentType tinyint unsigned,
paymentTypeString longtext,
startDate date NOT NULL,
endDate date,
fixed char(1) NOT NULL,
lastDayInMonth char(1) NOT NULL DEFAULT 'N',
autoEnter char(1) NOT NULL,
lastPayment date,
nextPaymentDue date,
weekendOption tinyint unsigned NOT NULL,
weekendOptionString mediumtext,
PRIMARY KEY (id)
);
CREATE TABLE kmmSecurities (id varchar(32) NOT NULL,
name text NOT NULL,
symbol mediumtext,
type smallint unsigned NOT NULL,
typeString mediumtext,
smallestAccountFraction varchar(24),
pricePrecision smallint unsigned NOT NULL DEFAULT 4,
tradingMarket mediumtext,
tradingCurrency char(3),
roundingMethod smallint unsigned NOT NULL DEFAULT 7,
PRIMARY KEY (id)
);
CREATE TABLE kmmSplits (
transactionId varchar(32) NOT NULL,
txType char(1),
splitId smallint unsigned NOT NULL,
payeeId varchar(32),
reconcileDate datetime,
action varchar(16),
reconcileFlag char(1),
value text NOT NULL,
valueFormatted text,
shares text NOT NULL,
sharesFormatted mediumtext,
price text,
priceFormatted mediumtext,
memo mediumtext,
accountId varchar(32) NOT NULL,
costCenterId varchar(32),
checkNumber varchar(32),
postDate datetime,
bankId mediumtext,
PRIMARY KEY (transactionId, splitId)
);
CREATE INDEX kmmSplits_kmmSplitsaccount_type_idx ON kmmSplits (accountId,txType);
CREATE TABLE kmmTagSplits (transactionId varchar(32) NOT NULL,
tagId varchar(32) NOT NULL,
splitId smallint unsigned NOT NULL,
PRIMARY KEY (transactionId,tagId,splitId)
);
CREATE TABLE kmmTags (id varchar(32) NOT NULL,
name mediumtext,
closed char(1),
notes longtext,
tagColor mediumtext,
PRIMARY KEY (id)
);
CREATE TABLE kmmTransactions (id varchar(32) NOT NULL,
txType char(1),
postDate datetime,
memo mediumtext,
entryDate datetime,
currencyId char(3),
bankId mediumtext,
PRIMARY KEY (id)
);