Fully functional opening transactions

This commit is contained in:
2025-02-16 03:17:29 +01:00
parent b5039f6468
commit 148b1d00c4
9 changed files with 114 additions and 50 deletions

View File

@@ -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)

View File

@@ -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"),
}, },
] ]

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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>
); );

View File

@@ -14,8 +14,7 @@ const fetcher = async (url: string, options?: RequestInit) => {
export const dataProvider: DataProvider = { export const dataProvider: DataProvider = {
getOne: async ({ resource, id, meta }) => { getOne: async ({ resource, id, meta }) => {
const response = await fetcher(`${API_URL}/${resource}/${id}`); const response = id !== "" ? await fetcher(`${API_URL}/${resource}/${id}`) : await fetcher(`${API_URL}/${resource}`);
if (response.status < 200 || response.status > 299) throw response; if (response.status < 200 || response.status > 299) throw response;
const data = await response.json(); const data = await response.json();