Compare commits
39 Commits
c8514a11f1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ed37298a74 | |||
| 950090c762 | |||
| 3268f065d3 | |||
| c8eb7cd9bf | |||
| a81c4fbd7d | |||
| cd325154da | |||
| 4a823b7115 | |||
| a83711315f | |||
| 699009d21a | |||
| 440401049a | |||
| 148b1d00c4 | |||
| b5039f6468 | |||
| 65ecfcd919 | |||
| afe89cfb03 | |||
| ff9b59f38c | |||
| aff702156d | |||
| 171ad9ea4e | |||
| f4bf1a2b30 | |||
| 825fa41bd9 | |||
| 2fa5e04dca | |||
| b6bef1f775 | |||
| 539410c18b | |||
| 171875f915 | |||
| ed6be838fe | |||
| 39c4ab9102 | |||
| f26bd9846a | |||
| a4be703713 | |||
| a33f84c5b4 | |||
| fb7e46efdb | |||
| 778bdc2c74 | |||
| 0b150abae4 | |||
| 1e8731d78b | |||
| fd92c57eb5 | |||
| c1a6c0f572 | |||
| 0207672b69 | |||
| 52db0bb2f3 | |||
| 7ad85dfb34 | |||
| 716ba233f5 | |||
| 5ac667f200 |
2
.idea/budget_forecast.iml
generated
2
.idea/budget_forecast.iml
generated
@@ -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>
|
||||||
15
.idea/dataSources.xml
generated
15
.idea/dataSources.xml
generated
@@ -8,5 +8,20 @@
|
|||||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/api/app/database.db</jdbc-url>
|
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/api/app/database.db</jdbc-url>
|
||||||
<working-dir>$ProjectFileDir$</working-dir>
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
</data-source>
|
</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>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -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>
|
||||||
91
api/app/account/account_routes.py
Normal file
91
api/app/account/account_routes.py
Normal 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}
|
||||||
65
api/app/account/category_routes.py
Normal file
65
api/app/account/category_routes.py
Normal 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
43
api/app/account/enums.py
Normal 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
195
api/app/account/fixtures.py
Normal 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>
|
||||||
|
"""
|
||||||
@@ -1,76 +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
|
||||||
|
|
||||||
class AccountType(Enum):
|
from account.enums import CategoryFamily, Asset, Liability, AccountFamily
|
||||||
Asset = "Asset" # < Denotes a generic asset account.
|
from account.schemas import AccountBaseId
|
||||||
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)
|
|
||||||
|
|
||||||
class Account(AccountBaseId, table=True):
|
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 is_category(self):
|
||||||
def create(cls, account, session):
|
return self.family in [v.value for v in CategoryFamily]
|
||||||
account_db = cls.model_validate(account)
|
|
||||||
session.add(account_db)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(account_db)
|
|
||||||
|
|
||||||
return account_db
|
def get_child_path(self, child):
|
||||||
|
return f"{self.path}{child.name}/"
|
||||||
|
|
||||||
@classmethod
|
def get_root_path(self):
|
||||||
def list(cls):
|
root = "/Categories" if self.is_category() else "/Accounts"
|
||||||
return select(Account)
|
return f"{root}/{self.family}/{self.name}/"
|
||||||
|
|
||||||
@classmethod
|
def compute_path(self):
|
||||||
def get(cls, session, account_id):
|
if self.parent_account is None:
|
||||||
return session.get(Account, account_id)
|
self.path = self.get_root_path()
|
||||||
|
else:
|
||||||
|
self.path = self.parent_account.get_child_path(self)
|
||||||
|
return self.path
|
||||||
|
|
||||||
@classmethod
|
def compute_family(self):
|
||||||
def update(cls, session, account_db, account_data):
|
if self.type in Asset:
|
||||||
account_db.sqlmodel_update(account_data)
|
self.family = AccountFamily.Asset.value
|
||||||
session.add(account_db)
|
elif self.type in Liability:
|
||||||
session.commit()
|
self.family = AccountFamily.Liability.value
|
||||||
session.refresh(account_db)
|
else:
|
||||||
return account_db
|
self.family = self.type
|
||||||
|
return self.family
|
||||||
@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
|
|
||||||
|
|||||||
212
api/app/account/resource.py
Normal file
212
api/app/account/resource.py
Normal 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()
|
||||||
@@ -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}
|
|
||||||
76
api/app/account/schemas.py
Normal file
76
api/app/account/schemas.py
Normal 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
0
api/app/core/__init__.py
Normal file
45
api/app/core/types.py
Normal file
45
api/app/core/types.py
Normal 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
|
||||||
@@ -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
18
api/app/initialize_db.py
Normal 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)
|
||||||
0
api/app/ledger/__init__.py
Normal file
0
api/app/ledger/__init__.py
Normal file
26
api/app/ledger/resource.py
Normal file
26
api/app/ledger/resource.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import and_, select, func
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from transaction.models import Split, Transaction
|
||||||
|
|
||||||
|
class LedgerResource:
|
||||||
|
@classmethod
|
||||||
|
def get_ledger(cls, account_id, filters):
|
||||||
|
split_account = aliased(Split)
|
||||||
|
split_balance = aliased(Split)
|
||||||
|
transaction_balance = aliased(Transaction)
|
||||||
|
|
||||||
|
balance_stmt = select(func.sum(split_balance.amount)) \
|
||||||
|
.join(transaction_balance) \
|
||||||
|
.where(and_(
|
||||||
|
split_balance.account_id == split_account.account_id,
|
||||||
|
transaction_balance.sequence <= Transaction.sequence)
|
||||||
|
).scalar_subquery()
|
||||||
|
|
||||||
|
stmt = select(Transaction,split_account,balance_stmt.label('balance')) \
|
||||||
|
.join(
|
||||||
|
split_account,
|
||||||
|
and_(Transaction.id == split_account.transaction_id, split_account.account_id == account_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return filters.filter(stmt)
|
||||||
31
api/app/ledger/routes.py
Normal file
31
api/app/ledger/routes.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi_filter import FilterDepends
|
||||||
|
from fastapi_filter.contrib.sqlalchemy import Filter
|
||||||
|
from fastapi_pagination import Page
|
||||||
|
from fastapi_pagination.ext.sqlalchemy import paginate
|
||||||
|
from fastapi_pagination.utils import disable_installed_extensions_check
|
||||||
|
|
||||||
|
from db import SessionDep
|
||||||
|
from ledger.resource import LedgerResource
|
||||||
|
from ledger.schema import TransactionLedgerRead
|
||||||
|
from transaction.models import Transaction
|
||||||
|
from user.manager import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
class LedgerFilters(Filter):
|
||||||
|
|
||||||
|
class Constants(Filter.Constants):
|
||||||
|
model = Transaction
|
||||||
|
search_model_fields = ["id", "sequence"]
|
||||||
|
disable_installed_extensions_check()
|
||||||
|
|
||||||
|
@router.get("/{account_id}")
|
||||||
|
def read_ledger(account_id: UUID, session: SessionDep, filters: LedgerFilters = FilterDepends(LedgerFilters), current_user=Depends(get_current_user)) -> Page[TransactionLedgerRead]:
|
||||||
|
return paginate(
|
||||||
|
session,
|
||||||
|
LedgerResource.get_ledger(account_id, filters),
|
||||||
|
transformer=lambda items: [TransactionLedgerRead(transaction=transaction, account_split=split, balance=balance) for transaction, split, balance in items],
|
||||||
|
)
|
||||||
10
api/app/ledger/schema.py
Normal file
10
api/app/ledger/schema.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from transaction.models import TransactionRead, SplitRead
|
||||||
|
|
||||||
|
class TransactionLedgerRead(BaseModel):
|
||||||
|
transaction: TransactionRead = Field()
|
||||||
|
account_split: SplitRead = Field()
|
||||||
|
balance: decimal.Decimal = Field()
|
||||||
@@ -5,18 +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.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 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
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ app.add_middleware(
|
|||||||
app.include_router(auth_router, prefix="/auth", tags=["auth"], )
|
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(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
94
api/app/payee/fixtures.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from account.resource import AccountResource
|
||||||
|
from account.schemas import AccountCreate, CategoryCreate
|
||||||
|
from payee.models import PayeeCreate, Payee
|
||||||
|
|
||||||
|
|
||||||
|
def inject_fixtures(session):
|
||||||
|
for f in fixtures_payee:
|
||||||
|
# f = prepare_dict(session, f)
|
||||||
|
schema = PayeeCreate(**f)
|
||||||
|
Payee.create(schema, session)
|
||||||
|
|
||||||
|
def prepare_dict(session, entry):
|
||||||
|
if entry['parent_path']:
|
||||||
|
parent = AccountResource.get_by_path(session, entry['parent_path'])
|
||||||
|
entry['parent_account_id'] = parent.id
|
||||||
|
else:
|
||||||
|
entry['parent_account_id'] = None
|
||||||
|
del entry['parent_path']
|
||||||
|
return entry
|
||||||
|
|
||||||
|
fixtures_payee = [
|
||||||
|
{
|
||||||
|
"name": "PayeeEmployer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeSocialSecurity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeAssurance1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeAssurance2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeSupermarket1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeSupermarket2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeTaxes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PayeeRent",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fixtures_account = [
|
||||||
|
{
|
||||||
|
"name": "Current Assets",
|
||||||
|
"parent_path": None,
|
||||||
|
"type": "Asset",
|
||||||
|
"opening_date": date(1970, 1, 2),
|
||||||
|
"opening_balance": Decimal("0.00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cash in Wallet",
|
||||||
|
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||||
|
"type": "Asset",
|
||||||
|
"opening_date": date(1970, 1, 3),
|
||||||
|
"opening_balance": Decimal("0.00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Checking Account",
|
||||||
|
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||||
|
"type": "Asset",
|
||||||
|
"opening_date": date(1970, 1, 4),
|
||||||
|
"opening_balance": Decimal("0.00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Savings Account",
|
||||||
|
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||||
|
"type": "Asset",
|
||||||
|
"opening_date": date(1970, 1, 5),
|
||||||
|
"opening_balance": Decimal("0.00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debt Accounts",
|
||||||
|
"parent_path": None,
|
||||||
|
"type": "Liability",
|
||||||
|
"opening_date": date(1970, 1, 6),
|
||||||
|
"opening_balance": Decimal("0.00"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Credit Card",
|
||||||
|
"parent_path": "/Accounts/Liability/Debt Accounts/",
|
||||||
|
"type": "Liability",
|
||||||
|
"opening_date": date(1970, 1, 7),
|
||||||
|
"opening_balance": Decimal("0.00"),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
@@ -37,7 +38,7 @@ class Payee(PayeeBaseId, table=True):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update(cls, session, payee_db, payee_data):
|
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.add(payee_db)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(payee_db)
|
session.refresh(payee_db)
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
36
api/app/transaction/fixtures.py
Normal file
36
api/app/transaction/fixtures.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import random
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from account.resource import AccountResource
|
||||||
|
from transaction.models import TransactionCreate, Split
|
||||||
|
from transaction.resource import TransactionResource
|
||||||
|
|
||||||
|
|
||||||
|
def inject_fixtures(session):
|
||||||
|
for f in fixtures_transaction:
|
||||||
|
for i in range(f["count"]):
|
||||||
|
data = prepare_dict(session, f, i)
|
||||||
|
schema = TransactionCreate(**data)
|
||||||
|
schema.splits.append(Split(**data))
|
||||||
|
TransactionResource.create(session, schema)
|
||||||
|
|
||||||
|
def prepare_dict(session, entry, iteration):
|
||||||
|
account = AccountResource.get_by_path(session, entry['account_path'])
|
||||||
|
result = entry.copy()
|
||||||
|
|
||||||
|
result["account_id"] = account.id
|
||||||
|
result["payee_id"] = None
|
||||||
|
result["splits"] = []
|
||||||
|
result["transaction_date"] = entry["start_date"] + timedelta(days=(iteration / 2))
|
||||||
|
result["amount"] = [10, 100, 1000][random.Random().randint(0,2)] * [1, -1][random.Random().randint(0,1)]
|
||||||
|
result["id"] = 0
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
fixtures_transaction = [
|
||||||
|
{
|
||||||
|
"account_path": "/Accounts/Asset/Current Assets/Checking Account/",
|
||||||
|
"start_date": date(1970, 1, 5),
|
||||||
|
"count": 200
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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(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
|
||||||
|
|||||||
44
api/app/transaction/resource.py
Normal file
44
api/app/transaction/resource.py
Normal 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()
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import { useFormContext } from "react-hook-form";
|
|||||||
|
|
||||||
import { PostList, PostCreate, PostEdit } from "./pages/posts";
|
import { PostList, PostCreate, PostEdit } from "./pages/posts";
|
||||||
import { AccountList, AccountCreate, AccountEdit } from "./pages/accounts";
|
import { AccountList, AccountCreate, AccountEdit } from "./pages/accounts";
|
||||||
|
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
|
||||||
@@ -81,6 +83,12 @@ const App: React.FC = () => {
|
|||||||
edit: "/accounts/edit/:id",
|
edit: "/accounts/edit/:id",
|
||||||
create: "/accounts/create",
|
create: "/accounts/create",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "categories",
|
||||||
|
list: "/categories",
|
||||||
|
edit: "/categories/edit/:id",
|
||||||
|
create: "/categories/create",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "payees",
|
name: "payees",
|
||||||
list: "/payees",
|
list: "/payees",
|
||||||
@@ -128,6 +136,12 @@ 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 path="/categories">
|
||||||
|
<Route index element={<CategoryList />} />
|
||||||
|
<Route path="create" element={<CategoryCreate />} />
|
||||||
|
<Route path="edit/:id" element={<CategoryEdit />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/payees">
|
<Route path="/payees">
|
||||||
<Route index element={<PayeeList />} />
|
<Route index element={<PayeeList />} />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import validator from "@rjsf/validator-ajv8";
|
import validator from "@rjsf/validator-ajv8";
|
||||||
import Form from "@rjsf/mui";
|
import Form from "@rjsf/mui";
|
||||||
import { RegistryWidgetsType } from "@rjsf/utils";
|
import {RegistryFieldsType, RegistryWidgetsType} from "@rjsf/utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { jsonschemaProvider } from "../../providers/jsonschema-provider";
|
import { jsonschemaProvider } from "../../providers/jsonschema-provider";
|
||||||
import { useForm } from "@refinedev/core";
|
import { useForm } from "@refinedev/core";
|
||||||
//import TextWidget from "@rjsf/core/src/components/widgets/TextWidget";
|
|
||||||
import CrudTextWidget from "./widgets/crud-text-widget";
|
import CrudTextWidget from "./widgets/crud-text-widget";
|
||||||
|
import UnionEnumField from "./fields/union-enum";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schemaName: string,
|
schemaName: string,
|
||||||
@@ -14,7 +14,13 @@ type Props = {
|
|||||||
//onSubmit: (data: IChangeEvent, event: FormEvent<any>) => void
|
//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}) => {
|
export const CrudForm: React.FC<Props> = ({schemaName, resource, id}) => {
|
||||||
const { onFinish, query, formLoading } = useForm({
|
const { onFinish, query, formLoading } = useForm({
|
||||||
@@ -53,6 +59,7 @@ export const CrudForm: React.FC<Props> = ({schemaName, resource, id}) => {
|
|||||||
validator={validator}
|
validator={validator}
|
||||||
omitExtraData={true}
|
omitExtraData={true}
|
||||||
widgets={customWidgets}
|
widgets={customWidgets}
|
||||||
|
fields={customFields}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
93
gui/app/src/common/crud/fields/union-enum.tsx
Normal file
93
gui/app/src/common/crud/fields/union-enum.tsx
Normal 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;
|
||||||
@@ -8,6 +8,7 @@ export const BgfcPriceWidget = (props: WidgetProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NumericFormat
|
<NumericFormat
|
||||||
|
{...props}
|
||||||
customInput={TextField}
|
customInput={TextField}
|
||||||
getInputRef={inputRef}
|
getInputRef={inputRef}
|
||||||
onValueChange={values => {
|
onValueChange={values => {
|
||||||
@@ -19,15 +20,15 @@ export const BgfcPriceWidget = (props: WidgetProps) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: <InputAdornment position="end">$</InputAdornment>,
|
//startAdornment: <InputAdornment position="end">$</InputAdornment>,
|
||||||
endAdornment: <InputAdornment position="start">€</InputAdornment>
|
endAdornment: <InputAdornment position="start">€</InputAdornment>
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
valueIsNumericString={true}
|
valueIsNumericString={true}
|
||||||
fixedDecimalScale={true}
|
fixedDecimalScale={true}
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
defaultValue={0}
|
//defaultValue={0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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)}
|
||||||
|
|||||||
40
gui/app/src/common/crud/widgets/union-enum.tsx
Normal file
40
gui/app/src/common/crud/widgets/union-enum.tsx
Normal 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" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,17 @@ export const AccountEdit: React.FC = () => {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CrudForm
|
<div>
|
||||||
schemaName={"AccountUpdate"}
|
<CrudForm
|
||||||
resource={"accounts"}
|
schemaName={"AccountUpdate"}
|
||||||
id={id}
|
resource={"accounts"}
|
||||||
/>
|
id={id}
|
||||||
|
/>
|
||||||
|
<CrudForm
|
||||||
|
schemaName={"OpeningTransactionUpdate"}
|
||||||
|
resource={`accounts/${id}/opening_state`}
|
||||||
|
id=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
69
gui/app/src/pages/accounts/ledger.tsx
Normal file
69
gui/app/src/pages/accounts/ledger.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
|
||||||
|
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import {useList, useOne} from "@refinedev/core";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const AccountLedger: React.FC = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useList({
|
||||||
|
resource: `ledgers/${id}`,
|
||||||
|
});
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell></TableCell>
|
||||||
|
<TableCell align="right">Date</TableCell>
|
||||||
|
<TableCell align="right">Deposit</TableCell>
|
||||||
|
<TableCell align="right">Payment</TableCell>
|
||||||
|
<TableCell align="right">Balance</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((row: any) => {
|
||||||
|
const is_expense: boolean = row.account_split.amount < 0
|
||||||
|
const destination_accounts = []
|
||||||
|
for (const split of row.transaction.splits) {
|
||||||
|
if ((is_expense && split.amount >= 0) || (!is_expense && split.amount < 0)) {
|
||||||
|
destination_accounts.push(split)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let is_split = false;
|
||||||
|
if (destination_accounts) {
|
||||||
|
if (destination_accounts.length > 1) {
|
||||||
|
is_split = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return(
|
||||||
|
<TableRow
|
||||||
|
key={row.name}
|
||||||
|
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||||
|
>
|
||||||
|
<TableCell component="th" scope="row"></TableCell>
|
||||||
|
<TableCell align="right">{row.transaction.transaction_date}</TableCell>
|
||||||
|
<TableCell align="right">{ is_expense ? "" : row.account_split.amount }</TableCell>
|
||||||
|
<TableCell align="right">{ is_expense ? row.account_split.amount : "" }</TableCell>
|
||||||
|
<TableCell align="right">{ row.balance }</TableCell>
|
||||||
|
</TableRow>)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
|
|||||||
12
gui/app/src/pages/categories/create.tsx
Normal file
12
gui/app/src/pages/categories/create.tsx
Normal 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"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
gui/app/src/pages/categories/edit.tsx
Normal file
15
gui/app/src/pages/categories/edit.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
gui/app/src/pages/categories/index.tsx
Normal file
3
gui/app/src/pages/categories/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./list";
|
||||||
|
export * from "./create";
|
||||||
|
export * from "./edit";
|
||||||
49
gui/app/src/pages/categories/list.tsx
Normal file
49
gui/app/src/pages/categories/list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 {};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { DataProvider } from "@refinedev/core";
|
import type { DataProvider } from "@refinedev/core";
|
||||||
|
|
||||||
//const API_URL = "https://api.fake-rest.refine.dev";
|
|
||||||
const API_URL = "http://localhost:8000";
|
const API_URL = "http://localhost:8000";
|
||||||
|
|
||||||
const fetcher = async (url: string, options?: RequestInit) => {
|
const fetcher = async (url: string, options?: RequestInit) => {
|
||||||
@@ -15,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();
|
||||||
@@ -49,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) {
|
||||||
|
|||||||
@@ -31,12 +31,17 @@ function buildResource(rawSchemas: RJSFSchema, resourceName: string) {
|
|||||||
if (is_reference(prop)) {
|
if (is_reference(prop)) {
|
||||||
resolveReference(rawSchemas, resource, prop);
|
resolveReference(rawSchemas, resource, prop);
|
||||||
} else if (is_union(prop)) {
|
} else if (is_union(prop)) {
|
||||||
for (let i in prop.oneOf) {
|
const union = prop.hasOwnProperty("oneOf") ? prop.oneOf : prop.anyOf;
|
||||||
resolveReference(rawSchemas, resource, prop.oneOf[i]);
|
for (let i in union) {
|
||||||
|
if (is_reference(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) {
|
||||||
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)) {
|
} else if (is_array(prop) && is_reference(prop.items)) {
|
||||||
resolveReference(rawSchemas, resource, prop.items);
|
resolveReference(rawSchemas, resource, prop.items);
|
||||||
@@ -92,11 +97,11 @@ function is_array(prop: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function is_union(prop: any) {
|
function is_union(prop: any) {
|
||||||
return prop.hasOwnProperty('oneOf');
|
return prop.hasOwnProperty('oneOf') || prop.hasOwnProperty('anyOf');
|
||||||
}
|
}
|
||||||
|
|
||||||
function is_enum(prop: any) {
|
function is_enum(prop: any) {
|
||||||
return prop.hasOwnProperty('allOf');
|
return prop.hasOwnProperty('enum');
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_reference_name(prop: any) {
|
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);
|
let subresourceName = get_reference_name(resource);
|
||||||
return has_descendant(rawSchemas, buildResource(rawSchemas, subresourceName), property_name);
|
return has_descendant(rawSchemas, buildResource(rawSchemas, subresourceName), property_name);
|
||||||
} else if (is_union(resource)) {
|
} 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)
|
return has_descendant(rawSchemas, ref, property_name)
|
||||||
}
|
}
|
||||||
} else if (is_enum(resource)) {
|
} else if (is_enum(resource)) {
|
||||||
@@ -183,4 +189,3 @@ function get_property_by_path(rawSchemas: RJSFSchema, resource: RJSFSchema, path
|
|||||||
path.substring(pointFirstPosition + 1)
|
path.substring(pointFirstPosition + 1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
267
kmm.sql
Normal file
267
kmm.sql
Normal 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)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user