Added the transaction management.

This commit is contained in:
2023-02-27 15:28:45 +08:00
parent 9383f5484f
commit 05fde3a742
42 changed files with 7090 additions and 2 deletions

View 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")

View 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()

View 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

View 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

View 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()

View 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(" ", " &nbsp;")
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")

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