Fully functional opening transactions
This commit is contained in:
@@ -7,7 +7,7 @@ from fastapi_filter.contrib.sqlalchemy import Filter
|
|||||||
from fastapi_pagination import Page
|
from fastapi_pagination import Page
|
||||||
from fastapi_pagination.ext.sqlmodel import paginate
|
from fastapi_pagination.ext.sqlmodel import paginate
|
||||||
|
|
||||||
from account.schemas import AccountCreate, AccountRead, AccountUpdate
|
from account.schemas import AccountCreate, AccountRead, AccountUpdate, OpeningTransaction, OpeningTransactionUpdate
|
||||||
from account.models import Account
|
from account.models import Account
|
||||||
from account.resource import AccountResource
|
from account.resource import AccountResource
|
||||||
|
|
||||||
@@ -57,13 +57,6 @@ def read_account(account_id: UUID, session: SessionDep, current_user=Depends(get
|
|||||||
raise HTTPException(status_code=404, detail="Account not found")
|
raise HTTPException(status_code=404, detail="Account not found")
|
||||||
return account
|
return account
|
||||||
|
|
||||||
@router.get("/{account_id}/opening_state")
|
|
||||||
def read_account_opening_state(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)) -> TransactionRead:
|
|
||||||
transaction = TransactionResource.get_opening_transaction(session, account_id)
|
|
||||||
if not transaction:
|
|
||||||
raise HTTPException(status_code=404, detail="Account not found")
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
@router.put("/{account_id}")
|
@router.put("/{account_id}")
|
||||||
def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
|
def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep, current_user=Depends(get_current_user)) -> AccountRead:
|
||||||
db_account = AccountResource.get(session, account_id)
|
db_account = AccountResource.get(session, account_id)
|
||||||
@@ -74,6 +67,22 @@ def update_account(account_id: UUID, account: AccountUpdate, session: SessionDep
|
|||||||
account = AccountResource.update(session, db_account, account_data)
|
account = AccountResource.update(session, db_account, account_data)
|
||||||
return account
|
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}")
|
@router.delete("/{account_id}")
|
||||||
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
|
def delete_account(account_id: UUID, session: SessionDep, current_user=Depends(get_current_user)):
|
||||||
account = AccountResource.get(session, account_id)
|
account = AccountResource.get(session, account_id)
|
||||||
|
|||||||
@@ -29,42 +29,42 @@ fixtures_account = [
|
|||||||
"name": "Current Assets",
|
"name": "Current Assets",
|
||||||
"parent_path": None,
|
"parent_path": None,
|
||||||
"type": "Asset",
|
"type": "Asset",
|
||||||
"opening_date": date(1970, 1, 1),
|
"opening_date": date(1970, 1, 2),
|
||||||
"opening_balance": Decimal("0.00"),
|
"opening_balance": Decimal("0.00"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Cash in Wallet",
|
"name": "Cash in Wallet",
|
||||||
"parent_path": "/Accounts/Asset/Current Assets/",
|
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||||
"type": "Asset",
|
"type": "Asset",
|
||||||
"opening_date": date(1970, 1, 1),
|
"opening_date": date(1970, 1, 3),
|
||||||
"opening_balance": Decimal("0.00"),
|
"opening_balance": Decimal("0.00"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Checking Account",
|
"name": "Checking Account",
|
||||||
"parent_path": "/Accounts/Asset/Current Assets/",
|
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||||
"type": "Asset",
|
"type": "Asset",
|
||||||
"opening_date": date(1970, 1, 1),
|
"opening_date": date(1970, 1, 4),
|
||||||
"opening_balance": Decimal("0.00"),
|
"opening_balance": Decimal("0.00"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Savings Account",
|
"name": "Savings Account",
|
||||||
"parent_path": "/Accounts/Asset/Current Assets/",
|
"parent_path": "/Accounts/Asset/Current Assets/",
|
||||||
"type": "Asset",
|
"type": "Asset",
|
||||||
"opening_date": date(1970, 1, 1),
|
"opening_date": date(1970, 1, 5),
|
||||||
"opening_balance": Decimal("0.00"),
|
"opening_balance": Decimal("0.00"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Debt Accounts",
|
"name": "Debt Accounts",
|
||||||
"parent_path": None,
|
"parent_path": None,
|
||||||
"type": "Liability",
|
"type": "Liability",
|
||||||
"opening_date": date(1970, 1, 1),
|
"opening_date": date(1970, 1, 6),
|
||||||
"opening_balance": Decimal("0.00"),
|
"opening_balance": Decimal("0.00"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Credit Card",
|
"name": "Credit Card",
|
||||||
"parent_path": "/Accounts/Liability/Debt Accounts/",
|
"parent_path": "/Accounts/Liability/Debt Accounts/",
|
||||||
"type": "Liability",
|
"type": "Liability",
|
||||||
"opening_date": date(1970, 1, 1),
|
"opening_date": date(1970, 1, 7),
|
||||||
"opening_balance": Decimal("0.00"),
|
"opening_balance": Decimal("0.00"),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from sqlalchemy import literal_column
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from account.models import Account
|
from account.models import Account
|
||||||
|
from account.schemas import OpeningTransaction
|
||||||
from transaction.models import Split, Transaction
|
from transaction.models import Split, Transaction
|
||||||
|
|
||||||
|
|
||||||
@@ -24,9 +27,9 @@ class AccountResource:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_opening_transaction(cls, session, account, schema):
|
def create_opening_transaction(cls, session, account, schema):
|
||||||
|
|
||||||
equity_account = cls.get_by_path(session, "/Equity/")
|
equity_account = cls.get_by_path(session, "/Equity/")
|
||||||
t = Transaction()
|
t = Transaction()
|
||||||
|
t.transaction_date = schema.opening_date
|
||||||
split_opening = Split()
|
split_opening = Split()
|
||||||
split_opening.id = 0
|
split_opening.id = 0
|
||||||
split_opening.transaction = t
|
split_opening.transaction = t
|
||||||
@@ -41,6 +44,56 @@ class AccountResource:
|
|||||||
|
|
||||||
account.transaction_splits.append(split_opening)
|
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
|
@classmethod
|
||||||
def schema_to_model(cls, session, schema, model=None):
|
def schema_to_model(cls, session, schema, model=None):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import Field, SQLModel
|
||||||
from pydantic import Field as PydField
|
from pydantic import Field as PydField, BaseModel
|
||||||
from pydantic.json_schema import SkipJsonSchema
|
from pydantic.json_schema import SkipJsonSchema
|
||||||
|
|
||||||
from account.enums import Asset, Liability, CategoryFamily
|
from account.enums import Asset, Liability, CategoryFamily
|
||||||
@@ -67,3 +67,10 @@ class CategoryCreate(CategoryWrite):
|
|||||||
|
|
||||||
class CategoryUpdate(CategoryWrite):
|
class CategoryUpdate(CategoryWrite):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class OpeningTransaction(BaseModel):
|
||||||
|
opening_date: date = Field()
|
||||||
|
opening_balance: MonetaryAmount = Field(default=0)
|
||||||
|
|
||||||
|
class OpeningTransactionUpdate(OpeningTransaction):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -2,25 +2,33 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import GetCoreSchemaHandler
|
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
|
||||||
from pydantic_core import core_schema
|
from pydantic_core import core_schema
|
||||||
|
from pydantic.json_schema import JsonSchemaValue
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MonetaryAmount:
|
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
|
@classmethod
|
||||||
def __get_pydantic_core_schema__(
|
def __get_pydantic_core_schema__(
|
||||||
cls, source: type[Any], handler: GetCoreSchemaHandler
|
cls, source: type[Any], handler: GetCoreSchemaHandler
|
||||||
) -> core_schema.CoreSchema:
|
) -> core_schema.CoreSchema:
|
||||||
assert source is MonetaryAmount
|
assert source is MonetaryAmount
|
||||||
return core_schema.no_info_after_validator_function(
|
|
||||||
cls._validate,
|
return core_schema.decimal_schema(multiple_of=0.01)
|
||||||
core_schema.decimal_schema(multiple_of=0.01),
|
|
||||||
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
||||||
cls._serialize,
|
|
||||||
info_arg=False,
|
|
||||||
return_schema=core_schema.decimal_schema(multiple_of=0.01),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -11,7 +12,8 @@ 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)
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
from decimal import Decimal
|
from datetime import date
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Field, SQLModel, select, Relationship
|
from sqlmodel import select
|
||||||
from pydantic import Field as PydField
|
|
||||||
|
|
||||||
from account.models import Account
|
from account.models import Account
|
||||||
from account.schemas import AccountRead
|
from account.schemas import OpeningTransaction
|
||||||
from transaction.models import Transaction, Split
|
from transaction.models import Transaction, Split
|
||||||
from payee.models import Payee, PayeeRead
|
|
||||||
|
|
||||||
class TransactionResource:
|
class TransactionResource:
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -29,16 +25,6 @@ class TransactionResource:
|
|||||||
def get(cls, session, transaction_id):
|
def get(cls, session, transaction_id):
|
||||||
return session.get(Transaction, transaction_id)
|
return session.get(Transaction, transaction_id)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_opening_transaction(cls, session, account_id):
|
|
||||||
split_account = aliased(Split)
|
|
||||||
split_equity = aliased(Split)
|
|
||||||
account_filter = aliased(Account)
|
|
||||||
return session.exec(select(Transaction)
|
|
||||||
.join(split_account, split_account.account_id == account_id)
|
|
||||||
.join(split_equity)
|
|
||||||
.join(account_filter, account_filter.id == split_equity.account_id and Account.path == "/Equity/")).first()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def update(cls, session, transaction_db, transaction_data):
|
def update(cls, session, transaction_db, transaction_data):
|
||||||
transaction_db.sqlmodel_update(Transaction.model_validate(transaction_data))
|
transaction_db.sqlmodel_update(Transaction.model_validate(transaction_data))
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export const AccountEdit: React.FC = () => {
|
|||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
<CrudForm
|
<CrudForm
|
||||||
schemaName={"AccountUpdate"}
|
schemaName={"OpeningTransactionUpdate"}
|
||||||
resource={`accounts/${id}/opening`}
|
resource={`accounts/${id}/opening_state`}
|
||||||
id={id}
|
id=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ const fetcher = async (url: string, options?: RequestInit) => {
|
|||||||
|
|
||||||
export const dataProvider: DataProvider = {
|
export const dataProvider: DataProvider = {
|
||||||
getOne: async ({ resource, id, meta }) => {
|
getOne: async ({ resource, id, meta }) => {
|
||||||
const response = await fetcher(`${API_URL}/${resource}/${id}`);
|
const response = id !== "" ? await fetcher(`${API_URL}/${resource}/${id}`) : await fetcher(`${API_URL}/${resource}`);
|
||||||
|
|
||||||
if (response.status < 200 || response.status > 299) throw response;
|
if (response.status < 200 || response.status > 299) throw response;
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user