Added the transaction management.
This commit is contained in:
37
src/accounting/transaction/__init__.py
Normal file
37
src/accounting/transaction/__init__.py
Normal file
@ -0,0 +1,37 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The transaction management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import TransactionConverter, TransactionTypeConverter, \
|
||||
DateConverter
|
||||
app.url_map.converters["transaction"] = TransactionConverter
|
||||
app.url_map.converters["transactionType"] = TransactionTypeConverter
|
||||
app.url_map.converters["date"] = DateConverter
|
||||
|
||||
from .views import bp as transaction_bp
|
||||
bp.register_blueprint(transaction_bp, url_prefix="/transactions")
|
100
src/accounting/transaction/converters.py
Normal file
100
src/accounting/transaction/converters.py
Normal file
@ -0,0 +1,100 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The path converters for the transaction management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Transaction
|
||||
from accounting.transaction.dispatcher import TransactionType, \
|
||||
TXN_TYPE_DICT
|
||||
|
||||
|
||||
class TransactionConverter(BaseConverter):
|
||||
"""The transaction converter to convert the transaction ID from and to the
|
||||
corresponding transaction in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Transaction:
|
||||
"""Converts a transaction ID to a transaction.
|
||||
|
||||
:param value: The transaction ID.
|
||||
:return: The corresponding transaction.
|
||||
"""
|
||||
transaction: Transaction | None = db.session.get(Transaction, value)
|
||||
if transaction is None:
|
||||
abort(404)
|
||||
return transaction
|
||||
|
||||
def to_url(self, value: Transaction) -> str:
|
||||
"""Converts a transaction to its ID.
|
||||
|
||||
:param value: The transaction.
|
||||
:return: The ID.
|
||||
"""
|
||||
return str(value.id)
|
||||
|
||||
|
||||
class TransactionTypeConverter(BaseConverter):
|
||||
"""The transaction converter to convert the transaction type ID from and to
|
||||
the corresponding transaction type in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> TransactionType:
|
||||
"""Converts a transaction ID to a transaction.
|
||||
|
||||
:param value: The transaction ID.
|
||||
:return: The corresponding transaction.
|
||||
"""
|
||||
txn_type: TransactionType | None = TXN_TYPE_DICT.get(value)
|
||||
if txn_type is None:
|
||||
abort(404)
|
||||
return txn_type
|
||||
|
||||
def to_url(self, value: TransactionType) -> str:
|
||||
"""Converts a transaction type to its ID.
|
||||
|
||||
:param value: The transaction type.
|
||||
:return: The ID.
|
||||
"""
|
||||
return str(value.ID)
|
||||
|
||||
|
||||
class DateConverter(BaseConverter):
|
||||
"""The date converter to convert the ISO date from and to the
|
||||
corresponding date in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> date:
|
||||
"""Converts an ISO date to a date.
|
||||
|
||||
:param value: The ISO date.
|
||||
:return: The corresponding date.
|
||||
"""
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
|
||||
def to_url(self, value: date) -> str:
|
||||
"""Converts a date to its ISO date.
|
||||
|
||||
:param value: The date.
|
||||
:return: The ISO date.
|
||||
"""
|
||||
return value.isoformat()
|
344
src/accounting/transaction/dispatcher.py
Normal file
344
src/accounting/transaction/dispatcher.py
Normal file
@ -0,0 +1,344 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The transaction type dispatcher.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from flask import render_template, request, abort
|
||||
from flask_wtf import FlaskForm
|
||||
|
||||
from accounting.models import Transaction
|
||||
from .forms import TransactionForm, IncomeTransactionForm, \
|
||||
ExpenseTransactionForm, TransferTransactionForm
|
||||
from .template import default_currency_code
|
||||
|
||||
|
||||
class TransactionType(ABC):
|
||||
"""An abstract transaction type."""
|
||||
ID: str = ""
|
||||
"""The transaction type ID."""
|
||||
CHECK_ORDER: int = -1
|
||||
"""The order when checking the transaction type."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def form(self) -> t.Type[TransactionForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render_create_template(self, form: FlaskForm) -> str:
|
||||
"""Renders the template for the form to create a transaction.
|
||||
|
||||
:param form: The transaction form.
|
||||
:return: the form to create a transaction.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render_detail_template(self, txn: Transaction) -> str:
|
||||
"""Renders the template for the detail page.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: the detail page.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str:
|
||||
"""Renders the template for the form to edit a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:param form: The form.
|
||||
:return: the form to edit a transaction.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def is_my_type(self, txn: Transaction) -> bool:
|
||||
"""Checks and returns whether the transaction belongs to the type.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: True if the transaction belongs to the type, or False
|
||||
otherwise.
|
||||
"""
|
||||
|
||||
@property
|
||||
def _entry_template(self) -> str:
|
||||
"""Renders and returns the template for the journal entry sub-form.
|
||||
|
||||
:return: The template for the journal entry sub-form.
|
||||
"""
|
||||
return render_template(
|
||||
"accounting/transaction/include/form-entry-item.html",
|
||||
currency_index="CURRENCY_INDEX",
|
||||
entry_type="ENTRY_TYPE",
|
||||
entry_index="ENTRY_INDEX")
|
||||
|
||||
|
||||
class IncomeTransaction(TransactionType):
|
||||
"""An income transaction."""
|
||||
ID: str = "income"
|
||||
"""The transaction type ID."""
|
||||
CHECK_ORDER: int = 2
|
||||
"""The order when checking the transaction type."""
|
||||
|
||||
@property
|
||||
def form(self) -> t.Type[TransactionForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
"""
|
||||
return IncomeTransactionForm
|
||||
|
||||
def render_create_template(self, form: IncomeTransactionForm) -> str:
|
||||
"""Renders the template for the form to create a transaction.
|
||||
|
||||
:param form: The transaction form.
|
||||
:return: the form to create a transaction.
|
||||
"""
|
||||
return render_template("accounting/transaction/income/create.html",
|
||||
form=form, txn_type=self,
|
||||
currency_template=self.__currency_template,
|
||||
entry_template=self._entry_template)
|
||||
|
||||
def render_detail_template(self, txn: Transaction) -> str:
|
||||
"""Renders the template for the detail page.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: the detail page.
|
||||
"""
|
||||
return render_template("accounting/transaction/income/detail.html",
|
||||
obj=txn)
|
||||
|
||||
def render_edit_template(self, txn: Transaction,
|
||||
form: IncomeTransactionForm) -> str:
|
||||
"""Renders the template for the form to edit a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:param form: The form.
|
||||
:return: the form to edit a transaction.
|
||||
"""
|
||||
return render_template("accounting/transaction/income/edit.html",
|
||||
txn=txn, form=form,
|
||||
currency_template=self.__currency_template,
|
||||
entry_template=self._entry_template)
|
||||
|
||||
def is_my_type(self, txn: Transaction) -> bool:
|
||||
"""Checks and returns whether the transaction belongs to the type.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: True if the transaction belongs to the type, or False
|
||||
otherwise.
|
||||
"""
|
||||
return txn.is_cash_income
|
||||
|
||||
@property
|
||||
def __currency_template(self) -> str:
|
||||
"""Renders and returns the template for the currency sub-form.
|
||||
|
||||
:return: The template for the currency sub-form.
|
||||
"""
|
||||
return render_template(
|
||||
"accounting/transaction/income/include/form-currency-item.html",
|
||||
currency_index="CURRENCY_INDEX",
|
||||
currency_code_data=default_currency_code(),
|
||||
credit_total="-")
|
||||
|
||||
|
||||
class ExpenseTransaction(TransactionType):
|
||||
"""An expense transaction."""
|
||||
ID: str = "expense"
|
||||
"""The transaction type ID."""
|
||||
CHECK_ORDER: int = 1
|
||||
"""The order when checking the transaction type."""
|
||||
|
||||
@property
|
||||
def form(self) -> t.Type[TransactionForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
"""
|
||||
return ExpenseTransactionForm
|
||||
|
||||
def render_create_template(self, form: ExpenseTransactionForm) -> str:
|
||||
"""Renders the template for the form to create a transaction.
|
||||
|
||||
:param form: The transaction form.
|
||||
:return: the form to create a transaction.
|
||||
"""
|
||||
return render_template("accounting/transaction/expense/create.html",
|
||||
form=form, txn_type=self,
|
||||
currency_template=self.__currency_template,
|
||||
entry_template=self._entry_template)
|
||||
|
||||
def render_detail_template(self, txn: Transaction) -> str:
|
||||
"""Renders the template for the detail page.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: the detail page.
|
||||
"""
|
||||
return render_template("accounting/transaction/expense/detail.html",
|
||||
obj=txn)
|
||||
|
||||
def render_edit_template(self, txn: Transaction,
|
||||
form: ExpenseTransactionForm) -> str:
|
||||
"""Renders the template for the form to edit a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:param form: The form.
|
||||
:return: the form to edit a transaction.
|
||||
"""
|
||||
return render_template("accounting/transaction/expense/edit.html",
|
||||
txn=txn, form=form,
|
||||
currency_template=self.__currency_template,
|
||||
entry_template=self._entry_template)
|
||||
|
||||
def is_my_type(self, txn: Transaction) -> bool:
|
||||
"""Checks and returns whether the transaction belongs to the type.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: True if the transaction belongs to the type, or False
|
||||
otherwise.
|
||||
"""
|
||||
return txn.is_cash_expense
|
||||
|
||||
@property
|
||||
def __currency_template(self) -> str:
|
||||
"""Renders and returns the template for the currency sub-form.
|
||||
|
||||
:return: The template for the currency sub-form.
|
||||
"""
|
||||
return render_template(
|
||||
"accounting/transaction/expense/include/form-currency-item.html",
|
||||
currency_index="CURRENCY_INDEX",
|
||||
currency_code_data=default_currency_code(),
|
||||
debit_total="-")
|
||||
|
||||
|
||||
class TransferTransaction(TransactionType):
|
||||
"""A transfer transaction."""
|
||||
ID: str = "transfer"
|
||||
"""The transaction type ID."""
|
||||
CHECK_ORDER: int = 3
|
||||
"""The order when checking the transaction type."""
|
||||
|
||||
@property
|
||||
def form(self) -> t.Type[TransactionForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
"""
|
||||
return TransferTransactionForm
|
||||
|
||||
def render_create_template(self, form: TransferTransactionForm) -> str:
|
||||
"""Renders the template for the form to create a transaction.
|
||||
|
||||
:param form: The transaction form.
|
||||
:return: the form to create a transaction.
|
||||
"""
|
||||
return render_template("accounting/transaction/transfer/create.html",
|
||||
form=form, txn_type=self,
|
||||
currency_template=self.__currency_template,
|
||||
entry_template=self._entry_template)
|
||||
|
||||
def render_detail_template(self, txn: Transaction) -> str:
|
||||
"""Renders the template for the detail page.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: the detail page.
|
||||
"""
|
||||
return render_template("accounting/transaction/transfer/detail.html",
|
||||
obj=txn)
|
||||
|
||||
def render_edit_template(self, txn: Transaction,
|
||||
form: TransferTransactionForm) -> str:
|
||||
"""Renders the template for the form to edit a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:param form: The form.
|
||||
:return: the form to edit a transaction.
|
||||
"""
|
||||
return render_template("accounting/transaction/transfer/edit.html",
|
||||
txn=txn, form=form,
|
||||
currency_template=self.__currency_template,
|
||||
entry_template=self._entry_template)
|
||||
|
||||
def is_my_type(self, txn: Transaction) -> bool:
|
||||
"""Checks and returns whether the transaction belongs to the type.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: True if the transaction belongs to the type, or False
|
||||
otherwise.
|
||||
"""
|
||||
return True
|
||||
|
||||
@property
|
||||
def __currency_template(self) -> str:
|
||||
"""Renders and returns the template for the currency sub-form.
|
||||
|
||||
:return: The template for the currency sub-form.
|
||||
"""
|
||||
return render_template(
|
||||
"accounting/transaction/transfer/include/form-currency-item.html",
|
||||
currency_index="CURRENCY_INDEX",
|
||||
currency_code_data=default_currency_code(),
|
||||
debit_total="-", credit_total="-")
|
||||
|
||||
|
||||
class TransactionTypes:
|
||||
"""The transaction types, as object properties."""
|
||||
|
||||
def __init__(self, income: IncomeTransaction, expense: ExpenseTransaction,
|
||||
transfer: TransferTransaction):
|
||||
"""Constructs the transaction types as object properties.
|
||||
|
||||
:param income: The income transaction type.
|
||||
:param expense: The expense transaction type.
|
||||
:param transfer: The transfer transaction type.
|
||||
"""
|
||||
self.income: IncomeTransaction = income
|
||||
self.expense: ExpenseTransaction = expense
|
||||
self.transfer: TransferTransaction = transfer
|
||||
|
||||
|
||||
TXN_TYPE_DICT: dict[str, TransactionType] \
|
||||
= {x.ID: x() for x in {IncomeTransaction,
|
||||
ExpenseTransaction,
|
||||
TransferTransaction}}
|
||||
"""The transaction types, as a dictionary."""
|
||||
TXN_TYPE_OBJ: TransactionTypes = TransactionTypes(**TXN_TYPE_DICT)
|
||||
"""The transaction types, as an object."""
|
||||
|
||||
|
||||
def get_txn_type(txn: Transaction) -> TransactionType:
|
||||
"""Returns the transaction type that may be specified in the "as" query
|
||||
parameter. If it is not specified, check the transaction type from the
|
||||
transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: None.
|
||||
"""
|
||||
if "as" in request.args:
|
||||
if request.args["as"] not in TXN_TYPE_DICT:
|
||||
abort(404)
|
||||
return TXN_TYPE_DICT[request.args["as"]]
|
||||
for txn_type in sorted(TXN_TYPE_DICT.values(),
|
||||
key=lambda x: x.CHECK_ORDER):
|
||||
if txn_type.is_my_type(txn):
|
||||
return txn_type
|
832
src/accounting/transaction/forms.py
Normal file
832
src/accounting/transaction/forms.py
Normal file
@ -0,0 +1,832 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the transaction management.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import DateField, StringField, FieldList, FormField, \
|
||||
IntegerField, TextAreaField, DecimalField, BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Transaction, Account, JournalEntry, \
|
||||
TransactionCurrency, Currency
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text, strip_multiline_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.")
|
||||
"""The error message when the currency code is empty."""
|
||||
MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.")
|
||||
"""The error message when the account code is empty."""
|
||||
|
||||
|
||||
class NeedSomeCurrencies:
|
||||
"""The validator to check if there is any currency sub-form."""
|
||||
|
||||
def __call__(self, form: CurrencyForm, field: FieldList) \
|
||||
-> None:
|
||||
if len(field) == 0:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Please add some currencies."))
|
||||
|
||||
|
||||
class CurrencyExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The currency does not exist."))
|
||||
|
||||
|
||||
class NeedSomeJournalEntries:
|
||||
"""The validator to check if there is any journal entry sub-form."""
|
||||
|
||||
def __call__(self, form: TransferCurrencyForm, field: FieldList) \
|
||||
-> None:
|
||||
if len(field) == 0:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Please add some journal entries."))
|
||||
|
||||
|
||||
class AccountExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if Account.find_by_code(field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account does not exist."))
|
||||
|
||||
|
||||
class PositiveAmount:
|
||||
"""The validator to check if the amount is positive."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if field.data <= 0:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Please fill in a positive amount."))
|
||||
|
||||
|
||||
class IsDebitAccount:
|
||||
"""The validator to check if the account is for debit journal entries."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(lazy_gettext(
|
||||
"This account is not for debit entries."))
|
||||
|
||||
|
||||
class JournalEntryForm(FlaskForm):
|
||||
"""The base form to create or edit a journal entry."""
|
||||
eid = IntegerField()
|
||||
"""The existing journal entry ID."""
|
||||
no = IntegerField()
|
||||
"""The order in the currency."""
|
||||
account_code = StringField()
|
||||
"""The account code."""
|
||||
amount = DecimalField()
|
||||
"""The amount."""
|
||||
|
||||
@property
|
||||
def account_text(self) -> str:
|
||||
"""Returns the text representation of the account.
|
||||
|
||||
:return: The text representation of the account.
|
||||
"""
|
||||
if self.account_code.data is None:
|
||||
return ""
|
||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||
if account is None:
|
||||
return ""
|
||||
return str(account)
|
||||
|
||||
@property
|
||||
def all_errors(self) -> list[str | LazyString]:
|
||||
"""Returns all the errors of the form.
|
||||
|
||||
:return: All the errors of the form.
|
||||
"""
|
||||
all_errors: list[str | LazyString] = []
|
||||
for key in self.errors:
|
||||
if key != "csrf_token":
|
||||
all_errors.extend(self.errors[key])
|
||||
return all_errors
|
||||
|
||||
|
||||
class DebitEntryForm(JournalEntryForm):
|
||||
"""The form to create or edit a debit journal entry."""
|
||||
eid = IntegerField()
|
||||
"""The existing journal entry ID."""
|
||||
no = IntegerField()
|
||||
"""The order in the currency."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(MISSING_ACCOUNT),
|
||||
AccountExists(),
|
||||
IsDebitAccount()])
|
||||
"""The account code."""
|
||||
summary = StringField(filters=[strip_text])
|
||||
"""The summary."""
|
||||
amount = DecimalField(validators=[PositiveAmount()])
|
||||
"""The amount."""
|
||||
|
||||
def populate_obj(self, obj: JournalEntry) -> None:
|
||||
"""Populates the form data into a journal entry object.
|
||||
|
||||
:param obj: The journal entry object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(JournalEntry)
|
||||
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||
obj.summary = self.summary.data
|
||||
obj.is_debit = True
|
||||
obj.amount = self.amount.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
|
||||
|
||||
class IsCreditAccount:
|
||||
"""The validator to check if the account is for credit journal entries."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(lazy_gettext(
|
||||
"This account is not for credit entries."))
|
||||
|
||||
|
||||
class CreditEntryForm(JournalEntryForm):
|
||||
"""The form to create or edit a credit journal entry."""
|
||||
eid = IntegerField()
|
||||
"""The existing journal entry ID."""
|
||||
no = IntegerField()
|
||||
"""The order in the currency."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(MISSING_ACCOUNT),
|
||||
AccountExists(),
|
||||
IsCreditAccount()])
|
||||
"""The account code."""
|
||||
summary = StringField(filters=[strip_text])
|
||||
"""The summary."""
|
||||
amount = DecimalField(validators=[PositiveAmount()])
|
||||
"""The amount."""
|
||||
|
||||
def populate_obj(self, obj: JournalEntry) -> None:
|
||||
"""Populates the form data into a journal entry object.
|
||||
|
||||
:param obj: The journal entry object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(JournalEntry)
|
||||
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||
obj.summary = self.summary.data
|
||||
obj.is_debit = False
|
||||
obj.amount = self.amount.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
|
||||
|
||||
class CurrencyForm(FlaskForm):
|
||||
"""The form to create or edit a currency in a transaction."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
code = StringField()
|
||||
"""The currency code."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
|
||||
class TransactionForm(FlaskForm):
|
||||
"""The base form to create or edit a transaction."""
|
||||
date = DateField()
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(CurrencyForm))
|
||||
"""The journal entries categorized by their currencies."""
|
||||
note = TextAreaField()
|
||||
"""The note."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Constructs a base transaction form.
|
||||
|
||||
:param args: The arguments.
|
||||
:param kwargs: The keyword arguments.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_modified: bool = False
|
||||
"""Whether the transaction is modified during populate_obj()."""
|
||||
self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector
|
||||
"""The journal entry collector. The default is the base abstract
|
||||
collector only to provide the correct type. The subclass forms should
|
||||
provide their own collectors."""
|
||||
self.__in_use_account_id: set[int] | None = None
|
||||
"""The ID of the accounts that are in use."""
|
||||
|
||||
def populate_obj(self, obj: Transaction) -> None:
|
||||
"""Populates the form data into a transaction object.
|
||||
|
||||
:param obj: The transaction object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(Transaction)
|
||||
self.__set_date(obj, self.date.data)
|
||||
obj.note = self.note.data
|
||||
|
||||
entries: list[JournalEntry] = obj.entries
|
||||
collector_cls: t.Type[JournalEntryCollector] = self.collector
|
||||
collector: collector_cls = collector_cls(self, obj.id, entries,
|
||||
obj.currencies)
|
||||
collector.collect()
|
||||
|
||||
to_delete: set[int] = {x.id for x in entries
|
||||
if x.id not in collector.to_keep}
|
||||
if len(to_delete) > 0:
|
||||
JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete()
|
||||
self.is_modified = True
|
||||
|
||||
if is_new or db.session.is_modified(obj):
|
||||
self.is_modified = True
|
||||
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
|
||||
@staticmethod
|
||||
def __set_date(obj: Transaction, new_date: date) -> None:
|
||||
"""Sets the transaction date and number.
|
||||
|
||||
:param obj: The transaction object.
|
||||
:param new_date: The new date.
|
||||
:return: None.
|
||||
"""
|
||||
if obj.date is None or obj.date != new_date:
|
||||
if obj.date is not None:
|
||||
sort_transactions_in(obj.date, obj.id)
|
||||
sort_transactions_in(new_date, obj.id)
|
||||
count: int = Transaction.query\
|
||||
.filter(Transaction.date == new_date).count()
|
||||
obj.date = new_date
|
||||
obj.no = count + 1
|
||||
|
||||
@property
|
||||
def debit_account_options(self) -> list[Account]:
|
||||
"""The selectable debit accounts.
|
||||
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
accounts: list[Account] = Account.debit()
|
||||
in_use: set[int] = self.__get_in_use_account_id()
|
||||
for account in accounts:
|
||||
account.is_in_use = account.id in in_use
|
||||
return accounts
|
||||
|
||||
@property
|
||||
def credit_account_options(self) -> list[Account]:
|
||||
"""The selectable credit accounts.
|
||||
|
||||
:return: The selectable credit accounts.
|
||||
"""
|
||||
accounts: list[Account] = Account.credit()
|
||||
in_use: set[int] = self.__get_in_use_account_id()
|
||||
for account in accounts:
|
||||
account.is_in_use = account.id in in_use
|
||||
return accounts
|
||||
|
||||
def __get_in_use_account_id(self) -> set[int]:
|
||||
"""Returns the ID of the accounts that are in use.
|
||||
|
||||
:return: The ID of the accounts that are in use.
|
||||
"""
|
||||
if self.__in_use_account_id is None:
|
||||
self.__in_use_account_id = set(db.session.scalars(
|
||||
sa.select(JournalEntry.account_id)
|
||||
.group_by(JournalEntry.account_id)).all())
|
||||
return self.__in_use_account_id
|
||||
|
||||
@property
|
||||
def currencies_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the currency errors, without the errors in their sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.currencies.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
T = t.TypeVar("T", bound=TransactionForm)
|
||||
"""A transaction form variant."""
|
||||
|
||||
|
||||
class JournalEntryCollector(t.Generic[T], ABC):
|
||||
"""The journal entry collector."""
|
||||
|
||||
def __init__(self, form: T, txn_id: int, entries: list[JournalEntry],
|
||||
currencies: list[TransactionCurrency]):
|
||||
"""Constructs the journal entry collector.
|
||||
|
||||
:param form: The transaction form.
|
||||
:param txn_id: The transaction ID.
|
||||
:param entries: The existing journal entries.
|
||||
:param currencies: The currencies in the transaction.
|
||||
"""
|
||||
self.form: T = form
|
||||
"""The transaction form."""
|
||||
self.entries: list[JournalEntry] = entries
|
||||
"""The existing journal entries."""
|
||||
self.txn_id: int = txn_id
|
||||
"""The transaction ID."""
|
||||
self.__entries_by_id: dict[int, JournalEntry] \
|
||||
= {x.id: x for x in entries}
|
||||
"""A dictionary from the entry ID to their entries."""
|
||||
self.__no_by_id: dict[int, int] = {x.id: x.no for x in entries}
|
||||
"""A dictionary from the entry number to their entries."""
|
||||
self.__currencies: list[TransactionCurrency] = currencies
|
||||
"""The currencies in the transaction."""
|
||||
self._debit_no: int = 1
|
||||
"""The number index for the debit entries."""
|
||||
self._credit_no: int = 1
|
||||
"""The number index for the credit entries."""
|
||||
self.to_keep: set[int] = set()
|
||||
"""The ID of the existing journal entries to keep."""
|
||||
|
||||
@abstractmethod
|
||||
def collect(self) -> set[int]:
|
||||
"""Collects the journal entries.
|
||||
|
||||
:return: The ID of the journal entries to keep.
|
||||
"""
|
||||
|
||||
def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \
|
||||
-> None:
|
||||
"""Composes a journal entry from the form.
|
||||
|
||||
:param form: The journal entry form.
|
||||
:param currency_code: The code of the currency.
|
||||
:param no: The number of the entry.
|
||||
:return: None.
|
||||
"""
|
||||
entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data)
|
||||
if entry is not None:
|
||||
self.to_keep.add(entry.id)
|
||||
entry.currency_code = currency_code
|
||||
form.populate_obj(entry)
|
||||
entry.no = no
|
||||
if db.session.is_modified(entry):
|
||||
self.form.is_modified = True
|
||||
else:
|
||||
entry = JournalEntry()
|
||||
entry.transaction_id = self.txn_id
|
||||
entry.currency_code = currency_code
|
||||
form.populate_obj(entry)
|
||||
entry.no = no
|
||||
db.session.add(entry)
|
||||
self.form.is_modified = True
|
||||
|
||||
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool,
|
||||
currency_code: str, no: int) -> None:
|
||||
"""Composes the cash journal entry at the other side of the cash
|
||||
transaction.
|
||||
|
||||
:param forms: The journal entry forms in the same currency.
|
||||
:param is_debit: True for a cash income transaction, or False for a
|
||||
cash expense transaction.
|
||||
:param currency_code: The code of the currency.
|
||||
:param no: The number of the entry.
|
||||
:return: None.
|
||||
"""
|
||||
candidates: list[JournalEntry] = [x for x in self.entries
|
||||
if x.is_debit == is_debit
|
||||
and x.currency_code == currency_code]
|
||||
entry: JournalEntry
|
||||
if len(candidates) > 0:
|
||||
candidates.sort(key=lambda x: x.no)
|
||||
entry = candidates[0]
|
||||
self.to_keep.add(entry.id)
|
||||
entry.account_id = Account.cash().id
|
||||
entry.summary = None
|
||||
entry.amount = sum([x.amount.data for x in forms])
|
||||
entry.no = no
|
||||
if db.session.is_modified(entry):
|
||||
self.form.is_modified = True
|
||||
else:
|
||||
entry = JournalEntry()
|
||||
entry.id = new_id(JournalEntry)
|
||||
entry.transaction_id = self.txn_id
|
||||
entry.is_debit = is_debit
|
||||
entry.currency_code = currency_code
|
||||
entry.account_id = Account.cash().id
|
||||
entry.summary = None
|
||||
entry.amount = sum([x.amount.data for x in forms])
|
||||
entry.no = no
|
||||
db.session.add(entry)
|
||||
self.form.is_modified = True
|
||||
|
||||
def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None:
|
||||
"""Sorts the journal entry forms.
|
||||
|
||||
:param forms: The journal entry forms.
|
||||
:return: None.
|
||||
"""
|
||||
missing_no: int = 100 if len(self.__no_by_id) == 0 \
|
||||
else max(self.__no_by_id.values()) + 100
|
||||
ord_by_form: dict[JournalEntryForm, int] \
|
||||
= {forms[i]: i for i in range(len(forms))}
|
||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||
missing_no if x.eid.data is None else
|
||||
self.__no_by_id.get(x.eid.data, missing_no),
|
||||
ord_by_form.get(x)))
|
||||
|
||||
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
|
||||
"""Sorts the currency forms.
|
||||
|
||||
:param forms: The currency forms.
|
||||
:return: None.
|
||||
"""
|
||||
missing_no: int = len(self.__currencies) + 100
|
||||
no_by_code: dict[str, int] = {self.__currencies[i].code: i
|
||||
for i in range(len(self.__currencies))}
|
||||
ord_by_form: dict[CurrencyForm, int] \
|
||||
= {forms[i]: i for i in range(len(forms))}
|
||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||
no_by_code.get(x.code.data, missing_no),
|
||||
ord_by_form.get(x)))
|
||||
|
||||
|
||||
class IncomeCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a cash income transaction."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(MISSING_CURRENCY),
|
||||
CurrencyExists()])
|
||||
"""The currency code."""
|
||||
credit = FieldList(FormField(CreditEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The credit entries."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def credit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the credit journal entries.
|
||||
|
||||
:return: The total amount of the credit journal entries.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.credit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def credit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the credit journal entry errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.credit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
class IncomeTransactionForm(TransactionForm):
|
||||
"""The form to create or edit a cash income transaction."""
|
||||
date = DateField(default=date.today())
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
|
||||
validators=[NeedSomeCurrencies()])
|
||||
"""The journal entries categorized by their currencies."""
|
||||
note = TextAreaField(filters=[strip_multiline_text])
|
||||
"""The note."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Collector(JournalEntryCollector[IncomeTransactionForm]):
|
||||
"""The journal entry collector for the cash income transactions."""
|
||||
|
||||
def collect(self) -> None:
|
||||
currencies: list[IncomeCurrencyForm] \
|
||||
= [x.form for x in self.form.currencies]
|
||||
self._sort_currency_forms(currencies)
|
||||
for currency in currencies:
|
||||
# The debit cash entry
|
||||
self._make_cash_entry(list(currency.credit), True,
|
||||
currency.code.data, self._debit_no)
|
||||
self._debit_no = self._debit_no + 1
|
||||
|
||||
# The credit forms
|
||||
credit_forms: list[CreditEntryForm] \
|
||||
= [x.form for x in currency.credit]
|
||||
self._sort_entry_forms(credit_forms)
|
||||
for credit_form in credit_forms:
|
||||
self._add_entry(credit_form, currency.code.data,
|
||||
self._credit_no)
|
||||
self._credit_no = self._credit_no + 1
|
||||
|
||||
self.collector = Collector
|
||||
|
||||
|
||||
class ExpenseCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a cash expense transaction."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(MISSING_CURRENCY),
|
||||
CurrencyExists()])
|
||||
"""The currency code."""
|
||||
debit = FieldList(FormField(DebitEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The debit entries."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def debit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the debit journal entries.
|
||||
|
||||
:return: The total amount of the debit journal entries.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.debit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def debit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the debit journal entry errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.debit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
class ExpenseTransactionForm(TransactionForm):
|
||||
"""The form to create or edit a cash expense transaction."""
|
||||
date = DateField(default=date.today())
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
|
||||
validators=[NeedSomeCurrencies()])
|
||||
"""The journal entries categorized by their currencies."""
|
||||
note = TextAreaField(filters=[strip_multiline_text])
|
||||
"""The note."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
|
||||
"""The journal entry collector for the cash expense
|
||||
transactions."""
|
||||
|
||||
def collect(self) -> None:
|
||||
currencies: list[ExpenseCurrencyForm] \
|
||||
= [x.form for x in self.form.currencies]
|
||||
self._sort_currency_forms(currencies)
|
||||
for currency in currencies:
|
||||
# The debit forms
|
||||
debit_forms: list[DebitEntryForm] \
|
||||
= [x.form for x in currency.debit]
|
||||
self._sort_entry_forms(debit_forms)
|
||||
for debit_form in debit_forms:
|
||||
self._add_entry(debit_form, currency.code.data,
|
||||
self._debit_no)
|
||||
self._debit_no = self._debit_no + 1
|
||||
|
||||
# The credit forms
|
||||
self._make_cash_entry(list(currency.debit), False,
|
||||
currency.code.data, self._credit_no)
|
||||
self._credit_no = self._credit_no + 1
|
||||
|
||||
self.collector = Collector
|
||||
|
||||
|
||||
class TransferCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a transfer transaction."""
|
||||
|
||||
class IsBalanced:
|
||||
"""The validator to check that the total amount of the debit and credit
|
||||
entries are equal."""
|
||||
def __call__(self, form: TransferCurrencyForm, field: BooleanField)\
|
||||
-> None:
|
||||
if len(form.debit) == 0 or len(form.credit) == 0:
|
||||
return
|
||||
if form.debit_total != form.credit_total:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The totals of the debit and credit amounts do not"
|
||||
" match."))
|
||||
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(MISSING_CURRENCY),
|
||||
CurrencyExists()])
|
||||
"""The currency code."""
|
||||
debit = FieldList(FormField(DebitEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The debit entries."""
|
||||
credit = FieldList(FormField(CreditEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The credit entries."""
|
||||
whole_form = BooleanField(validators=[IsBalanced()])
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def debit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the debit journal entries.
|
||||
|
||||
:return: The total amount of the debit journal entries.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.debit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def credit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the credit journal entries.
|
||||
|
||||
:return: The total amount of the credit journal entries.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.credit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def debit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the debit journal entry errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.debit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
@property
|
||||
def credit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the credit journal entry errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.credit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
class TransferTransactionForm(TransactionForm):
|
||||
"""The form to create or edit a transfer transaction."""
|
||||
date = DateField(default=date.today())
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
||||
validators=[NeedSomeCurrencies()])
|
||||
"""The journal entries categorized by their currencies."""
|
||||
note = TextAreaField(filters=[strip_multiline_text])
|
||||
"""The note."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Collector(JournalEntryCollector[TransferTransactionForm]):
|
||||
"""The journal entry collector for the transfer transactions."""
|
||||
|
||||
def collect(self) -> None:
|
||||
currencies: list[TransferCurrencyForm] \
|
||||
= [x.form for x in self.form.currencies]
|
||||
self._sort_currency_forms(currencies)
|
||||
for currency in currencies:
|
||||
# The debit forms
|
||||
debit_forms: list[DebitEntryForm] \
|
||||
= [x.form for x in currency.debit]
|
||||
self._sort_entry_forms(debit_forms)
|
||||
for debit_form in debit_forms:
|
||||
self._add_entry(debit_form, currency.code.data,
|
||||
self._debit_no)
|
||||
self._debit_no = self._debit_no + 1
|
||||
|
||||
# The credit forms
|
||||
credit_forms: list[CreditEntryForm] \
|
||||
= [x.form for x in currency.credit]
|
||||
self._sort_entry_forms(credit_forms)
|
||||
for credit_form in credit_forms:
|
||||
self._add_entry(credit_form, currency.code.data,
|
||||
self._credit_no)
|
||||
self._credit_no = self._credit_no + 1
|
||||
|
||||
self.collector = Collector
|
||||
|
||||
|
||||
def sort_transactions_in(txn_date: date, exclude: int) -> None:
|
||||
"""Sorts the transactions under a date after changing the date or deleting
|
||||
a transaction.
|
||||
|
||||
:param txn_date: The date of the transaction.
|
||||
:param exclude: The transaction ID to exclude.
|
||||
:return: None.
|
||||
"""
|
||||
transactions: list[Transaction] = Transaction.query\
|
||||
.filter(Transaction.date == txn_date,
|
||||
Transaction.id != exclude)\
|
||||
.order_by(Transaction.no).all()
|
||||
for i in range(len(transactions)):
|
||||
if transactions[i].no != i + 1:
|
||||
transactions[i].no = i + 1
|
||||
|
||||
|
||||
class TransactionReorderForm:
|
||||
"""The form to reorder the transactions."""
|
||||
|
||||
def __init__(self, txn_date: date):
|
||||
"""Constructs the form to reorder the transactions in a day.
|
||||
|
||||
:param txn_date: The date.
|
||||
"""
|
||||
self.date: date = txn_date
|
||||
self.is_modified: bool = False
|
||||
|
||||
def save_order(self) -> None:
|
||||
"""Saves the order of the account.
|
||||
|
||||
:return:
|
||||
"""
|
||||
transactions: list[Transaction] = Transaction.query\
|
||||
.filter(Transaction.date == self.date).all()
|
||||
|
||||
# Collects the specified order.
|
||||
orders: dict[Transaction, int] = {}
|
||||
for txn in transactions:
|
||||
if f"{txn.id}-no" in request.form:
|
||||
try:
|
||||
orders[txn] = int(request.form[f"{txn.id}-no"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Missing and invalid orders are appended to the end.
|
||||
missing: list[Transaction] \
|
||||
= [x for x in transactions if x not in orders]
|
||||
if len(missing) > 0:
|
||||
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||
for txn in missing:
|
||||
orders[txn] = next_no
|
||||
|
||||
# Sort by the specified order first, and their original order.
|
||||
transactions.sort(key=lambda x: (orders[x], x.no))
|
||||
|
||||
# Update the orders.
|
||||
with db.session.no_autoflush:
|
||||
for i in range(len(transactions)):
|
||||
if transactions[i].no != i + 1:
|
||||
transactions[i].no = i + 1
|
||||
self.is_modified = True
|
65
src/accounting/transaction/query.py
Normal file
65
src/accounting/transaction/query.py
Normal file
@ -0,0 +1,65 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The transaction query.
|
||||
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.models import Transaction
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
|
||||
|
||||
def get_transaction_query() -> list[Transaction]:
|
||||
"""Returns the transactions, optionally filtered by the query.
|
||||
|
||||
:return: The transactions.
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return Transaction.query\
|
||||
.order_by(Transaction.date, Transaction.no).all()
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Transaction.note.contains(k)]
|
||||
date: datetime
|
||||
try:
|
||||
date = datetime.strptime(k, "%Y")
|
||||
sub_conditions.append(
|
||||
sa.extract("year", Transaction.date) == date.year)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
date = datetime.strptime(k, "%Y/%m")
|
||||
sub_conditions.append(sa.and_(
|
||||
sa.extract("year", Transaction.date) == date.year,
|
||||
sa.extract("month", Transaction.date) == date.month))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||
sub_conditions.append(sa.and_(
|
||||
sa.extract("month", Transaction.date) == date.month,
|
||||
sa.extract("day", Transaction.date) == date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
return Transaction.query.filter(*conditions)\
|
||||
.order_by(Transaction.date, Transaction.no).all()
|
119
src/accounting/transaction/template.py
Normal file
119
src/accounting/transaction/template.py
Normal file
@ -0,0 +1,119 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The template filters and globals for the transaction management.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from html import escape
|
||||
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
|
||||
urlunparse
|
||||
|
||||
from flask import request, current_app
|
||||
from flask_babel import get_locale
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency
|
||||
|
||||
|
||||
def with_type(uri: str) -> str:
|
||||
"""Adds the transaction type to the URI, if it is specified.
|
||||
|
||||
:param uri: The URI.
|
||||
:return: The result URL, optionally with the transaction type added.
|
||||
"""
|
||||
if "as" not in request.args:
|
||||
return uri
|
||||
uri_p: ParseResult = urlparse(uri)
|
||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||
params = [x for x in params if x[0] != "next"]
|
||||
params.append(("as", request.args["as"]))
|
||||
parts: list[str] = list(uri_p)
|
||||
parts[4] = urlencode(params)
|
||||
return urlunparse(parts)
|
||||
|
||||
|
||||
def format_amount(value: Decimal | None) -> str:
|
||||
"""Formats an amount for readability.
|
||||
|
||||
:param value: The amount.
|
||||
:return: The formatted amount text.
|
||||
"""
|
||||
if value is None or value == 0:
|
||||
return "-"
|
||||
whole: int = int(value)
|
||||
frac: Decimal = (value - whole).normalize()
|
||||
return "{:,}".format(whole) + str(frac)[1:]
|
||||
|
||||
|
||||
def format_date(value: date) -> str:
|
||||
"""Formats a date to be human-friendly.
|
||||
|
||||
:param value: The date.
|
||||
:return: The human-friendly date text.
|
||||
"""
|
||||
today: date = date.today()
|
||||
if value == today:
|
||||
return gettext("Today")
|
||||
if value == today - timedelta(days=1):
|
||||
return gettext("Yesterday")
|
||||
if value == today + timedelta(days=1):
|
||||
return gettext("Tomorrow")
|
||||
locale = str(get_locale())
|
||||
if locale == "zh" or locale.startswith("zh_"):
|
||||
if value == today - timedelta(days=2):
|
||||
return gettext("The day before yesterday")
|
||||
if value == today + timedelta(days=2):
|
||||
return gettext("The day after tomorrow")
|
||||
if locale == "zh" or locale.startswith("zh_"):
|
||||
weekdays = ["一", "二", "三", "四", "五", "六", "日"]
|
||||
weekday = weekdays[value.weekday()]
|
||||
else:
|
||||
weekday = value.strftime("%a")
|
||||
if value.year != today.year:
|
||||
return "{}/{}/{}({})".format(
|
||||
value.year, value.month, value.day, weekday)
|
||||
return "{}/{}({})".format(value.month, value.day, weekday)
|
||||
|
||||
|
||||
def text2html(value: str) -> str:
|
||||
"""Converts plain text into HTML.
|
||||
|
||||
:param value: The plain text.
|
||||
:return: The HTML.
|
||||
"""
|
||||
s: str = escape(value)
|
||||
s = s.replace("\n", "<br>")
|
||||
s = s.replace(" ", " ")
|
||||
return s
|
||||
|
||||
|
||||
def currency_options() -> str:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return Currency.query.order_by(Currency.code).all()
|
||||
|
||||
|
||||
def default_currency_code() -> str:
|
||||
"""Returns the default currency code.
|
||||
|
||||
:return: The default currency code.
|
||||
"""
|
||||
with current_app.app_context():
|
||||
return current_app.config.get("DEFAULT_CURRENCY", "USD")
|
223
src/accounting/transaction/views.py
Normal file
223
src/accounting/transaction/views.py
Normal file
@ -0,0 +1,223 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the transaction management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, session, redirect, request, \
|
||||
flash, url_for
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Transaction
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ
|
||||
from .template import with_type, format_amount, format_date, text2html, \
|
||||
currency_options, default_currency_code
|
||||
from .forms import sort_transactions_in, TransactionReorderForm
|
||||
from .query import get_transaction_query
|
||||
|
||||
bp: Blueprint = Blueprint("transaction", __name__)
|
||||
"""The view blueprint for the transaction management."""
|
||||
bp.add_app_template_filter(with_type, "accounting_txn_with_type")
|
||||
bp.add_app_template_filter(format_amount, "accounting_txn_format_amount")
|
||||
bp.add_app_template_filter(format_date, "accounting_txn_format_date")
|
||||
bp.add_app_template_filter(text2html, "accounting_txn_text2html")
|
||||
bp.add_app_template_global(currency_options, "accounting_txn_currency_options")
|
||||
bp.add_app_template_global(default_currency_code,
|
||||
"accounting_txn_default_currency_code")
|
||||
|
||||
|
||||
@bp.get("", endpoint="list")
|
||||
@has_permission(can_view)
|
||||
def list_transactions() -> str:
|
||||
"""Lists the transactions.
|
||||
|
||||
:return: The transaction list.
|
||||
"""
|
||||
transactions: list[Transaction] = get_transaction_query()
|
||||
pagination: Pagination = Pagination[Transaction](transactions)
|
||||
return render_template("accounting/transaction/list.html",
|
||||
list=pagination.list, pagination=pagination,
|
||||
types=TXN_TYPE_OBJ)
|
||||
|
||||
|
||||
@bp.get("/create/<transactionType:txn_type>", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_transaction_form(txn_type: TransactionType) -> str:
|
||||
"""Shows the form to add a transaction.
|
||||
|
||||
:param txn_type: The transaction type.
|
||||
:return: The form to add a transaction.
|
||||
"""
|
||||
form: txn_type.form
|
||||
if "form" in session:
|
||||
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = txn_type.form()
|
||||
return txn_type.render_create_template(form)
|
||||
|
||||
|
||||
@bp.post("/store/<transactionType:txn_type>", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_transaction(txn_type: TransactionType) -> redirect:
|
||||
"""Adds a transaction.
|
||||
|
||||
:param txn_type: The transaction type.
|
||||
:return: The redirection to the transaction detail on success, or the
|
||||
transaction creation form on error.
|
||||
"""
|
||||
form: txn_type.form = txn_type.form(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(with_type(
|
||||
url_for("accounting.transaction.create", txn_type=txn_type))))
|
||||
txn: Transaction = Transaction()
|
||||
form.populate_obj(txn)
|
||||
db.session.add(txn)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The transaction is added successfully"), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(txn)))
|
||||
|
||||
|
||||
@bp.get("/<transaction:txn>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_transaction_detail(txn: Transaction) -> str:
|
||||
"""Shows the transaction detail.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: The detail.
|
||||
"""
|
||||
txn_type: TransactionType = get_txn_type(txn)
|
||||
return txn_type.render_detail_template(txn)
|
||||
|
||||
|
||||
@bp.get("/<transaction:txn>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_transaction_edit_form(txn: Transaction) -> str:
|
||||
"""Shows the form to edit a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: The form to edit the transaction.
|
||||
"""
|
||||
txn_type: TransactionType = get_txn_type(txn)
|
||||
form: txn_type.form
|
||||
if "form" in session:
|
||||
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = txn_type.form(obj=txn)
|
||||
return txn_type.render_edit_template(txn, form)
|
||||
|
||||
|
||||
@bp.post("/<transaction:txn>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_transaction(txn: Transaction) -> redirect:
|
||||
"""Updates a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: The redirection to the transaction detail on success, or the
|
||||
transaction edit form on error.
|
||||
"""
|
||||
txn_type: TransactionType = get_txn_type(txn)
|
||||
form: txn_type.form = txn_type.form(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(with_type(
|
||||
url_for("accounting.transaction.edit", txn=txn))))
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(txn)
|
||||
if not form.is_modified:
|
||||
flash(lazy_gettext("The transaction was not modified."), "success")
|
||||
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
|
||||
txn.updated_by_id = get_current_user_pk()
|
||||
txn.updated_at = sa.func.now()
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The transaction is updated successfully."), "success")
|
||||
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
|
||||
|
||||
|
||||
@bp.post("/<transaction:txn>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_transaction(txn: Transaction) -> redirect:
|
||||
"""Deletes a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: The redirection to the transaction list on success, or the
|
||||
transaction detail on error.
|
||||
"""
|
||||
txn.delete()
|
||||
sort_transactions_in(txn.date, txn.id)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The transaction is deleted successfully."), "success")
|
||||
return redirect(or_next(with_type(url_for("accounting.transaction.list"))))
|
||||
|
||||
|
||||
@bp.get("/dates/<date:txn_date>", endpoint="order")
|
||||
@has_permission(can_view)
|
||||
def show_transaction_order(txn_date: date) -> str:
|
||||
"""Shows the order of the transactions in a same date.
|
||||
|
||||
:param txn_date: The date.
|
||||
:return: The order of the transactions in the date.
|
||||
"""
|
||||
transactions: list[Transaction] = Transaction.query\
|
||||
.filter(Transaction.date == txn_date)\
|
||||
.order_by(Transaction.no).all()
|
||||
return render_template("accounting/transaction/order.html",
|
||||
date=txn_date, list=transactions)
|
||||
|
||||
|
||||
@bp.post("/dates/<date:txn_date>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_accounts(txn_date: date) -> redirect:
|
||||
"""Reorders the transactions in a date.
|
||||
|
||||
:param txn_date: The date.
|
||||
:return: The redirection to the incoming account or the account list. The
|
||||
reordering operation does not fail.
|
||||
"""
|
||||
form: TransactionReorderForm = TransactionReorderForm(txn_date)
|
||||
form.save_order()
|
||||
if not form.is_modified:
|
||||
flash(lazy_gettext("The order was not modified."), "success")
|
||||
return redirect(or_next(url_for("accounting.account.list")))
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||
return redirect(or_next(url_for("accounting.account.list")))
|
||||
|
||||
|
||||
def __get_detail_uri(txn: Transaction) -> str:
|
||||
"""Returns the detail URI of a transaction.
|
||||
|
||||
:param txn: The transaction.
|
||||
:return: The detail URI of the transaction.
|
||||
"""
|
||||
return url_for("accounting.transaction.detail", txn=txn)
|
Reference in New Issue
Block a user