90 Commits

Author SHA1 Message Date
f3548a2327 Advanced to version 0.4.0. 2023-03-01 01:49:08 +08:00
79883d6940 Changed the Sphinx documentation scheme from "nature" to "sphinx_rtd_theme", to prepare for publishing in the future. 2023-03-01 01:48:56 +08:00
b2bc993416 Replaced the #populate method with the #parseAndPopulate method that is used both when starting the summary helper and when the summary input is updated. 2023-03-01 01:45:38 +08:00
453b3f0da5 Renamed the #tagInputOnChange method to #onTagInputChange in the JavaScript summary helper. 2023-03-01 01:31:25 +08:00
63ae3f0746 Replace the is_in_use pseudo property of the Account data model with the AccountOption class, and revised the #getAccountCodeUsedInForm method of the SummaryHelper, to solve the issue that the list of used accounts should be different for debit and credit entries. 2023-03-01 01:28:25 +08:00
da4cc6489f Removed the direction arrows from the tab navigation in the summary helper. 2023-03-01 00:59:40 +08:00
1102a3a4f3 Updated the translation. 2023-03-01 00:51:58 +08:00
1402a12f04 Simplified the logic in the add_txn function in testlib_txn.py. 2023-03-01 00:51:24 +08:00
f049b5d7ee Revised the form data used in the SummeryHelperTestCase test case, to avoid problems with SonarQube. 2023-03-01 00:51:24 +08:00
14ed4ca354 Added the #initializeTagButtons and #tagInputOnChange methods to the JavaScript SummaryHelper to simplify the code. 2023-03-01 00:51:24 +08:00
535ff96ab3 Revised the JavaScript regular expressions used in the summary helper, as suggested by SonarQube for security. 2023-03-01 00:51:24 +08:00
57482f81fc Revised the transaction form to start a new journal entry with the journal entry form instead of the summary helper, because it feels strange when the user want to leave the summary empty. 2023-03-01 00:51:24 +08:00
a31ce3c400 Replaced the function-based JavaScript account selector with the AccountSelector class that does things better. 2023-03-01 00:51:11 +08:00
319f0aed90 Fixed a documentation in the JavaScript summary helper. 2023-02-28 22:54:20 +08:00
826dcf0f86 Revised the documentation of the JavaScript for the summary helper. 2023-02-28 22:47:04 +08:00
b2411aee74 Updated the Sphinx documentation. 2023-02-28 22:44:40 +08:00
731acdced0 Revised the HTML in the summary helper template. 2023-02-28 22:41:56 +08:00
35b3bca1e6 Renamed the variables for the button elements in the summary helper, to be clear. 2023-02-28 22:37:46 +08:00
3c413497ae Split the JavaScript for the account selector from transaction-form.js to account-selector.js, to modularize the complex JavaScript. 2023-02-28 22:33:14 +08:00
1b5e516413 Renamed the HTML ID and class name prefix of the account selector modal, for consistency. 2023-02-28 22:24:12 +08:00
20cb5cecc4 Renamed the accounting-selector-modal class to accounting-account-selector-modal in the account selector. 2023-02-28 22:14:03 +08:00
08dc24605d Replaced the forEach loops with the for-of loops in the JavaScript for the currency form, account form, and the drag-and-drop reorder library functions. 2023-02-28 22:09:39 +08:00
bb7e9e94ee Replaced the forEach loops with the for-of loops whenever appropriate in the JavaScript for the transaction form. 2023-02-28 22:00:19 +08:00
2680a1c872 Merged debit-account-modal.html and credit-account-modal.html into account-selector-modal.html, because they are almost the same. 2023-02-28 21:45:10 +08:00
20a7ce591c Renamed the account_selector_modals block to form_modals in the transaction form templates. 2023-02-28 21:37:08 +08:00
474e844ed9 Revised the loading of the summary helper so that only the required helpers are loaded, but not both the debit and credit helpers. 2023-02-28 21:35:02 +08:00
b34955f2fb Replaced the forEach loops with the for-of loops in the JavaScript summary helper. The for-of loops are more consistent with the other languages and the traditional for loops, and do not mess up with the "this" object. 2023-02-28 20:20:36 +08:00
2bd0f0f14d Fixed the target in the initShow method of the JavaScript summary helper. 2023-02-28 19:13:08 +08:00
8b77d9ff93 Added the suggested accounts to the summary helper. 2023-02-28 19:11:09 +08:00
a9c7360020 Renamed the variables in the #reset method of the JavaScript SummaryHelper class, for consistency. 2023-02-28 17:14:02 +08:00
d02c87602b Added validation to the summary helper. 2023-02-28 16:38:50 +08:00
9f966643b5 Added ARIA labels to the different pages in the summary helper. 2023-02-28 16:38:19 +08:00
5746e2a3d6 Added a missing amount filter to the debit entries of the transaction form. 2023-02-28 15:52:30 +08:00
d5c2231794 Added the summary helper for the transaction form. 2023-02-28 15:49:01 +08:00
fc8e257a10 Added missing documentation to the currencies_errors pseudo property of the TransactionForm form. 2023-02-28 09:36:20 +08:00
2e9bf382fb Revised the documentation of the "accounting.transaction.dispatcher" module. 2023-02-28 09:31:46 +08:00
de48c848da Revised the code in the common account shorts in testlib_txn.py. 2023-02-28 08:24:15 +08:00
9cdcc828a7 Added the add_txn function to testlib_txn.py and applied it in the transaction test cases. 2023-02-28 08:14:23 +08:00
b28d446d07 Advanced to version 0.3.1. 2023-02-28 00:16:20 +08:00
274a38a588 Fixed a localization error in the transaction detail. 2023-02-28 00:16:12 +08:00
fff89a9957 Replaced the direct database add with the relationship append in the JournalEntryCollector class, to fix the PostgreSQL error that the new journal entries are added when the transaction is not added yet. 2023-02-28 00:04:32 +08:00
5613657c8f Fixed the JavaScript filterAccountOptions function in the transaction form so that the accounting list is not hidden when there is no account in use. 2023-02-27 23:00:49 +08:00
26bb16dd40 Revised the translation. 2023-02-27 18:59:50 +08:00
f0d39bb27b Added the action button to convert a cash income or cash expense transaction to a transfer transaction. 2023-02-27 18:59:42 +08:00
4c17310ebf Fixed an error to recognize the current transaction type in the supplied URI in the with_type filter in the "accounting.transaction.template" module. 2023-02-27 18:47:19 +08:00
fd36672877 Revised the imports in the "accounting.transaction.views" module. 2023-02-27 18:44:33 +08:00
d67c57056b Added the accounting_txn_format_amount_input template filter to properly format the decimal amount for the number input fields. 2023-02-27 18:40:54 +08:00
59c55ef574 Fixed the amount display in the template of the journal entry sub-form. 2023-02-27 18:34:02 +08:00
329027969a Advanced to version 0.3.0. 2023-02-27 17:23:20 +08:00
9f7a8c9540 Revised the Sphinx documentation. 2023-02-27 17:22:56 +08:00
384bb2c46d Added the dummy commented <ul>...</ul> to the navigation menu and the journal entry sub-form templates, for SonarQube not to complain about incorrect HTML. 2023-02-27 17:20:43 +08:00
cabfe268ce Added the page_37 and page_size_15_default constants in the test_malformed test of the PaginationTestCase test case, for consistency. 2023-02-27 17:15:29 +08:00
26df71014b Added the LIST_URL and DETAIL_URI constants to test_base_account.py, for consistency. 2023-02-27 16:37:01 +08:00
3126ee8153 Added the NEXT_URI constant to test_account.py, for consistency. 2023-02-27 16:37:01 +08:00
cb622f4bad Added the __get_detail_uri function to the "accounting.currency.views" module, for simplicity. 2023-02-27 16:31:41 +08:00
515d39e61c Added the __get_detail_uri and __get_list_uri functions to the "accounting.account.views" module, for simplicity. 2023-02-27 16:29:56 +08:00
952061c4bb Added the TEST_SERVER constant in testlib.py, for consistency. 2023-02-27 16:25:36 +08:00
788225826d Added resource integrity to the decimal.js-light CDN in the test site. 2023-02-27 16:21:30 +08:00
c52081e528 Replaced decimal.js CDN from cdnjs with decimal.js-light CDN from jsDelivr in the base template of the test site. 2023-02-27 16:20:58 +08:00
1f235acdf9 Added resource integrity to the bootstrap CDN in the test site. 2023-02-27 16:07:59 +08:00
0f6c23e1f3 Replaced the regular expression replace with trimEnd() in the validateNote validator in the JavaScript for the transaction form. 2023-02-27 16:03:25 +08:00
488e72679e Revised the NextUriTestCase view, split the test_next_uri test into the two test_next_uri and test_no_next_uri tests, and replaced the decorator with add_url_rule to work around the security audit from SonarQube. 2023-02-27 15:57:39 +08:00
6d43b14862 Added CSRF to the test_next_uri test in the NextUriTestCase test case. 2023-02-27 15:35:35 +08:00
685213cdbb Revised the translation. 2023-02-27 15:29:37 +08:00
05fde3a742 Added the transaction management. 2023-02-27 15:28:45 +08:00
9383f5484f Revised aria-label in the templates, added necessary aria labels, removed excess aria labels, and added localization. 2023-02-27 12:54:41 +08:00
88314e1e45 Revised the regular expression in the find_by_code method of the Account data model. 2023-02-27 12:54:38 +08:00
83b5761bca Replaced the for loop with the for-of loop in the search-as-you-type JavaScript of the account form. 2023-02-27 10:30:21 +08:00
f25c993b75 Revised the translation of the test site. 2023-02-27 10:30:19 +08:00
6d02f8033d Revised the font awesome icon of the accounting application in the navigation menu. 2023-02-27 10:18:29 +08:00
2c367703e4 Removed a debug logging in the JavaScript for the account form. 2023-02-27 10:18:29 +08:00
284b5be128 Fixed the typo "model" to "modal" in the templates. 2023-02-27 10:18:10 +08:00
a672a13789 Revised the strip_text filter to return None when the text is empty. 2023-02-26 08:00:58 +08:00
9af9afd14d Added the height for the textarea with floating labels. 2023-02-26 07:59:25 +08:00
d98e9f8f05 Added the accounting-dragged class to replace the list-group-item-dark class when reordering with drag-and-drop, because the dragged list may not be a list group. 2023-02-26 07:54:23 +08:00
652bddc07a Fixed an error in the onDragOver function in drag-and-drop-reorder.js that sometimes the dragged object may be null. 2023-02-26 07:54:20 +08:00
5a6e4f5b5e Replaced the import for the db object from the accounting model with the test site in test_account.py and test_currency.py. They are the same object, and the db object from the test site is safe at the compile time. 2023-02-25 18:04:32 +08:00
f878ba5535 Revised to rewind the time in the test_update_not_modified tests of the AccountTestCase and CurrencyTestCase test cases, so that the test cases don't have to wait for the time to be different. 2023-02-25 18:04:29 +08:00
e7c36ba13a Revised the type hints in the test_update_not_modified tests of the AccountTestCase and CurrencyTestCase test cases. 2023-02-25 18:04:27 +08:00
4cfe7c7c59 Added the flash_all_errors utility in the "accounting.utils.flash_errors" module to recursively flush all form errors in the sub-forms. 2023-02-25 12:27:55 +08:00
b0b30a8ae6 Fixed the broken action button group in the account list and currency list, by adding a separated action button group for the mobile screens. 2023-02-25 10:37:28 +08:00
2e3633b205 Revised to sort the accounts in the same base before saving an account to a new base, and added the test_change_base_code test to the AccountTestCase test case for this. 2023-02-25 09:44:17 +08:00
d68aa91c33 Removed the redundant post_update methods from the AccountForm and CurrencyForm forms. 2023-02-24 17:18:55 +08:00
3f63fb0bda Fixed a type hint in the populate_obj method of the AccountForm form. 2023-02-24 00:18:55 +08:00
d5af5de3c1 Renamed offset to pay-off, to be clear. 2023-02-23 11:32:55 +08:00
d9c08568cf Revised the test_update_not_modified tests to be more specific in the AccountTestCase and CurrencyTestCase test cases. 2023-02-21 09:38:31 +08:00
a4c89f1494 Added the type hint and the documentation for the obj parameter of the post_update method of the AccountForm and CurrencyForm forms. 2023-02-20 16:08:49 +08:00
a73e3204b9 Renamed the "accounting.utils.next_url" module to "accounting.utils.next_uri". 2023-02-20 16:08:32 +08:00
330a71ebf2 Fixed the logic in the __set_next method in the "accounting.utils.next_url" module. 2023-02-20 08:17:31 +08:00
36b0bb3a0e Revised the import in the "accounting.account.view" module. 2023-02-18 18:40:11 +08:00
77 changed files with 9641 additions and 260 deletions

View File

@ -10,6 +10,7 @@ Subpackages
accounting.account accounting.account
accounting.base_account accounting.base_account
accounting.currency accounting.currency
accounting.transaction
accounting.utils accounting.utils
Submodules Submodules

View File

@ -0,0 +1,69 @@
accounting.transaction package
==============================
Submodules
----------
accounting.transaction.converters module
----------------------------------------
.. automodule:: accounting.transaction.converters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.dispatcher module
----------------------------------------
.. automodule:: accounting.transaction.dispatcher
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms module
-----------------------------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.query module
-----------------------------------
.. automodule:: accounting.transaction.query
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.summary\_helper module
---------------------------------------------
.. automodule:: accounting.transaction.summary_helper
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template module
--------------------------------------
.. automodule:: accounting.transaction.template
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.views module
-----------------------------------
.. automodule:: accounting.transaction.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction
:members:
:undoc-members:
:show-inheritance:

View File

@ -4,10 +4,18 @@ accounting.utils package
Submodules Submodules
---------- ----------
accounting.utils.next\_url module accounting.utils.flash\_errors module
-------------------------------------
.. automodule:: accounting.utils.flash_errors
:members:
:undoc-members:
:show-inheritance:
accounting.utils.next\_uri module
--------------------------------- ---------------------------------
.. automodule:: accounting.utils.next_url .. automodule:: accounting.utils.next_uri
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask' project = 'Mia! Accounting Flask'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '0.0.0' release = '0.4.0'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -28,5 +28,5 @@ exclude_patterns = []
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'nature' html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static'] html_static_path = ['_static']

View File

@ -17,7 +17,7 @@
[metadata] [metadata]
name = mia-accounting-flask name = mia-accounting-flask
version = 0.2.0 version = 0.4.0
author = imacat author = imacat
author_email = imacat@mail.imacat.idv.tw author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project. description = The Mia! Accounting Flask project.

View File

@ -73,7 +73,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import currency from . import currency
currency.init_app(app, bp) currency.init_app(app, bp)
from .utils import next_url from . import transaction
next_url.init_app(bp) transaction.init_app(app, bp)
from .utils import next_uri
next_uri.init_app(bp)
app.register_blueprint(bp) app.register_blueprint(bp)

View File

@ -30,7 +30,7 @@ from accounting.utils.user import has_user, get_user_pk
AccountData = tuple[int, str, int, str, str, str, bool] AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number, """The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples.""" English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option, def __validate_username(ctx: click.core.Context, param: click.core.Option,
@ -93,10 +93,10 @@ def init_accounts_command(username: str) -> None:
data: list[AccountData] = [] data: list[AccountData] = []
for base in bases_to_add: for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \ is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
else False else False
data.append((get_new_id(), base.code, 1, base.title_l10n, data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed)) l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
__add_accounting_accounts(data, creator_pk) __add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.") click.echo(F"{len(data)} added. Accounting accounts initialized.")
@ -113,7 +113,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
base_code=x[1], base_code=x[1],
no=x[2], no=x[2],
title_l10n=x[3], title_l10n=x[3],
is_offset_needed=x[6], is_pay_off_needed=x[6],
created_by_id=creator_pk, created_by_id=creator_pk,
updated_by_id=creator_pk) updated_by_id=creator_pk)
for x in data] for x in data]

View File

@ -66,8 +66,8 @@ class AccountForm(FlaskForm):
filters=[strip_text], filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))]) validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title.""" """The title."""
is_offset_needed = BooleanField() is_pay_off_needed = BooleanField()
"""Whether the the entries of this account need offsets.""" """Whether the the entries of this account need pay-off."""
def populate_obj(self, obj: Account) -> None: def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object. """Populates the form data into an account object.
@ -76,29 +76,27 @@ class AccountForm(FlaskForm):
:return: None. :return: None.
""" """
is_new: bool = obj.id is None is_new: bool = obj.id is None
prev_base_code: str | None = obj.base_code
if is_new: if is_new:
obj.id = new_id(Account) obj.id = new_id(Account)
if obj.base_code != self.base_code.data:
if obj.base_code is not None:
sort_accounts_in(obj.base_code, obj.id)
sort_accounts_in(self.base_code.data, obj.id)
count: int = Account.query\
.filter(Account.base_code == self.base_code.data).count()
obj.base_code = self.base_code.data obj.base_code = self.base_code.data
if prev_base_code != self.base_code.data: obj.no = count + 1
max_no: int = db.session.scalars(
sa.select(sa.func.max(Account.no))
.filter(Account.base_code == self.base_code.data)).one()
obj.no = 1 if max_no is None else max_no + 1
obj.title = self.title.data obj.title = self.title.data
obj.is_offset_needed = self.is_offset_needed.data obj.is_pay_off_needed = self.is_pay_off_needed.data
if is_new: if is_new:
current_user_pk: int = get_current_user_pk() current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk obj.updated_by_id = current_user_pk
if prev_base_code is not None \
and prev_base_code != self.base_code.data:
setattr(self, "__post_update",
lambda: sort_accounts_in(prev_base_code, obj.id))
def post_update(self, obj) -> None: def post_update(self, obj: Account) -> None:
"""The post-processing after the update. """The post-processing after the update.
:param obj: The account object.
:return: None :return: None
""" """
current_user_pk: int = get_current_user_pk() current_user_pk: int = get_current_user_pk()

View File

@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
Account.title_l10n.contains(k), Account.title_l10n.contains(k),
code.contains(k), code.contains(k),
Account.id.in_(l10n_matches)] Account.id.in_(l10n_matches)]
if k in gettext("Offset needed"): if k in gettext("Pay-off needed"):
sub_conditions.append(Account.is_offset_needed) sub_conditions.append(Account.is_pay_off_needed)
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\ return Account.query.filter(*conditions)\

View File

@ -19,6 +19,7 @@
""" """
from urllib.parse import parse_qsl, urlencode from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, flash, \ from flask import Blueprint, render_template, session, redirect, flash, \
url_for, request url_for, request
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
@ -26,10 +27,13 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount from accounting.models import Account, BaseAccount
from accounting.utils.next_url import inherit_next, or_next 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.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit from accounting.utils.permission import can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .query import get_account_query
bp: Blueprint = Blueprint("account", __name__) bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management.""" """The view blueprint for the account management."""
@ -42,7 +46,6 @@ def list_accounts() -> str:
:return: The account list. :return: The account list.
""" """
from .query import get_account_query
accounts: list[BaseAccount] = get_account_query() accounts: list[BaseAccount] = get_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts) pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/account/list.html", return render_template("accounting/account/list.html",
@ -76,9 +79,7 @@ def add_account() -> redirect:
""" """
form = AccountForm(request.form) form = AccountForm(request.form)
if not form.validate(): if not form.validate():
for key in form.errors: flash_form_errors(form)
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items())) session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.account.create"))) return redirect(inherit_next(url_for("accounting.account.create")))
account: Account = Account() account: Account = Account()
@ -86,8 +87,7 @@ def add_account() -> redirect:
db.session.add(account) db.session.add(account)
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is added successfully"), "success") flash(lazy_gettext("The account is added successfully"), "success")
return redirect(inherit_next(url_for("accounting.account.detail", return redirect(inherit_next(__get_detail_uri(account)))
account=account)))
@bp.get("/<account:account>", endpoint="detail") @bp.get("/<account:account>", endpoint="detail")
@ -131,9 +131,7 @@ def update_account(account: Account) -> redirect:
""" """
form = AccountForm(request.form) form = AccountForm(request.form)
if not form.validate(): if not form.validate():
for key in form.errors: flash_form_errors(form)
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items())) session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.account.edit", return redirect(inherit_next(url_for("accounting.account.edit",
account=account))) account=account)))
@ -141,13 +139,12 @@ def update_account(account: Account) -> redirect:
form.populate_obj(account) form.populate_obj(account)
if not account.is_modified: if not account.is_modified:
flash(lazy_gettext("The account was not modified."), "success") flash(lazy_gettext("The account was not modified."), "success")
return redirect(inherit_next(url_for("accounting.account.detail", return redirect(inherit_next(__get_detail_uri(account)))
account=account))) account.updated_by_id = get_current_user_pk()
form.post_update(account) account.updated_at = sa.func.now()
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is updated successfully."), "success") flash(lazy_gettext("The account is updated successfully."), "success")
return redirect(inherit_next(url_for("accounting.account.detail", return redirect(inherit_next(__get_detail_uri(account)))
account=account)))
@bp.post("/<account:account>/delete", endpoint="delete") @bp.post("/<account:account>/delete", endpoint="delete")
@ -163,7 +160,7 @@ def delete_account(account: Account) -> redirect:
sort_accounts_in(account.base_code, account.id) sort_accounts_in(account.base_code, account.id)
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success") flash(lazy_gettext("The account is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.account.list"))) return redirect(or_next(__get_list_uri()))
@bp.get("/bases/<baseAccount:base>", endpoint="order") @bp.get("/bases/<baseAccount:base>", endpoint="order")
@ -190,7 +187,24 @@ def sort_accounts(base: BaseAccount) -> redirect:
form.save_order() form.save_order()
if not form.is_modified: if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success") flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(url_for("accounting.account.list"))) return redirect(or_next(__get_list_uri()))
db.session.commit() db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success") flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(url_for("accounting.account.list"))) return redirect(or_next(__get_list_uri()))
def __get_detail_uri(account: Account) -> str:
"""Returns the detail URI of an account.
:param account: The account.
:return: The detail URI of the account.
"""
return url_for("accounting.account.detail", account=account)
def __get_list_uri() -> str:
"""Returns the account list URI.
:return: The account list URI.
"""
return url_for("accounting.account.list")

View File

@ -19,7 +19,6 @@
""" """
from __future__ import annotations from __future__ import annotations
import sqlalchemy as sa
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf from wtforms.validators import DataRequired, Regexp, NoneOf
@ -82,12 +81,3 @@ class CurrencyForm(FlaskForm):
current_user_pk: int = get_current_user_pk() current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk obj.updated_by_id = current_user_pk
def post_update(self, obj) -> None:
"""The post-processing after the update.
:return: None
"""
current_user_pk: int = get_current_user_pk()
obj.updated_by_id = current_user_pk
obj.updated_at = sa.func.now()

View File

@ -19,6 +19,7 @@
""" """
from urllib.parse import urlencode, parse_qsl from urllib.parse import urlencode, parse_qsl
import sqlalchemy as sa
from flask import Blueprint, render_template, redirect, session, request, \ from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for flash, url_for
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
@ -26,9 +27,11 @@ from werkzeug.datastructures import ImmutableMultiDict
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Currency from accounting.models import Currency
from accounting.utils.next_url import inherit_next, or_next 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.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm from .forms import CurrencyForm
bp: Blueprint = Blueprint("currency", __name__) bp: Blueprint = Blueprint("currency", __name__)
@ -78,9 +81,7 @@ def add_currency() -> redirect:
""" """
form = CurrencyForm(request.form) form = CurrencyForm(request.form)
if not form.validate(): if not form.validate():
for key in form.errors: flash_form_errors(form)
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items())) session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.create"))) return redirect(inherit_next(url_for("accounting.currency.create")))
currency: Currency = Currency() currency: Currency = Currency()
@ -88,8 +89,7 @@ def add_currency() -> redirect:
db.session.add(currency) db.session.add(currency)
db.session.commit() db.session.commit()
flash(lazy_gettext("The currency is added successfully"), "success") flash(lazy_gettext("The currency is added successfully"), "success")
return redirect(inherit_next(url_for("accounting.currency.detail", return redirect(inherit_next(__get_detail_uri(currency)))
currency=currency)))
@bp.get("/<currency:currency>", endpoint="detail") @bp.get("/<currency:currency>", endpoint="detail")
@ -134,9 +134,7 @@ def update_currency(currency: Currency) -> redirect:
form = CurrencyForm(request.form) form = CurrencyForm(request.form)
form.obj_code = currency.code form.obj_code = currency.code
if not form.validate(): if not form.validate():
for key in form.errors: flash_form_errors(form)
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items())) session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.edit", return redirect(inherit_next(url_for("accounting.currency.edit",
currency=currency))) currency=currency)))
@ -144,13 +142,12 @@ def update_currency(currency: Currency) -> redirect:
form.populate_obj(currency) form.populate_obj(currency)
if not currency.is_modified: if not currency.is_modified:
flash(lazy_gettext("The currency was not modified."), "success") flash(lazy_gettext("The currency was not modified."), "success")
return redirect(inherit_next(url_for("accounting.currency.detail", return redirect(inherit_next(__get_detail_uri(currency)))
currency=currency))) currency.updated_by_id = get_current_user_pk()
form.post_update(currency) currency.updated_at = sa.func.now()
db.session.commit() db.session.commit()
flash(lazy_gettext("The currency is updated successfully."), "success") flash(lazy_gettext("The currency is updated successfully."), "success")
return redirect(inherit_next(url_for("accounting.currency.detail", return redirect(inherit_next(__get_detail_uri(currency)))
currency=currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete") @bp.post("/<currency:currency>/delete", endpoint="delete")
@ -176,3 +173,13 @@ def exists_code() -> dict[str, bool]:
:return: Whether the currency code exists. :return: Whether the currency code exists.
""" """
return {"exists": db.session.get(Currency, request.args["q"]) is not None} return {"exists": db.session.get(Currency, request.args["q"]) is not None}
def __get_detail_uri(currency: Currency) -> str:
"""Returns the detail URI of a currency.
:param currency: The currency.
:return: The detail URI of the currency.
"""
return url_for("accounting.currency.detail", currency=currency)

View File

@ -17,8 +17,11 @@
"""The data models. """The data models.
""" """
from __future__ import annotations
import re import re
import typing as t import typing as t
from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import current_app from flask import current_app
@ -26,6 +29,7 @@ from flask_babel import get_locale
from sqlalchemy import text from sqlalchemy import text
from accounting import db from accounting import db
from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column from accounting.utils.user import user_cls, user_pk_column
@ -109,8 +113,8 @@ class Account(db.Model):
"""The account number under the base account.""" """The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n = db.Column("title", db.String, nullable=False)
"""The title.""" """The title."""
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False) is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offsets.""" """Whether the entries of this account need pay-off."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now()) server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
@ -134,6 +138,8 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account", l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False) lazy=False)
"""The localized titles.""" """The localized titles."""
entries = db.relationship("JournalEntry", back_populates="account")
"""The journal entries."""
__CASH = "1111-001" __CASH = "1111-001"
"""The code of the cash account,""" """The code of the cash account,"""
@ -204,7 +210,7 @@ class Account(db.Model):
:param code: The code. :param code: The code.
:return: The account, or None if this account does not exist. :return: The account, or None if this account does not exist.
""" """
m = re.match("^([1-9]{4})-([0-9]{3})$", code) m = re.match(r"^([1-9]{4})-(\d{3})$", code)
if m is None: if m is None:
return None return None
return cls.query.filter(cls.base_code == m.group(1), return cls.query.filter(cls.base_code == m.group(1),
@ -251,6 +257,14 @@ class Account(db.Model):
cls.base_code != "3353")\ cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
@classmethod @classmethod
def cash(cls) -> t.Self: def cash(cls) -> t.Self:
"""Returns the cash account. """Returns the cash account.
@ -370,6 +384,8 @@ class Currency(db.Model):
l10n = db.relationship("CurrencyL10n", back_populates="currency", l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False) lazy=False)
"""The localized names.""" """The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency")
"""The journal entries."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the currency. """Returns the string representation of the currency.
@ -450,3 +466,215 @@ class CurrencyL10n(db.Model):
"""The locale.""" """The locale."""
name = db.Column(db.String, nullable=False) name = db.Column(db.String, nullable=False)
"""The localized name.""" """The localized name."""
class TransactionCurrency:
"""A currency in a transaction."""
def __init__(self, code: str, debit: list[JournalEntry],
credit: list[JournalEntry]):
"""Constructs the currency in the transaction.
:param code: The currency code.
:param debit: The debit entries.
:param credit: The credit entries.
"""
self.code: str = code
"""The currency code."""
self.debit: list[JournalEntry] = debit
"""The debit entries."""
self.credit: list[JournalEntry] = credit
"""The credit entries."""
@property
def name(self) -> str:
"""Returns the currency name.
:return: The currency name.
"""
return db.session.get(Currency, self.code).name
@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 for x in self.debit])
@property
def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount for x in self.credit])
class Transaction(db.Model):
"""A transaction."""
__tablename__ = "accounting_transactions"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The transaction ID."""
date = db.Column(db.Date, nullable=False)
"""The date."""
no = db.Column(db.Integer, nullable=False, default=text("1"))
"""The account number under the date."""
note = db.Column(db.String)
"""The note."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction")
"""The journal entries."""
def __str__(self) -> str:
"""Returns the string representation of this transaction.
:return: The string representation of this transaction.
"""
if self.is_cash_expense:
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
if self.is_cash_income:
return gettext("Cash Income Transaction#%(id)s", id=self.id)
return gettext("Transfer Transaction#%(id)s", id=self.id)
@property
def currencies(self) -> list[TransactionCurrency]:
"""Returns the journal entries categorized by their currencies.
:return: The currency categories.
"""
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {}
for entry in entries:
if entry.currency_code not in by_currency:
codes.append(entry.currency_code)
by_currency[entry.currency_code] = []
by_currency[entry.currency_code].append(entry)
return [TransactionCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
for x in codes]
@property
def is_cash_income(self) -> bool:
"""Returns whether this is a cash income transaction.
:return: True if this is a cash income transaction, or False otherwise.
"""
for currency in self.currencies:
if len(currency.debit) > 1:
return False
if currency.debit[0].account.code != "1111-001":
return False
return True
@property
def is_cash_expense(self) -> bool:
"""Returns whether this is a cash expense transaction.
:return: True if this is a cash expense transaction, or False
otherwise.
"""
for currency in self.currencies:
if len(currency.credit) > 1:
return False
if currency.credit[0].account.code != "1111-001":
return False
return True
def delete(self) -> None:
"""Deletes the transaction.
:return: None.
"""
JournalEntry.query\
.filter(JournalEntry.transaction_id == self.id).delete()
db.session.delete(self)
class JournalEntry(db.Model):
"""An accounting journal entry."""
__tablename__ = "accounting_journal_entries"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The entry ID."""
transaction_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The transaction ID."""
transaction = db.relationship(Transaction, back_populates="entries")
"""The transaction."""
is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
pay_off_target_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the pay-off target entry."""
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
remote_side=id, passive_deletes=True)
"""The pay-off target entry."""
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
"""The pay-off entries."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
"""The currency code."""
currency = db.relationship(Currency, back_populates="entries")
"""The currency."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id,
onupdate="CASCADE"),
nullable=False)
"""The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False)
"""The account."""
summary = db.Column(db.String, nullable=True)
"""The summary."""
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
ID field, to work with WTForms.
:return: The journal entry ID.
"""
return self.id
@property
def account_code(self) -> str:
"""Returns the account code.
:return: The account code.
"""
return self.account.code

View File

@ -34,6 +34,13 @@
color: inherit; color: inherit;
padding-right: 0; padding-right: 0;
} }
.form-floating > textarea.form-control {
height: 6rem;
}
.accounting-dragged {
color: #141619;
background-color: #D3D3D4;
}
/** The card layout */ /** The card layout */
.accounting-card { .accounting-card {
@ -58,6 +65,50 @@
overflow-y: scroll; overflow-y: scroll;
} }
/** The transaction management */
.accounting-currency-control {
background-color: transparent;
}
.accounting-currency-content {
width: calc(100% - 3rem);
}
.accounting-entry-content {
width: calc(100% - 3rem);
background-color: transparent;
}
.accounting-entry-control {
border-color: transparent;
}
.accounting-transaction-card {
padding: 2em 1.5em;
margin: 1em;
background-color: #F8F9FA;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.accounting-transaction-card h2 {
border-bottom: thick double slategray;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
background-color: #c7dbd2;
}
.accounting-list-group-hover .list-group-item:hover {
background-color: #ececec;
}
.accounting-transaction-entry {
border: none;
}
.accounting-transaction-entry-header {
font-weight: bolder;
border-bottom: thick double slategray;
}
.list-group-item.accounting-transaction-entry-total {
font-weight: bolder;
border-top: thick double slategray;
}
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {
position: relative; position: relative;
@ -96,6 +147,36 @@
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus { .accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12); box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
} }
.accounting-btn-material-fab {
transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
}
.show .accounting-btn-material-fab {
transform: scale(1.5) rotate(-45deg);
}
.accounting-material-fab-speed-dial-group {
position: absolute;
right: -2rem;
bottom: -7rem;
text-align: right;
opacity: 0;
transform: scale(0.1);
line-height: 5.5rem;
transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
}
.show .accounting-material-fab-speed-dial-group {
opacity: 1;
transform: scale(0.6);
right: -0.5rem;
bottom: 0.7rem;
}
.accounting-material-fab-speed-dial-group .btn {
background-color: white;
white-space: nowrap;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
}
.accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
}
/* The Material Design form switch */ /* The Material Design form switch */
@media(max-width:767px) { @media(max-width:767px) {

View File

@ -38,7 +38,7 @@ document.addEventListener("DOMContentLoaded", function () {
* @private * @private
*/ */
function initializeBaseAccountSelector() { function initializeBaseAccountSelector() {
const selector = document.getElementById("accounting-base-selector-model"); const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base"); const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code"); const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content"); const baseContent = document.getElementById("accounting-base-content");
@ -46,9 +46,9 @@ function initializeBaseAccountSelector() {
const btnClear = document.getElementById("accounting-btn-clear-base"); const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () { selector.addEventListener("show.bs.modal", function () {
base.classList.add("accounting-not-empty"); base.classList.add("accounting-not-empty");
options.forEach(function (item) { for (const option of options) {
item.classList.remove("active"); option.classList.remove("active");
}); }
const selected = document.getElementById("accounting-base-option-" + baseCode.value); const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) { if (selected !== null) {
selected.classList.add("active"); selected.classList.add("active");
@ -59,7 +59,7 @@ function initializeBaseAccountSelector() {
base.classList.remove("accounting-not-empty"); base.classList.remove("accounting-not-empty");
} }
}); });
options.forEach(function (option) { for (const option of options) {
option.onclick = function () { option.onclick = function () {
baseCode.value = option.dataset.code; baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content; baseContent.innerText = option.dataset.content;
@ -69,7 +69,7 @@ function initializeBaseAccountSelector() {
validateBase(); validateBase();
bootstrap.Modal.getInstance(selector).hide(); bootstrap.Modal.getInstance(selector).hide();
}; };
}); }
btnClear.onclick = function () { btnClear.onclick = function () {
baseCode.value = ""; baseCode.value = "";
baseContent.innerText = ""; baseContent.innerText = "";
@ -93,21 +93,20 @@ function initializeBaseAccountQuery() {
const options = Array.from(document.getElementsByClassName("accounting-base-option")); const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const queryNoResult = document.getElementById("accounting-base-option-no-result"); const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () { query.addEventListener("input", function () {
console.log(query.value);
if (query.value === "") { if (query.value === "") {
options.forEach(function (option) { for (const option of options) {
option.classList.remove("d-none"); option.classList.remove("d-none");
}); }
optionList.classList.remove("d-none"); optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none"); queryNoResult.classList.add("d-none");
return return
} }
let hasAnyMatched = false; let hasAnyMatched = false;
options.forEach(function (option) { for (const option of options) {
const queryValues = JSON.parse(option.dataset.queryValues); const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false; let isMatched = false;
for (let i = 0; i < queryValues.length; i++) { for (const queryValue of queryValues) {
if (queryValues[i].includes(query.value)) { if (queryValue.includes(query.value)) {
isMatched = true; isMatched = true;
break; break;
} }
@ -118,7 +117,7 @@ function initializeBaseAccountQuery() {
} else { } else {
option.classList.add("d-none"); option.classList.add("d-none");
} }
}); }
if (!hasAnyMatched) { if (!hasAnyMatched) {
optionList.classList.add("d-none"); optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none"); queryNoResult.classList.remove("d-none");

View File

@ -0,0 +1,254 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
AccountSelector.initialize();
});
/**
* The account selector.
*
*/
class AccountSelector {
/**
* The entry type
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* Constructs an account selector.
*
* @param modal {HTMLFormElement} the account selector modal
*/
constructor(modal) {
this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#init();
}
/**
* Initializes the account selector.
*
*/
#init() {
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const selector1 = this
more.onclick = function () {
more.classList.add("d-none");
selector1.#filterAccountOptions();
};
this.#initializeAccountQuery();
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
for (const option of options) {
option.onclick = function () {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
}
}
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
const helper = this;
query.addEventListener("input", function () {
helper.#filterAccountOptions();
});
}
/**
* Filters the account options.
*
*/
#filterAccountOptions() {
const query = document.getElementById(this.#prefix + "-query");
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
let shouldAnyShow = false;
for (const option of options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) {
option.classList.remove("d-none");
shouldAnyShow = true;
} else {
option.classList.add("d-none");
}
}
if (!shouldAnyShow && more.classList.contains("d-none")) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
*/
#getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) {
inUse.push(accountCode.value);
}
return inUse
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
*/
#shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Initializes the account selector when it is shown.
*
*/
initShow() {
const formAccount = document.getElementById("accounting-entry-form-account");
const query = document.getElementById(this.#prefix + "-query")
const more = document.getElementById(this.#prefix + "-more");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
}
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
}
/**
* The account selectors.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
*
*/
static initialize() {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) {
const selector = new AccountSelector(modal);
this.#selectors[selector.#entryType] = selector;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const selectors = this.#selectors;
formAccountControl.onclick = function () {
selectors[entryForm.dataset.entryType].initShow();
};
}
/**
* Initializes the account selector for the journal entry form.
*x
*/
static initializeJournalEntryForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
}
}

View File

@ -65,9 +65,9 @@ function validateForm() {
*/ */
function submitFormIfAllAsyncValid() { function submitFormIfAllAsyncValid() {
let isValid = true; let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) { for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid; isValid = isAsyncValid[key] && isValid;
}); }
if (isValid) { if (isValid) {
document.getElementById("accounting-form").submit() document.getElementById("accounting-form").submit()
} }

View File

@ -42,21 +42,21 @@ function initializeDragAndDropReordering(list, onReorder) {
function initializeMouseDragAndDropReordering(list, onReorder) { function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children); const items = Array.from(list.children);
let dragged = null; let dragged = null;
items.forEach(function (item) { for (const item of items) {
item.draggable = true; item.draggable = true;
item.addEventListener("dragstart", function () { item.addEventListener("dragstart", function () {
dragged = item; dragged = item;
dragged.classList.add("list-group-item-dark"); dragged.classList.add("accounting-dragged");
}); });
item.addEventListener("dragover", function () { item.addEventListener("dragover", function () {
onDragOver(dragged, item); onDragOver(dragged, item);
onReorder(); onReorder();
}); });
item.addEventListener("dragend", function () { item.addEventListener("dragend", function () {
dragged.classList.remove("list-group-item-dark"); dragged.classList.remove("accounting-dragged");
dragged = null; dragged = null;
}); });
}); }
} }
/** /**
@ -68,9 +68,9 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
*/ */
function initializeTouchDragAndDropReordering(list, onReorder) { function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children); const items = Array.from(list.children);
items.forEach(function (item) { for (const item of items) {
item.addEventListener("touchstart", function () { item.addEventListener("touchstart", function () {
item.classList.add("list-group-item-dark"); item.classList.add("accounting-dragged");
}); });
item.addEventListener("touchmove", function (event) { item.addEventListener("touchmove", function (event) {
const touch = event.targetTouches[0]; const touch = event.targetTouches[0];
@ -79,9 +79,9 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
onReorder(); onReorder();
}); });
item.addEventListener("touchend", function () { item.addEventListener("touchend", function () {
item.classList.remove("list-group-item-dark"); item.classList.remove("accounting-dragged");
});
}); });
}
} }
/** /**
@ -91,7 +91,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
* @param target {Element} the other item that was dragged over * @param target {Element} the other item that was dragged over
*/ */
function onDragOver(dragged, target) { function onDragOver(dragged, target) {
if (target.parentElement !== dragged.parentElement || target === dragged) { if (dragged === null || target.parentElement !== dragged.parentElement || target === dragged) {
return; return;
} }
let isBefore = false; let isBefore = false;

View File

@ -0,0 +1,44 @@
/* The Mia! Accounting Flask Project
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeMaterialFabSpeedDial();
});
/**
* Initializes the speed dial of the material floating buttons.
*
* @private
*/
function initializeMaterialFabSpeedDial() {
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
const fab = document.getElementById(btnFab.dataset.target);
btnFab.onclick = function () {
if (fab.classList.contains("show")) {
fab.classList.remove("show");
} else {
fab.classList.add("show");
}
}
}

View File

@ -0,0 +1,827 @@
/* The Mia! Accounting Flask Project
* summary-helper.js: The JavaScript for the summary helper
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/28
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
SummaryHelper.initialize();
});
/**
* A summary helper.
*
*/
class SummaryHelper {
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix;
/**
* The default tab ID
* @type {string}
*/
#defaultTabId;
/**
* Constructs a summary helper.
*
* @param form {HTMLFormElement} the summary helper form
*/
constructor(form) {
this.#entryType = form.dataset.entryType;
this.#prefix = "accounting-summary-helper-" + form.dataset.entryType;
this.#defaultTabId = form.dataset.defaultTabId;
this.#init();
}
/**
* Initializes the summary helper.
*
*/
#init() {
const helper = this;
const summary = document.getElementById(this.#prefix + "-summary");
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
for (const tab of tabs) {
tab.onclick = function () {
helper.#switchToTab(tab.dataset.tabId);
}
}
this.#initializeGeneralTagHelper();
this.#initializeGeneralTripHelper();
this.#initializeBusTripHelper();
this.#initializeNumberHelper();
this.#initializeSuggestedAccounts();
this.#initializeSubmission();
summary.onchange = function () {
summary.value = summary.value.trim();
helper.#parseAndPopulate();
};
}
/**
* Switches to a tab.
*
* @param tabId {string} the tab ID.
*/
#switchToTab(tabId) {
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page"));
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
for (const tab of tabs) {
if (tab.dataset.tabId === tabId) {
tab.classList.add("active");
tab.ariaCurrent = "page";
} else {
tab.classList.remove("active");
tab.ariaCurrent = "false";
}
}
for (const page of pages) {
if (page.dataset.tabId === tabId) {
page.classList.remove("d-none");
page.ariaCurrent = "page";
} else {
page.classList.add("d-none");
page.ariaCurrent = "false";
}
}
let selectedBtnTag = null;
for (const tagButton of tagButtons) {
if (tagButton.classList.contains("btn-primary")) {
selectedBtnTag = tagButton;
break;
}
}
this.#filterSuggestedAccounts(selectedBtnTag);
}
/**
* Initializes the general tag helper.
*
*/
#initializeGeneralTagHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-general-tag");
const helper = this;
const updateSummary = function () {
const pos = summary.value.indexOf("—");
const prefix = tag.value === ""? "": tag.value + "—";
if (pos === -1) {
summary.value = prefix + summary.value;
} else {
summary.value = prefix + summary.value.substring(pos + 1);
}
}
this.#initializeTagButtons("general", tag, updateSummary);
tag.onchange = function () {
helper.#onTagInputChange("general", tag, updateSummary);
};
}
/**
* Initializes the general trip helper.
*
*/
#initializeGeneralTripHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-travel-tag");
const from = document.getElementById(this.#prefix + "-travel-from");
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"))
const to = document.getElementById(this.#prefix + "-travel-to");
const helper = this;
const updateSummary = function () {
let direction;
for (const directionButton of directionButtons) {
if (directionButton.classList.contains("btn-primary")) {
direction = directionButton.dataset.arrow;
break;
}
}
summary.value = tag.value + "—" + from.value + direction + to.value;
};
this.#initializeTagButtons("travel", tag, updateSummary);
tag.onchange = function () {
helper.#onTagInputChange("travel", tag, updateSummary);
helper.#validateGeneralTripTag();
};
from.onchange = function () {
updateSummary();
helper.#validateGeneralTripFrom();
};
for (const directionButton of directionButtons) {
directionButton.onclick = function () {
for (const otherButton of directionButtons) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
}
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
updateSummary();
};
}
to.onchange = function () {
updateSummary();
helper.#validateGeneralTripTo();
};
}
/**
* Initializes the bus trip helper.
*
*/
#initializeBusTripHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const tag = document.getElementById(this.#prefix + "-bus-tag");
const route = document.getElementById(this.#prefix + "-bus-route");
const from = document.getElementById(this.#prefix + "-bus-from");
const to = document.getElementById(this.#prefix + "-bus-to");
const helper = this;
const updateSummary = function () {
summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value;
};
this.#initializeTagButtons("bus", tag, updateSummary);
tag.onchange = function () {
helper.#onTagInputChange("bus", tag, updateSummary);
helper.#validateBusTripTag();
};
route.onchange = function () {
updateSummary();
helper.#validateBusTripRoute();
};
from.onchange = function () {
updateSummary();
helper.#validateBusTripFrom();
};
to.onchange = function () {
updateSummary();
helper.#validateBusTripTo();
};
}
/**
* Initializes the tag buttons.
*
* @param tabId {string} the tab ID
* @param tag {HTMLInputElement} the tag input
* @param updateSummary {function(): void} the callback to update the summary
*/
#initializeTagButtons(tabId, tag, updateSummary) {
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
const helper = this;
for (const tagButton of tagButtons) {
tagButton.onclick = function () {
for (const otherButton of tagButtons) {
otherButton.classList.remove("btn-primary");
otherButton.classList.add("btn-outline-primary");
}
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
tag.value = tagButton.dataset.value;
helper.#filterSuggestedAccounts(tagButton);
updateSummary();
};
}
}
/**
* The callback when the tag input is changed.
*
* @param tabId {string} the tab ID
* @param tag {HTMLInputElement} the tag input
* @param updateSummary {function(): void} the callback to update the summary
*/
#onTagInputChange(tabId, tag, updateSummary) {
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-" + tabId + "-btn-tag"));
let isMatched = false;
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tag.value) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
isMatched = true;
} else {
tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary");
}
}
if (!isMatched) {
this.#filterSuggestedAccounts(null);
}
updateSummary();
}
/**
* Filters the suggested accounts.
*
* @param tagButton {HTMLButtonElement|null} the tag button
*/
#filterSuggestedAccounts(tagButton) {
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
if (tagButton === null) {
for (const accountButton of accountButtons) {
accountButton.classList.add("d-none");
accountButton.classList.remove("btn-primary");
accountButton.classList.add("btn-outline-primary");
this.#selectAccount(null);
}
return;
}
const suggested = JSON.parse(tagButton.dataset.accounts);
for (const accountButton of accountButtons) {
if (suggested.includes(accountButton.dataset.code)) {
accountButton.classList.remove("d-none");
} else {
accountButton.classList.add("d-none");
}
this.#selectAccount(suggested[0]);
}
}
/**
* Initializes the number helper.
*
*/
#initializeNumberHelper() {
const summary = document.getElementById(this.#prefix + "-summary");
const number = document.getElementById(this.#prefix + "-number");
number.onchange = function () {
const found = summary.value.match(/^(.+)×(\d+)$/);
if (found !== null) {
summary.value = found[1];
}
if (number.value > 1) {
summary.value = summary.value + "×" + String(number.value);
}
};
}
/**
* Initializes the suggested accounts.
*
*/
#initializeSuggestedAccounts() {
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
const helper = this;
for (const accountButton of accountButtons) {
accountButton.onclick = function () {
helper.#selectAccount(accountButton.dataset.code);
};
}
}
/**
* Select a suggested account.
*
* @param selectedCode {string|null} the account code, or null to deselect the account
*/
#selectAccount(selectedCode) {
const form = document.getElementById(this.#prefix);
if (selectedCode === null) {
form.dataset.selectedAccountCode = "";
form.dataset.selectedAccountText = "";
return;
}
const accountButtons = Array.from(document.getElementsByClassName(this.#prefix + "-account"));
for (const accountButton of accountButtons) {
if (accountButton.dataset.code === selectedCode) {
accountButton.classList.remove("btn-outline-primary");
accountButton.classList.add("btn-primary");
form.dataset.selectedAccountCode = accountButton.dataset.code;
form.dataset.selectedAccountText = accountButton.dataset.text;
} else {
accountButton.classList.remove("btn-primary");
accountButton.classList.add("btn-outline-primary");
}
}
}
/**
* Initializes the summary submission
*
*/
#initializeSubmission() {
const form = document.getElementById(this.#prefix);
const helper = this;
form.onsubmit = function () {
if (helper.#validate()) {
helper.#submit();
}
return false;
};
}
/**
* Validates the form.
*
* @return {boolean} true if valid, or false otherwise
*/
#validate() {
const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab"));
let isValid = true;
for (const tab of tabs) {
if (tab.classList.contains("active")) {
switch (tab.dataset.tabId) {
case "general":
isValid = this.#validateGeneralTag() && isValid;
break;
case "travel":
isValid = this.#validateGeneralTrip() && isValid;
break;
case "bus":
isValid = this.#validateBusTrip() && isValid;
break;
}
}
}
return isValid;
}
/**
* Validates a general tag.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTag() {
const field = document.getElementById(this.#prefix + "-general-tag");
const error = document.getElementById(this.#prefix + "-general-tag-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTrip() {
let isValid = true;
isValid = this.#validateGeneralTripTag() && isValid;
isValid = this.#validateGeneralTripFrom() && isValid;
isValid = this.#validateGeneralTripTo() && isValid;
return isValid;
}
/**
* Validates the tag of a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTripTag() {
const field = document.getElementById(this.#prefix + "-travel-tag");
const error = document.getElementById(this.#prefix + "-travel-tag-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the tag.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the origin of a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTripFrom() {
const field = document.getElementById(this.#prefix + "-travel-from");
const error = document.getElementById(this.#prefix + "-travel-from-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the origin.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the destination of a general trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateGeneralTripTo() {
const field = document.getElementById(this.#prefix + "-travel-to");
const error = document.getElementById(this.#prefix + "-travel-to-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the destination.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTrip() {
let isValid = true;
isValid = this.#validateBusTripTag() && isValid;
isValid = this.#validateBusTripRoute() && isValid;
isValid = this.#validateBusTripFrom() && isValid;
isValid = this.#validateBusTripTo() && isValid;
return isValid;
}
/**
* Validates the tag of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripTag() {
const field = document.getElementById(this.#prefix + "-bus-tag");
const error = document.getElementById(this.#prefix + "-bus-tag-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the tag.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the route of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripRoute() {
const field = document.getElementById(this.#prefix + "-bus-route");
const error = document.getElementById(this.#prefix + "-bus-route-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the route.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the origin of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripFrom() {
const field = document.getElementById(this.#prefix + "-bus-from");
const error = document.getElementById(this.#prefix + "-bus-from-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the origin.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the destination of a bus trip.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateBusTripTo() {
const field = document.getElementById(this.#prefix + "-bus-to");
const error = document.getElementById(this.#prefix + "-bus-to-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the destination.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Submits the summary.
*
*/
#submit() {
const form = document.getElementById(this.#prefix);
const summary = document.getElementById(this.#prefix + "-summary");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const helperModal = document.getElementById(this.#prefix + "-modal");
const entryModal = document.getElementById("accounting-entry-form-modal");
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
if (form.dataset.selectedAccountCode !== "") {
formAccountControl.classList.add("accounting-not-empty");
formAccount.dataset.code = form.dataset.selectedAccountCode;
formAccount.dataset.text = form.dataset.selectedAccountText;
formAccount.innerText = form.dataset.selectedAccountText;
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
bootstrap.Modal.getInstance(helperModal).hide();
bootstrap.Modal.getOrCreateInstance(entryModal).show();
}
/**
* Initializes the summary helper when it is shown.
*
* @param isNew {boolean} true for adding a new journal entry, or false otherwise
*/
initShow(isNew) {
const formSummary = document.getElementById("accounting-entry-form-summary");
const summary = document.getElementById(this.#prefix + "-summary");
const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close"));
for (const closeButton of closeButtons) {
if (isNew) {
closeButton.dataset.bsTarget = "#" + this.#prefix + "-modal";
} else {
closeButton.dataset.bsTarget = "#accounting-entry-form-modal";
}
}
this.#reset();
if (!isNew) {
summary.value = formSummary.dataset.value;
this.#parseAndPopulate();
}
}
/**
* Resets the summary helper.
*
*/
#reset() {
const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input"));
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag"));
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
for (const input of inputs) {
input.value = "";
input.classList.remove("is-invalid");
}
for (const tagButton of tagButtons) {
tagButton.classList.remove("btn-primary");
tagButton.classList.add("btn-outline-primary");
}
for (const directionButton of directionButtons) {
if (directionButton.classList.contains("accounting-default")) {
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
} else {
directionButton.classList.add("btn-outline-primary");
directionButton.classList.remove("btn-primary");
}
}
this.#filterSuggestedAccounts(null);
this.#switchToTab(this.#defaultTabId);
}
/**
* Parses the summary input and populates the summary helper.
*
*/
#parseAndPopulate() {
const summary = document.getElementById(this.#prefix + "-summary");
const pos = summary.value.indexOf("—");
if (pos === -1) {
return;
}
let found;
found = summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:×(\d+))?$/);
if (found !== null) {
return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]);
}
found = summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:×(\d+))?$/);
if (found !== null) {
return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]);
}
found = summary.value.match(/^([^—]+)—.+?(?:×(\d+)?)?$/);
if (found !== null) {
return this.#populateGeneralTag(found[1], found[2]);
}
}
/**
* Populates a bus trip.
*
* @param tagName {string} the tag name
* @param routeName {string} the route name or route number
* @param fromName {string} the name of the origin
* @param toName {string} the name of the destination
* @param numberStr {string|undefined} the number of items, if any
*/
#populateBusTrip(tagName, routeName, fromName, toName, numberStr) {
const tag = document.getElementById(this.#prefix + "-bus-tag");
const route = document.getElementById(this.#prefix + "-bus-route");
const from = document.getElementById(this.#prefix + "-bus-from");
const to = document.getElementById(this.#prefix + "-bus-to");
const number = document.getElementById(this.#prefix + "-number");
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag"));
tag.value = tagName;
route.value = routeName;
from.value = fromName;
to.value = toName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tagName) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
}
}
this.#switchToTab("bus");
}
/**
* Populates a general trip.
*
* @param tagName {string} the tag name
* @param fromName {string} the name of the origin
* @param direction {string} the direction arrow, either "→" or "↔"
* @param toName {string} the name of the destination
* @param numberStr {string|undefined} the number of items, if any
*/
#populateGeneralTrip(tagName, fromName, direction, toName, numberStr) {
const tag = document.getElementById(this.#prefix + "-travel-tag");
const from = document.getElementById(this.#prefix + "-travel-from");
const to = document.getElementById(this.#prefix + "-travel-to");
const number = document.getElementById(this.#prefix + "-number");
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag"));
const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction"));
tag.value = tagName;
from.value = fromName;
for (const directionButton of directionButtons) {
if (directionButton.dataset.arrow === direction) {
directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary");
} else {
directionButton.classList.add("btn-outline-primary");
directionButton.classList.remove("btn-primary");
}
}
to.value = toName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tagName) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
}
}
this.#switchToTab("travel");
}
/**
* Populates a general tag.
*
* @param tagName {string} the tag name
* @param numberStr {string|undefined} the number of items, if any
*/
#populateGeneralTag(tagName, numberStr) {
const tag = document.getElementById(this.#prefix + "-general-tag");
const number = document.getElementById(this.#prefix + "-number");
const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag"));
tag.value = tagName;
if (numberStr !== undefined) {
number.value = parseInt(numberStr);
}
for (const tagButton of tagButtons) {
if (tagButton.dataset.value === tagName) {
tagButton.classList.remove("btn-outline-primary");
tagButton.classList.add("btn-primary");
this.#filterSuggestedAccounts(tagButton);
}
}
this.#switchToTab("general");
}
/**
* The summary helpers.
* @type {{debit: SummaryHelper, credit: SummaryHelper}}
*/
static #helpers = {}
/**
* Initializes the summary helpers.
*
*/
static initialize() {
const forms = Array.from(document.getElementsByClassName("accounting-summary-helper"));
for (const form of forms) {
const helper = new SummaryHelper(form);
this.#helpers[helper.#entryType] = helper;
}
this.#initializeTransactionForm();
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const helpers = this.#helpers;
formSummaryControl.onclick = function () {
helpers[entryForm.dataset.entryType].initShow(false);
};
}
/**
* Initializes the summary helper for a new journal entry.
*
* @param entryType {string} the entry type, either "debit" or "credit"
*/
static initializeNewJournalEntry(entryType) {
this.#helpers[entryType].initShow(true);
}
}

View File

@ -0,0 +1,679 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/25
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeCurrencyForms();
initializeJournalEntries();
initializeFormValidation();
});
/**
* Escapes the HTML special characters and returns.
*
* @param s {string} the original string
* @returns {string} the string with HTML special character escaped
* @private
*/
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
/**
* Formats a Decimal number.
*
* @param number {Decimal} the Decimal number
* @returns {string} the formatted Decimal number
*/
function formatDecimal(number) {
if (number.equals(new Decimal("0"))) {
return "-";
}
const frac = number.modulo(1);
const whole = Number(number.minus(frac)).toLocaleString();
return whole + String(frac).substring(1);
}
/**
* Initializes the currency forms.
*
* @private
*/
function initializeCurrencyForms() {
const form = document.getElementById("accounting-form");
const btnNew = document.getElementById("accounting-btn-new-currency");
const currencyList = document.getElementById("accounting-currency-list");
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
const onReorder = function () {
const currencies = Array.from(currencyList.children);
for (let i = 0; i < currencies.length; i++) {
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
};
btnNew.onclick = function () {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let maxIndex = 0;
for (const currency of currencies) {
const index = parseInt(currency.dataset.index);
if (maxIndex < index) {
maxIndex = index;
}
}
const newIndex = String(maxIndex + 1);
const html = form.dataset.currencyTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
currencyList.insertAdjacentHTML("beforeend", html);
const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
newEntryButtons.forEach(initializeNewEntryButton);
initializeBtnDeleteCurrency(btnDelete);
resetDeleteCurrencyButtons();
initializeDragAndDropReordering(currencyList, onReorder);
};
deleteButtons.forEach(initializeBtnDeleteCurrency);
initializeDragAndDropReordering(currencyList, onReorder);
}
/**
* Initializes the button to delete a currency.
*
* @param button {HTMLButtonElement} the button to delete a currency.
* @private
*/
function initializeBtnDeleteCurrency(button) {
const target = document.getElementById(button.dataset.target);
button.onclick = function () {
target.parentElement.removeChild(target);
resetDeleteCurrencyButtons();
};
}
/**
* Resets the status of the delete currency buttons.
*
* @private
*/
function resetDeleteCurrencyButtons() {
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
if (buttons.length > 1) {
for (const button of buttons) {
button.classList.remove("d-none");
}
} else {
buttons[0].classList.add("d-none");
}
}
/**
* Initializes the journal entry forms.
*
* @private
*/
function initializeJournalEntries() {
const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
const entries = Array.from(document.getElementsByClassName("accounting-entry"))
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
newButtons.forEach(initializeNewEntryButton);
entryLists.forEach(initializeJournalEntryListReorder);
entries.forEach(initializeJournalEntry);
deleteButtons.forEach(initializeDeleteJournalEntryButton);
initializeJournalEntryFormModal();
}
/**
* Initializes the button to add a new journal entry.
*
* @param button {HTMLButtonElement} the button to add a new journal entry
*/
function initializeNewEntryButton(button) {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formAccountError = document.getElementById("accounting-entry-form-account-error")
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
const formAmount = document.getElementById("accounting-entry-form-amount");
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
button.onclick = function () {
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
entryForm.dataset.entryType = button.dataset.entryType;
entryForm.dataset.entryIndex = button.dataset.entryIndex;
formAccountControl.classList.remove("accounting-not-empty");
formAccountControl.classList.remove("is-invalid");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
formAccountError.innerText = "";
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal";
formSummaryControl.classList.remove("accounting-not-empty");
formSummaryControl.classList.remove("is-invalid");
formSummary.dataset.value = "";
formSummary.innerText = ""
formSummaryError.innerText = ""
formAmount.value = "";
formAmount.classList.remove("is-invalid");
formAmountError.innerText = "";
AccountSelector.initializeJournalEntryForm();
SummaryHelper.initializeNewJournalEntry(button.dataset.entryType);
};
}
/**
* Initializes the reordering of a journal entry list.
*
* @param entryList {HTMLUListElement} the journal entry list.
*/
function initializeJournalEntryListReorder(entryList) {
initializeDragAndDropReordering(entryList, function () {
const entries = Array.from(entryList.children);
for (let i = 0; i < entries.length; i++) {
const no = document.getElementById(entries[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
});
}
/**
* Initializes the journal entry.
*
* @param entry {HTMLLIElement} the journal entry.
*/
function initializeJournalEntry(entry) {
const entryForm = document.getElementById("accounting-entry-form");
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const summary = document.getElementById(entry.dataset.prefix + "-summary");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
const control = document.getElementById(entry.dataset.prefix + "-control");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
control.onclick = function () {
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
entryForm.dataset.entryType = entry.dataset.entryType;
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
if (accountCode.value === "") {
formAccountControl.classList.remove("accounting-not-empty");
} else {
formAccountControl.classList.add("accounting-not-empty");
}
formAccount.innerText = accountCode.dataset.text;
formAccount.dataset.code = accountCode.value;
formAccount.dataset.text = accountCode.dataset.text;
formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + entry.dataset.entryType + "-modal";
if (summary.value === "") {
formSummaryControl.classList.remove("accounting-not-empty");
} else {
formSummaryControl.classList.add("accounting-not-empty");
}
formSummary.dataset.value = summary.value;
formSummary.innerText = summary.value;
formAmount.value = amount.value;
AccountSelector.initializeJournalEntryForm();
validateJournalEntryForm();
};
}
/**
* Initializes the journal entry form modal.
*
* @private
*/
function initializeJournalEntryFormModal() {
const entryForm = document.getElementById("accounting-entry-form");
const formAmount = document.getElementById("accounting-entry-form-amount");
const modal = document.getElementById("accounting-entry-form-modal");
formAmount.onchange = validateJournalEntryAmount;
entryForm.onsubmit = function () {
if (validateJournalEntryForm()) {
saveJournalEntryForm();
bootstrap.Modal.getInstance(modal).hide();
}
return false;
}
}
/**
* Validates the journal entry form modal.
*
* @return {boolean} true if the form is valid, or false otherwise.
* @private
*/
function validateJournalEntryForm() {
let isValid = true;
isValid = validateJournalEntryAccount() && isValid;
isValid = validateJournalEntrySummary() && isValid;
isValid = validateJournalEntryAmount() && isValid
return isValid;
}
/**
* Validates the account in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
*/
function validateJournalEntryAccount() {
const field = document.getElementById("accounting-entry-form-account");
const error = document.getElementById("accounting-entry-form-account-error");
const control = document.getElementById("accounting-entry-form-account-control");
if (field.dataset.code === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please select the account.");
return false;
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the summary in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntrySummary() {
const control = document.getElementById("accounting-entry-form-summary-control");
const error = document.getElementById("accounting-entry-form-summary-error");
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the amount in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAmount() {
const field = document.getElementById("accounting-entry-form-amount");
const error = document.getElementById("accounting-entry-form-amount-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the amount.");
return false;
}
error.innerText = "";
return true;
}
/**
* Saves the journal entry form modal to the form.
*
* @private
*/
function saveJournalEntryForm() {
const form = document.getElementById("accounting-form");
const entryForm = document.getElementById("accounting-entry-form");
const formAccount = document.getElementById("accounting-entry-form-account");
const formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
const currencyIndex = entryForm.dataset.currencyIndex;
const entryType = entryForm.dataset.entryType;
let entryIndex;
if (entryForm.dataset.entryIndex === "new") {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
let maxIndex = 0;
for (const entry of entries) {
const index = parseInt(entry.dataset.entryIndex);
if (maxIndex < index) {
maxIndex = index;
}
}
entryIndex = String(maxIndex + 1);
const html = form.dataset.entryTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
.replaceAll("ENTRY_TYPE", escapeHtml(entryType))
.replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
entryList.insertAdjacentHTML("beforeend", html);
initializeJournalEntryListReorder(entryList);
} else {
entryIndex = entryForm.dataset.entryIndex;
}
const currency = document.getElementById("accounting-currency-" + currencyIndex);
const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
const summary = document.getElementById(entry.dataset.prefix + "-summary");
const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
accountCode.value = formAccount.dataset.code;
accountCode.dataset.text = formAccount.dataset.text;
accountText.innerText = formAccount.dataset.text;
summary.value = formSummary.dataset.value;
summaryText.innerText = formSummary.dataset.value;
amount.value = formAmount.value;
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
if (entryForm.dataset.entryIndex === "new") {
const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
initializeJournalEntry(entry);
initializeDeleteJournalEntryButton(btnDelete);
resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
}
updateBalance(currencyIndex, entryType);
validateJournalEntriesReal(currencyIndex, entryType);
validateBalance(currency);
}
/**
* Initializes the button to delete a journal entry.
*
* @param button {HTMLButtonElement} the button to delete a journal entry
*/
function initializeDeleteJournalEntryButton(button) {
const target = document.getElementById(button.dataset.target);
const currencyIndex = target.dataset.currencyIndex;
const entryType = target.dataset.entryType;
const currency = document.getElementById("accounting-currency-" + currencyIndex);
button.onclick = function () {
target.parentElement.removeChild(target);
resetDeleteJournalEntryButtons(button.dataset.sameClass);
updateBalance(currencyIndex, entryType);
validateJournalEntriesReal(currencyIndex, entryType);
validateBalance(currency);
};
}
/**
* Resets the status of the delete journal entry buttons.
*
* @param sameClass {string} the class of the buttons
* @private
*/
function resetDeleteJournalEntryButtons(sameClass) {
const buttons = Array.from(document.getElementsByClassName(sameClass));
if (buttons.length > 1) {
for (const button of buttons) {
button.classList.remove("d-none");
}
} else {
buttons[0].classList.add("d-none");
}
}
/**
* Updates the balance.
*
* @param currencyIndex {string} the currency index.
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @private
*/
function updateBalance(currencyIndex, entryType) {
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
const totalText = document.getElementById(prefix + "-total");
let total = new Decimal("0");
for (const amount of amounts) {
if (amount.value !== "") {
total = total.plus(new Decimal(amount.value));
}
}
totalText.innerText = formatDecimal(total);
}
/**
* Initializes the form validation.
*
* @private
*/
function initializeFormValidation() {
const date = document.getElementById("accounting-date");
const note = document.getElementById("accounting-note");
const form = document.getElementById("accounting-form");
date.onchange = validateDate;
note.onchange = validateNote;
form.onsubmit = validateForm;
}
/**
* Validates the form.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
let isValid = true;
isValid = validateDate() && isValid;
isValid = validateCurrencies() && isValid;
isValid = validateNote() && isValid;
return isValid;
}
/**
* Validates the date.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateDate() {
const field = document.getElementById("accounting-date");
const error = document.getElementById("accounting-date-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the date.");
return false;
}
error.innerText = "";
return true;
}
/**
* Validates the currency sub-forms.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrencies() {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let isValid = true;
isValid = validateCurrenciesReal() && isValid;
for (const currency of currencies) {
isValid = validateCurrency(currency) && isValid;
}
return isValid;
}
/**
* Validates the currency sub-forms, the validator itself.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrenciesReal() {
const field = document.getElementById("accounting-currencies");
const error = document.getElementById("accounting-currencies-error");
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
if (currencies.length === 0) {
field.classList.add("is-invalid");
error.innerText = A_("Please add some currencies.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a currency sub-form.
*
* @param currency {HTMLDivElement} the currency sub-form
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrency(currency) {
const prefix = "accounting-currency-" + currency.dataset.index;
const debit = document.getElementById(prefix + "-debit");
const credit = document.getElementById(prefix + "-credit");
let isValid = true;
if (debit !== null) {
isValid = validateJournalEntries(currency, "debit") && isValid;
}
if (credit !== null) {
isValid = validateJournalEntries(currency, "credit") && isValid;
}
if (debit !== null && credit !== null) {
isValid = validateBalance(currency) && isValid;
}
return isValid;
}
/**
* Validates the journal entries in a currency sub-form.
*
* @param currency {HTMLDivElement} the currency
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntries(currency, entryType) {
const currencyIndex = currency.dataset.index;
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
let isValid = true;
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
for (const entry of entries) {
isValid = validateJournalEntry(entry) && isValid;
}
return isValid;
}
/**
* Validates the journal entries, the validator itself.
*
* @param currencyIndex {string} the currency index
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntriesReal(currencyIndex, entryType) {
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
const field = document.getElementById(prefix);
const error = document.getElementById(prefix + "-error");
const entries = Array.from(document.getElementsByClassName(prefix));
if (entries.length === 0) {
field.classList.add("is-invalid");
error.innerText = A_("Please add some journal entries.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a journal entry sub-form in a currency sub-form.
*
* @param entry {HTMLLIElement} the journal entry
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntry(entry) {
const control = document.getElementById(entry.dataset.prefix + "-control");
const error = document.getElementById(entry.dataset.prefix + "-error");
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
if (accountCode.value === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please select the account.");
return false;
}
if (amount.value === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please fill in the amount.");
return false;
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the balance of a currency sub-form.
*
* @param currency {HTMLDivElement} the currency sub-form
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateBalance(currency) {
const prefix = "accounting-currency-" + currency.dataset.index;
const control = document.getElementById(prefix + "-control");
const error = document.getElementById(prefix + "-error");
const debit = document.getElementById(prefix + "-debit");
const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
const credit = document.getElementById(prefix + "-credit");
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
if (debit !== null && credit !== null) {
let debitTotal = new Decimal("0");
for (const amount of debitAmounts) {
if (amount.value !== "") {
debitTotal = debitTotal.plus(new Decimal(amount.value));
}
}
let creditTotal = new Decimal("0");
for (const amount of creditAmounts) {
if (amount.value !== "") {
creditTotal = creditTotal.plus(new Decimal(amount.value));
}
}
if (!debitTotal.equals(creditTotal)) {
control.classList.add("is-invalid");
error.innerText = A_("The totals of the debit and credit amounts do not match.");
return false;
}
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the note.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateNote() {
const field = document.getElementById("accounting-note");
const error = document.getElementById("accounting-note-error");
field.value = field.value
.replace(/^\s*\n/, "")
.trimEnd();
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,37 @@
/* The Mia! Accounting Flask Project
* transaction-order.js: The JavaScript for the transaction order
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/26
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
no.value = String(i + 1);
}
};
initializeDragAndDropReordering(list, onReorder);
}
});

View File

@ -62,12 +62,12 @@ First written: 2023/1/31
{% if "next" in request.args %} {% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}"> <input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %} {% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true"> <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Account Confirmation") }}</h1> <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ A_("Do you really want to delete this account?") }} {{ A_("Do you really want to delete this account?") }}
@ -85,9 +85,9 @@ First written: 2023/1/31
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_offset_needed %} {% if obj.is_pay_off_needed %}
<div> <div>
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span> <span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
</div> </div>
{% endif %} {% endif %}
<div class="small text-secondary fst-italic"> <div class="small text-secondary fst-italic">

View File

@ -41,7 +41,7 @@ First written: 2023/2/1
{% endif %} {% endif %}
<div class="form-floating mb-3"> <div class="form-floating mb-3">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}"> <input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-model"> <div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label> <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="accounting-base-content"> <div id="accounting-base-content">
{% if form.base_code.data %} {% if form.base_code.data %}
@ -63,9 +63,9 @@ First written: 2023/2/1
</div> </div>
<div class="form-check form-switch mb-3"> <div class="form-check form-switch mb-3">
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}> <input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-offset-needed"> <label class="form-check-label" for="accounting-is-pay-off-needed">
{{ A_("The entries in the account need offsets.") }} {{ A_("The entries in the account need pay-off.") }}
</label> </label>
</div> </div>
@ -83,16 +83,16 @@ First written: 2023/2/1
</div> </div>
</form> </form>
<div class="modal fade" id="accounting-base-selector-model" tabindex="-1" aria-labelledby="accounting-base-selector-model-label" aria-hidden="true"> <div class="modal fade" id="accounting-base-selector-modal" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-base-selector-model-label">{{ A_("Select Base Account") }}</h1> <h1 class="modal-title fs-5" id="accounting-base-selector-modal-label">{{ A_("Select Base Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="input-group mb-2"> <div class="input-group mb-2">
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search"> <input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-base-selector-query"> <label class="input-group-text" for="accounting-base-selector-query">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}

View File

@ -25,16 +25,28 @@ First written: 2023/1/30
{% block content %} {% block content %}
<div class="btn-group mb-2"> <div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> <a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search"> <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search"> <input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search" class="accounting-search-label"> <label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}
@ -58,8 +70,8 @@ First written: 2023/1/30
{% for item in list %} {% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
{{ item }} {{ item }}
{% if item.is_offset_needed %} {% if item.is_pay_off_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span> <span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
{% endif %} {% endif %}
</a> </a>
{% endfor %} {% endfor %}

View File

@ -26,8 +26,8 @@ First written: 2023/1/26
{% block content %} {% block content %}
<div class="btn-group mb-2"> <div class="btn-group mb-2">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search"> <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search"> <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search" class="accounting-search-label"> <label for="accounting-search" class="accounting-search-label">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>

View File

@ -58,12 +58,12 @@ First written: 2023/2/6
{% if "next" in request.args %} {% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}"> <input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %} {% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true"> <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Currency Confirmation") }}</h1> <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ A_("Do you really want to delete this currency?") }} {{ A_("Do you really want to delete this currency?") }}

View File

@ -25,16 +25,28 @@ First written: 2023/2/6
{% block content %} {% block content %}
<div class="btn-group mb-2"> <div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> <a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search"> <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search"> <input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search" class="accounting-search-label"> <label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}

View File

@ -19,13 +19,20 @@ nav.html: The navigation menu for the accounting application.
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26 First written: 2023/1/26
#} #}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% if accounting_can_view() %} {% if accounting_can_view() %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown"> <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-file-invoice-dollar"></i>
{{ A_("Accounting") }} {{ A_("Accounting") }}
</span> </span>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
<i class="fa-solid fa-receipt"></i>
{{ A_("Transactions") }}
</a>
</li>
<li> <li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> <a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>
@ -47,3 +54,4 @@ First written: 2023/1/26
</ul> </ul>
</li> </li>
{% endif %} {% endif %}
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -20,7 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26 First written: 2023/1/26
#} #}
{% if pagination.is_paged %} {% if pagination.is_paged %}
<nav aria-label="Page navigation"> <nav aria-label="{{ A_("Page navigation") }}">
<ul class="pagination"> <ul class="pagination">
{% for link in pagination.pages %} {% for link in pagination.pages %}
{% if link.uri is none %} {% if link.uri is none %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The cash expense transaction creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,60 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash expense transaction edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash expense transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list">
{% for entry_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
entry_id = entry_form.eid.data,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,56 @@
{#
The Mia! Accounting Flask Project
form.html: The cash expense transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_helper = form.summary_helper.debit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
account-selector-modal.html: The modal for the account selector
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector-modal" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,39 @@
{#
The Mia! Accounting Flask Project
add-new-material-fab.html: The material floating action buttons to add a new transaction
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
{{ A_("Cash expense") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
{{ A_("Cash income") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</div>
<button id="accounting-btn-material-fab-speed-dial" class="btn btn-primary rounded-circle accounting-btn-material-fab" type="button" data-target="accounting-material-fab-speed-dial">
<i class="fas fa-plus"></i>
</button>
</div>
{% endif %}

View File

@ -0,0 +1,112 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
</a>
{% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="accounting-transaction-card">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ obj }}</h2>
</div>
<div class="mb-3">
{{ obj.date|accounting_txn_format_date }}
</div>
{% block transaction_currencies %}{% endblock %}
{% if obj.note %}
<div class="card mb-3">
<div class="card-body">
<i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }}
</div>
</div>
{% endif %}
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,60 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the journal entry sub-form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
<div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label>
<div id="accounting-entry-form-account" data-code="" data-text=""></div>
</div>
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-entry-form-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-form-summary" data-value=""></div>
</div>
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,48 @@
{#
The Mia! Accounting Flask Project
entry-sub-form.html: The journal entry sub-form in the transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-{{ entry_type }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ "" if summary_data is none else summary_data }}</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<i class="fas fa-minus"></i>
</button>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -0,0 +1,92 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-helper.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ "" if form.date.data is none else form.date.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
<div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
</div>
<div class="mb-3">
<div id="accounting-currencies" class="form-control accounting-material-text-field accounting-not-empty {% if form.currencies_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currencies">{{ A_("Content") }}</label>
<div id="accounting-currency-list" class="mt-2">
{% block currency_sub_forms %}{% endblock %}
</div>
<div>
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currencies-error" class="invalid-feedback">{% if form.currencies_errors %}{{ form.currencies_errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<textarea id="accounting-note" class="form-control form-control-lg {% if form.note.errors %} is-invalid {% endif %}" name="note" rows="5" placeholder=" ">{{ "" if form.note.data is none else form.note.data }}</textarea>
<label class="form-label" for="accounting-note">{{ A_("Note") }}</label>
<div id="accounting-note-error" class="invalid-feedback">{% if form.note.errors %}{{ form.note.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% include "accounting/transaction/include/entry-form-modal.html" %}
{% block form_modals %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,181 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the summary helper
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28
#}
<form id="accounting-summary-helper-{{ summary_helper.type }}" class="accounting-summary-helper" data-entry-type="{{ summary_helper.type }}" data-default-tab-id="general" data-selected-account-code="" data-selected-account-text="">
<div id="accounting-summary-helper-{{ summary_helper.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
<label for="accounting-summary-helper-{{ summary_helper.type }}-summary">{{ A_("Summary") }}</label>
</h1>
<button class="btn-close accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<input id="accounting-summary-helper-{{ summary_helper.type }}-summary" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-modal-label">
</div>
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-general" class="nav-link active accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="page" data-tab-id="general">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="travel">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="bus">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="regular">
{{ A_("Regular") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-summary-helper-{{ summary_helper.type }}-tab-number" class="nav-link accounting-clickable accounting-summary-helper-{{ summary_helper.type }}-tab" aria-current="false" data-tab-id="number">
{{ A_("Number") }}
</span>
</li>
</ul>
{# A general summary with a tag #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page" aria-current="page" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-general" data-tab-id="general">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-general-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.general.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-travel" data-tab-id="travel">
<div class="form-floating mb-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in summary_helper.travel.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-travel-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-bus" data-tab-id="bus">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-route" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% for tag in summary_helper.bus.tags %}
<button class="btn btn-outline-primary accounting-summary-helper-{{ summary_helper.type }}-btn-tag accounting-summary-helper-{{ summary_helper.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-from" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-bus-to" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A regular income/payment #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-regular" data-tab-id="regular">
{# TODO: To be done #}
</div>
{# The number of items #}
<div class="accounting-summary-helper-{{ summary_helper.type }}-page d-none" aria-current="false" aria-labelledby="accounting-summary-helper-{{ summary_helper.type }}-tab-number" data-tab-id="number">
<div class="form-floating">
<input id="accounting-summary-helper-{{ summary_helper.type }}-number" class="form-control accounting-summary-helper-{{ summary_helper.type }}-input" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-helper-{{ summary_helper.type }}-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-helper-{{ summary_helper.type }}-number-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% for account in summary_helper.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-helper-{{ summary_helper.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary accounting-summary-helper-{{ summary_helper.type }}-close" type="button" data-bs-toggle="modal" data-bs-target="">{{ A_("Cancel") }}</button>
<button id="accounting-summary-helper-{{ summary_helper.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The cash income transaction creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,60 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }}
</a>
{% endblock %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash income transaction edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash income transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,56 @@
{#
The Mia! Accounting Flask Project
form.html: The cash income transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_helper = form.summary_helper.credit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,96 @@
{#
The Mia! Accounting Flask Project
list.html: The transaction list
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/18
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Transaction Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
{{ A_("Cash Expense") }}</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% include "accounting/transaction/include/add-new-material-fab.html" %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item)|accounting_append_next }}">
{{ item.date|accounting_txn_format_date }} {{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the transactions in a same day
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
{% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<ul id="accounting-order-list" class="list-group mb-3">
{% for item in list %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
<input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
<div>
{{ item }}
</div>
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
</ul>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% elif list %}
<ul class="list-group mb-3">
{% for item in list %}
<li class="list-group-item">
{{ item }}
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The transfer transaction creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,84 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<div class="row">
{# The debit entries #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{# The credit entries #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,126 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the transfer transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="row">
{# The debit entries #}
<div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list">
{% for entry_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div>
{# The credit entries #}
<div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,67 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
debit_total = "-",
credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block form_modals %}
{% with summary_helper = form.summary_helper.debit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with summary_helper = form.summary_helper.credit %}
{% include "accounting/transaction/include/summary-helper-modal.html" %}
{% endwith %}
{% with entry_type = "debit",
account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% with entry_type = "credit",
account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %}
{% endwith %}
{% endblock %}

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 view dispatcher for different transaction types.
"""
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,860 @@
# 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.transaction.summary_helper import SummaryHelper
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 AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.__account: Account = account
self.id: str = account.id
self.code: str = account.code
self.is_in_use: bool = False
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return str(self.__account)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return self.__account.query_values
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
collector_cls: t.Type[JournalEntryCollector] = self.collector
collector: collector_cls = collector_cls(self, obj)
collector.collect()
to_delete: set[int] = {x.id for x in obj.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[AccountOption]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit)
.group_by(JournalEntry.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def credit_account_options(self) -> list[AccountOption]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit))
.group_by(JournalEntry.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return: The currency errors, without the errors in their sub-forms.
"""
return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def summary_helper(self) -> SummaryHelper:
"""Returns the summary helper.
:return: The summary helper.
"""
return SummaryHelper()
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, obj: Transaction):
"""Constructs the journal entry collector.
:param form: The transaction form.
:param obj: The transaction.
"""
self.form: T = form
"""The transaction form."""
self.__obj: Transaction = obj
"""The transaction object."""
self.__entries: list[JournalEntry] = list(obj.entries)
"""The existing journal entries."""
self.__entries_by_id: dict[int, JournalEntry] \
= {x.id: x for x in self.__entries}
"""A dictionary from the entry ID to their entries."""
self.__no_by_id: dict[int, int] = {x.id: x.no for x in self.__entries}
"""A dictionary from the entry number to their entries."""
self.__currencies: list[TransactionCurrency] = obj.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:
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.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
self.__obj.entries.append(entry)
self.form.is_modified = True
self.to_keep.add(entry.id)
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]
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.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
self.__obj.entries.append(entry)
self.form.is_modified = True
self.to_keep.add(entry.id)
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,255 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# 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 summary helper.
"""
import typing as t
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntry
class SummaryAccount:
"""An account for a summary tag."""
def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag.
:param account: The account.
:param freq: The frequency of the tag with the account.
"""
self.account: Account = account
"""The account."""
self.id: int = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.freq: int = freq
"""The frequency of the tag with the account."""
def __str__(self) -> str:
"""Returns the string representation of the account.
:return: The string representation of the account.
"""
return str(self.account)
def add_freq(self, freq: int) -> None:
"""Adds the frequency of an account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.freq = self.freq + freq
class SummaryTag:
"""A summary tag."""
def __init__(self, name: str):
"""Constructs a summary tag.
:param name: The tag name.
"""
self.name: str = name
"""The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {}
"""The accounts that come with the tag, in the order of their
frequency."""
self.freq: int = 0
"""The frequency of the tag."""
def __str__(self) -> str:
"""Returns the string representation of the tag.
:return: The string representation of the tag.
"""
return self.name
def add_account(self, account: Account, freq: int):
"""Adds an account.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__account_dict[account.id] = SummaryAccount(account, freq)
self.freq = self.freq + freq
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies.
"""
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
@property
def account_codes(self) -> list[str]:
"""Returns the account codes by the order of their frequencies.
:return: The account codes by the order of their frequencies.
"""
return [x.code for x in self.accounts]
class SummaryType:
"""A summary type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type.
:param type_id: The type ID, either "general", "travel", or "bus".
"""
self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param name: The tag name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name)
self.__tag_dict[name].add_account(account, freq)
@property
def tags(self) -> list[SummaryTag]:
"""Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies.
"""
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType:
"""A summary type"""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type.
:param entry_type_id: The entry type ID, either "debit" or "credit".
"""
self.type: t.Literal["debit", "credit"] = entry_type_id
"""The entry type."""
self.general: SummaryType = SummaryType("general")
"""The general tags."""
self.travel: SummaryType = SummaryType("travel")
"""The travel tags."""
self.bus: SummaryType = SummaryType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None:
"""Adds a tag.
:param tag_type: The tag type, either "general", "travel", or "bus".
:param name: The name.
:param account: The associated account.
:param freq: The frequency of the tag name with the account.
:return: None.
"""
self.__type_dict[tag_type].add_tag(name, account, freq)
@property
def accounts(self) -> list[SummaryAccount]:
"""Returns the suggested accounts of all tags in the summary helper in
the entry type, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order.
"""
accounts: dict[int, SummaryAccount] = {}
freq: dict[int, int] = {}
for tag_type in self.__type_dict.values():
for tag in tag_type.tags:
for account in tag.accounts:
accounts[account.id] = account
if account.id not in freq:
freq[account.id] = 0
freq[account.id] \
= freq[account.id] + account.freq
return [accounts[y] for y in sorted(freq.keys(),
key=lambda x: -freq[x])]
class SummaryHelper:
"""The summary helper."""
def __init__(self):
"""Constructs the summary helper."""
self.debit: SummaryEntryType = SummaryEntryType("debit")
"""The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit")
"""The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
else_="credit").label("entry_type")
tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag,
JournalEntry.account_id,
sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
= {x.type: x for x in {self.debit, self.credit}}
for row in result:
entry_type_dict[row.entry_type].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq)
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the prefix of a string.
:param string: The string.
:param separator: The separator.
:return: The position of the substring, starting from 1.
"""
return sa.func.substr(string, 0, get_position(string, separator))
def get_position(string: str | sa.Column, substring: str | sa.Column) \
-> sa.Function:
"""Returns the SQL function to find the position of a substring.
:param string: The string.
:param substring: The substring.
:return: The position of the substring, starting from 1.
"""
if db.engine.name == "postgresql":
return sa.func.strpos(string, substring)
return sa.func.instr(string, substring)

View File

@ -0,0 +1,145 @@
# 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] != "as"]
params.append(("as", request.args["as"]))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def to_transfer(uri: str) -> str:
"""Adds the transfer transaction type to the URI.
:param uri: The URI.
:return: The result URL, with the transfer transaction type added.
"""
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "as"]
params.append(("as", "transfer"))
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_amount_input(value: Decimal) -> str:
"""Format an amount for an input value.
:param value: The amount.
:return: The formatted amount text for an input value.
"""
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return str(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,227 @@
# 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 .forms import sort_transactions_in, TransactionReorderForm
from .query import get_transaction_query
from .template import with_type, to_transfer, format_amount, \
format_amount_input, format_date, text2html, currency_options, \
default_currency_code
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(to_transfer, "accounting_txn_to_transfer")
bp.add_app_template_filter(format_amount, "accounting_txn_format_amount")
bp.add_app_template_filter(format_amount_input,
"accounting_txn_format_amount_input")
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)

View File

@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-07 16:22+0800\n" "POT-Creation-Date: 2023-03-01 00:51+0800\n"
"PO-Revision-Date: 2023-02-07 18:04+0800\n" "PO-Revision-Date: 2023-03-01 00:51+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,6 +19,21 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n" "Generated-By: Babel 2.11.0\n"
#: src/accounting/models.py:575
#, python-format
msgid "Cash Expense Transaction#%(id)s"
msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:577
#, python-format
msgid "Cash Income Transaction#%(id)s"
msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:578
#, python-format
msgid "Transfer Transaction#%(id)s"
msgstr "轉帳傳票#%(id)s"
#: src/accounting/account/forms.py:41 #: src/accounting/account/forms.py:41
msgid "The base account does not exist." msgid "The base account does not exist."
msgstr "沒有這個基本科目。" msgstr "沒有這個基本科目。"
@ -28,7 +43,7 @@ msgid "The base account is not available."
msgstr "不能選這個基本科目。" msgstr "不能選這個基本科目。"
#: src/accounting/account/forms.py:61 #: src/accounting/account/forms.py:61
#: src/accounting/static/js/account-form.js:110 #: src/accounting/static/js/account-form.js:157
msgid "Please select the base account." msgid "Please select the base account."
msgstr "請選擇基本科目。" msgstr "請選擇基本科目。"
@ -38,79 +53,128 @@ msgstr "請填上標題。"
#: src/accounting/account/query.py:50 #: src/accounting/account/query.py:50
#: src/accounting/templates/accounting/account/detail.html:90 #: src/accounting/templates/accounting/account/detail.html:90
#: src/accounting/templates/accounting/account/list.html:62 #: src/accounting/templates/accounting/account/list.html:74
msgid "Offset needed" msgid "Pay-off needed"
msgstr "逐筆核銷" msgstr "逐筆核銷"
#: src/accounting/account/views.py:88 #: src/accounting/account/views.py:89
msgid "The account is added successfully" msgid "The account is added successfully"
msgstr "科目加好了。" msgstr "科目加好了。"
#: src/accounting/account/views.py:143 #: src/accounting/account/views.py:141
msgid "The account was not modified." msgid "The account was not modified."
msgstr "科目未異動。" msgstr "科目未異動。"
#: src/accounting/account/views.py:148 #: src/accounting/account/views.py:146
msgid "The account is updated successfully." msgid "The account is updated successfully."
msgstr "科目存好了。" msgstr "科目存好了。"
#: src/accounting/account/views.py:165 #: src/accounting/account/views.py:162
msgid "The account is deleted successfully." msgid "The account is deleted successfully."
msgstr "科目刪掉了" msgstr "科目刪掉了"
#: src/accounting/account/views.py:192 #: src/accounting/account/views.py:189 src/accounting/transaction/views.py:214
msgid "The order was not modified." msgid "The order was not modified."
msgstr "順序未異動。" msgstr "順序未異動。"
#: src/accounting/account/views.py:195 #: src/accounting/account/views.py:192 src/accounting/transaction/views.py:217
msgid "The order is updated successfully." msgid "The order is updated successfully."
msgstr "順序存好了。" msgstr "順序存好了。"
#: src/accounting/currency/forms.py:47 #: src/accounting/currency/forms.py:46
#: src/accounting/static/js/currency-form.js:136 #: src/accounting/static/js/currency-form.js:136
msgid "Code conflicts with another currency." msgid "Code conflicts with another currency."
msgstr "代碼與其它貨幣重複。" msgstr "代碼與其它貨幣重複。"
#: src/accounting/currency/forms.py:52 #: src/accounting/currency/forms.py:51
#: src/accounting/static/js/currency-form.js:92 #: src/accounting/static/js/currency-form.js:92
msgid "Please fill in the code." msgid "Please fill in the code."
msgstr "請填上代碼。" msgstr "請填上代碼。"
#: src/accounting/currency/forms.py:54 #: src/accounting/currency/forms.py:53
#: src/accounting/static/js/currency-form.js:103 #: src/accounting/static/js/currency-form.js:103
msgid "Code can only be composed of 3 upper-cased letters." msgid "Code can only be composed of 3 upper-cased letters."
msgstr "代碼限為三個大寫英文字母。" msgstr "代碼限為三個大寫英文字母。"
#: src/accounting/currency/forms.py:57 #: src/accounting/currency/forms.py:56
#: src/accounting/static/js/currency-form.js:98 #: src/accounting/static/js/currency-form.js:98
msgid "This code is not available." msgid "This code is not available."
msgstr "不能用這個代碼。" msgstr "不能用這個代碼。"
#: src/accounting/currency/forms.py:63 #: src/accounting/currency/forms.py:62
#: src/accounting/static/js/currency-form.js:168 #: src/accounting/static/js/currency-form.js:168
msgid "Please fill in the name." msgid "Please fill in the name."
msgstr "請填上名稱。" msgstr "請填上名稱。"
#: src/accounting/currency/views.py:90 #: src/accounting/currency/views.py:91
msgid "The currency is added successfully" msgid "The currency is added successfully"
msgstr "貨幣加好了。" msgstr "貨幣加好了。"
#: src/accounting/currency/views.py:146 #: src/accounting/currency/views.py:144
msgid "The currency was not modified." msgid "The currency was not modified."
msgstr "貨幣未異動。" msgstr "貨幣未異動。"
#: src/accounting/currency/views.py:151 #: src/accounting/currency/views.py:149
msgid "The currency is updated successfully." msgid "The currency is updated successfully."
msgstr "貨幣存好了。" msgstr "貨幣存好了。"
#: src/accounting/currency/views.py:167 #: src/accounting/currency/views.py:164
msgid "The currency is deleted successfully." msgid "The currency is deleted successfully."
msgstr "貨幣刪掉了" msgstr "貨幣刪掉了"
#: src/accounting/static/js/account-form.js:130 #: src/accounting/static/js/account-form.js:177
msgid "Please fill in the title." msgid "Please fill in the title."
msgstr "請填上標題。" msgstr "請填上標題。"
#: src/accounting/static/js/summary-helper.js:441
#: src/accounting/static/js/summary-helper.js:512
msgid "Please fill in the tag."
msgstr "請填上標籤。"
#: src/accounting/static/js/summary-helper.js:460
#: src/accounting/static/js/summary-helper.js:550
msgid "Please fill in the origin."
msgstr "請填上起點。"
#: src/accounting/static/js/summary-helper.js:479
#: src/accounting/static/js/summary-helper.js:569
msgid "Please fill in the destination."
msgstr "請填上終點。"
#: src/accounting/static/js/summary-helper.js:531
msgid "Please fill in the route."
msgstr "請填上路線名稱。"
#: src/accounting/static/js/transaction-form.js:289
#: src/accounting/static/js/transaction-form.js:611
#: src/accounting/transaction/forms.py:47
msgid "Please select the account."
msgstr "請選擇科目。"
#: src/accounting/static/js/transaction-form.js:324
#: src/accounting/static/js/transaction-form.js:616
msgid "Please fill in the amount."
msgstr "請填上金額。"
#: src/accounting/static/js/transaction-form.js:488
msgid "Please fill in the date."
msgstr "請填上日期。"
#: src/accounting/static/js/transaction-form.js:523
#: src/accounting/transaction/forms.py:57
msgid "Please add some currencies."
msgstr "請加上貨幣。"
#: src/accounting/static/js/transaction-form.js:589
#: src/accounting/transaction/forms.py:78
msgid "Please add some journal entries."
msgstr "請加上分錄。"
#: src/accounting/static/js/transaction-form.js:654
#: src/accounting/transaction/forms.py:672
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
#: src/accounting/templates/accounting/account/create.html:24 #: src/accounting/templates/accounting/account/create.html:24
msgid "Add a New Account" msgid "Add a New Account"
msgstr "新增科目" msgstr "新增科目"
@ -121,20 +185,26 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/base-account/detail.html:31 #: src/accounting/templates/accounting/base-account/detail.html:31
#: src/accounting/templates/accounting/currency/detail.html:31 #: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33 #: src/accounting/templates/accounting/currency/include/form.html:33
#: src/accounting/templates/accounting/transaction/include/detail.html:31
#: src/accounting/templates/accounting/transaction/include/form.html:36
#: src/accounting/templates/accounting/transaction/order.html:36
msgid "Back" msgid "Back"
msgstr "回上頁" msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36 #: src/accounting/templates/accounting/account/detail.html:36
#: src/accounting/templates/accounting/currency/detail.html:36 #: src/accounting/templates/accounting/currency/detail.html:36
#: src/accounting/templates/accounting/transaction/include/detail.html:36
msgid "Settings" msgid "Settings"
msgstr "設定" msgstr "設定"
#: src/accounting/templates/accounting/account/detail.html:41 #: src/accounting/templates/accounting/account/detail.html:41
#: src/accounting/templates/accounting/transaction/include/detail.html:41
msgid "Order" msgid "Order"
msgstr "次序" msgstr "次序"
#: src/accounting/templates/accounting/account/detail.html:46 #: src/accounting/templates/accounting/account/detail.html:46
#: src/accounting/templates/accounting/currency/detail.html:42 #: src/accounting/templates/accounting/currency/detail.html:42
#: src/accounting/templates/accounting/transaction/include/detail.html:47
msgid "Delete" msgid "Delete"
msgstr "刪除" msgstr "刪除"
@ -142,28 +212,45 @@ msgstr "刪除"
msgid "Delete Account Confirmation" msgid "Delete Account Confirmation"
msgstr "科目刪除確認" msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:66
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:27
#: src/accounting/templates/accounting/transaction/include/detail.html:71
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:30
msgid "Close"
msgstr "關閉"
#: src/accounting/templates/accounting/account/detail.html:73 #: src/accounting/templates/accounting/account/detail.html:73
msgid "Do you really want to delete this account?" msgid "Do you really want to delete this account?"
msgstr "你確定要刪掉這個科目嗎?" msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:76 #: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/include/form.html:111 #: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:72 #: src/accounting/templates/accounting/currency/detail.html:72
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:49
#: src/accounting/templates/accounting/transaction/include/detail.html:77
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:54
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:175
msgid "Cancel" msgid "Cancel"
msgstr "取消" msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:77 #: src/accounting/templates/accounting/account/detail.html:77
#: src/accounting/templates/accounting/currency/detail.html:73 #: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/transaction/include/detail.html:78
msgid "Confirm" msgid "Confirm"
msgstr "確定" msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:94 #: src/accounting/templates/accounting/account/detail.html:94
#: src/accounting/templates/accounting/currency/detail.html:85 #: src/accounting/templates/accounting/currency/detail.html:85
#: src/accounting/templates/accounting/transaction/include/detail.html:107
msgid "Created" msgid "Created"
msgstr "建檔" msgstr "建檔"
#: src/accounting/templates/accounting/account/detail.html:95 #: src/accounting/templates/accounting/account/detail.html:95
#: src/accounting/templates/accounting/currency/detail.html:86 #: src/accounting/templates/accounting/currency/detail.html:86
#: src/accounting/templates/accounting/transaction/include/detail.html:108
msgid "Updated" msgid "Updated"
msgstr "更新" msgstr "更新"
@ -175,6 +262,7 @@ msgstr "%(account)s設定"
#: src/accounting/templates/accounting/account/list.html:24 #: src/accounting/templates/accounting/account/list.html:24
#: src/accounting/templates/accounting/base-account/list.html:24 #: src/accounting/templates/accounting/base-account/list.html:24
#: src/accounting/templates/accounting/currency/list.html:24 #: src/accounting/templates/accounting/currency/list.html:24
#: src/accounting/templates/accounting/transaction/list.html:28
#, python-format #, python-format
msgid "Search Result for \"%(query)s\"" msgid "Search Result for \"%(query)s\""
msgstr "「%(query)s」搜尋結果" msgstr "「%(query)s」搜尋結果"
@ -185,20 +273,48 @@ msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32 #: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32 #: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/include/form.html:62
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/list.html:37
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:117
msgid "New" msgid "New"
msgstr "新增" msgstr "新增"
#: src/accounting/templates/accounting/account/list.html:35
#: src/accounting/templates/accounting/currency/list.html:35
#: src/accounting/templates/accounting/transaction/list.html:57
msgid "Search for Desktop"
msgstr "桌機版檢索"
#: src/accounting/templates/accounting/account/include/form.html:98 #: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40 #: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/account/list.html:52
#: src/accounting/templates/accounting/base-account/list.html:29
#: src/accounting/templates/accounting/base-account/list.html:34 #: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40 #: src/accounting/templates/accounting/currency/list.html:40
#: src/accounting/templates/accounting/currency/list.html:52
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:34
#: src/accounting/templates/accounting/transaction/list.html:62
#: src/accounting/templates/accounting/transaction/list.html:74
msgid "Search" msgid "Search"
msgstr "搜尋" msgstr "搜尋"
#: src/accounting/templates/accounting/account/list.html:68 #: src/accounting/templates/accounting/account/list.html:47
#: src/accounting/templates/accounting/currency/list.html:47
#: src/accounting/templates/accounting/transaction/list.html:69
msgid "Search for Mobile"
msgstr "行動版檢索"
#: src/accounting/templates/accounting/account/include/form.html:109
#: src/accounting/templates/accounting/account/list.html:80
#: src/accounting/templates/accounting/account/order.html:81 #: src/accounting/templates/accounting/account/order.html:81
#: src/accounting/templates/accounting/base-account/list.html:51 #: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:57 #: src/accounting/templates/accounting/currency/list.html:77
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:46
#: src/accounting/templates/accounting/transaction/list.html:93
#: src/accounting/templates/accounting/transaction/order.html:80
msgid "There is no data." msgid "There is no data."
msgstr "沒有資料。" msgstr "沒有資料。"
@ -210,6 +326,10 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75 #: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62 #: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57 #: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:55
#: src/accounting/templates/accounting/transaction/include/form.html:78
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:176
#: src/accounting/templates/accounting/transaction/order.html:61
msgid "Save" msgid "Save"
msgstr "儲存" msgstr "儲存"
@ -226,15 +346,16 @@ msgid "Title"
msgstr "標題" msgstr "標題"
#: src/accounting/templates/accounting/account/include/form.html:68 #: src/accounting/templates/accounting/account/include/form.html:68
msgid "The entries in the account need offsets." msgid "The entries in the account need pay-off."
msgstr "帳目要逐筆核銷。" msgstr "帳目要逐筆核銷。"
#: src/accounting/templates/accounting/account/include/form.html:90 #: src/accounting/templates/accounting/account/include/form.html:90
msgid "Select Base Account" msgid "Select Base Account"
msgstr "選擇基本科目" msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:113 #: src/accounting/templates/accounting/account/include/form.html:114
#: src/accounting/templates/accounting/account/include/form.html:115 #: src/accounting/templates/accounting/account/include/form.html:116
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:50
msgid "Clear" msgid "Clear"
msgstr "清除" msgstr "清除"
@ -271,22 +392,264 @@ msgstr "代碼"
msgid "Name" msgid "Name"
msgstr "名稱" msgstr "名稱"
#: src/accounting/templates/accounting/include/nav.html:26 #: src/accounting/templates/accounting/include/nav.html:27
msgid "Accounting" msgid "Accounting"
msgstr "記帳" msgstr "記帳"
#: src/accounting/templates/accounting/include/nav.html:32 #: src/accounting/templates/accounting/include/nav.html:33
msgid "Transactions"
msgstr "傳票"
#: src/accounting/templates/accounting/include/nav.html:39
msgid "Accounts" msgid "Accounts"
msgstr "科目" msgstr "科目"
#: src/accounting/templates/accounting/include/nav.html:38 #: src/accounting/templates/accounting/include/nav.html:45
msgid "Base Accounts" msgid "Base Accounts"
msgstr "基本科目" msgstr "基本科目"
#: src/accounting/templates/accounting/include/nav.html:44 #: src/accounting/templates/accounting/include/nav.html:51
msgid "Currencies" msgid "Currencies"
msgstr "貨幣" msgstr "貨幣"
#: src/accounting/templates/accounting/include/pagination.html:23
msgid "Page navigation"
msgstr "分頁瀏覽"
#: src/accounting/templates/accounting/transaction/list.html:28
msgid "Transaction Management"
msgstr "傳票管理"
#: src/accounting/templates/accounting/transaction/list.html:42
msgid "Cash Expense"
msgstr "現金支出"
#: src/accounting/templates/accounting/transaction/list.html:46
msgid "Cash Income"
msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:32
#: src/accounting/templates/accounting/transaction/list.html:51
msgid "Transfer"
msgstr "轉帳"
#: src/accounting/templates/accounting/transaction/order.html:29
#, python-format
msgid "Transactions on %(date)s"
msgstr "%(date)s的傳票"
#: src/accounting/templates/accounting/transaction/expense/create.html:24
msgid "Add a New Cash Expense Transaction"
msgstr "新增現金支出傳票"
#: src/accounting/templates/accounting/transaction/expense/detail.html:27
#: src/accounting/templates/accounting/transaction/income/detail.html:27
msgid "To Transfer"
msgstr "改轉帳"
#: src/accounting/templates/accounting/transaction/expense/detail.html:37
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
#: src/accounting/templates/accounting/transaction/include/form.html:54
#: src/accounting/templates/accounting/transaction/income/detail.html:37
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
msgid "Content"
msgstr "內容"
#: src/accounting/templates/accounting/transaction/expense/detail.html:53
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:68
#: src/accounting/templates/accounting/transaction/income/detail.html:53
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:68
#: src/accounting/templates/accounting/transaction/transfer/detail.html:49
#: src/accounting/templates/accounting/transaction/transfer/detail.html:75
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:70
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:110
msgid "Total"
msgstr "合計"
#: src/accounting/templates/accounting/transaction/expense/edit.html:24
#: src/accounting/templates/accounting/transaction/income/edit.html:24
#: src/accounting/templates/accounting/transaction/transfer/edit.html:24
#, python-format
msgid "Editing %(txn)s"
msgstr "編輯%(txn)s"
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:32
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:32
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:32
msgid "Currency"
msgstr "貨幣"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:26
msgid "Select Account"
msgstr "選擇科目"
#: src/accounting/templates/accounting/transaction/include/account-selector-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
msgid "Cash expense"
msgstr "現金支出"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:29
msgid "Cash income"
msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/detail.html:70
msgid "Delete Transaction Confirmation"
msgstr "傳票刪除確認"
#: src/accounting/templates/accounting/transaction/include/detail.html:74
msgid "Do you really want to delete this transaction?"
msgstr "你確定要刪掉這張傳票嗎?"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:27
msgid "Journal Entry Content"
msgstr "分錄內容"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:33
msgid "Account"
msgstr "科目"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:28
msgid "Summary"
msgstr "摘要"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:49
msgid "Amount"
msgstr "金額"
#: src/accounting/templates/accounting/transaction/include/form.html:48
msgid "Date"
msgstr "日期"
#: src/accounting/templates/accounting/transaction/include/form.html:71
msgid "Note"
msgstr "備註"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:39
msgid "General"
msgstr "一般"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:44
msgid "Travel"
msgstr "差旅"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:49
msgid "Bus"
msgstr "公車"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:54
msgid "Regular"
msgstr "帳單"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:59
msgid "Number"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:67
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:84
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:119
msgid "Tag"
msgstr "標籤"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:99
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:140
msgid "From"
msgstr "從"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:108
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:145
msgid "To"
msgstr "至"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:124
msgid "Route"
msgstr "路線"
#: src/accounting/templates/accounting/transaction/include/summary-helper-modal.html:160
msgid "The number of items"
msgstr "數量"
#: src/accounting/templates/accounting/transaction/income/create.html:24
msgid "Add a New Cash Income Transaction"
msgstr "新增現金收入傳票"
#: src/accounting/templates/accounting/transaction/transfer/create.html:24
msgid "Add a New Transfer Transaction"
msgstr "新增轉帳傳票"
#: src/accounting/templates/accounting/transaction/transfer/detail.html:33
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:47
msgid "Debit"
msgstr "借方"
#: src/accounting/templates/accounting/transaction/transfer/detail.html:59
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:87
msgid "Credit"
msgstr "貸方"
#: src/accounting/transaction/forms.py:45
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/transaction/forms.py:68
msgid "The currency does not exist."
msgstr "沒有這個貨幣。"
#: src/accounting/transaction/forms.py:89
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/transaction/forms.py:100
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/transaction/forms.py:114
msgid "This account is not for debit entries."
msgstr "科目不是借方科目。"
#: src/accounting/transaction/forms.py:201
msgid "This account is not for credit entries."
msgstr "科目不是貸方科目。"
#: src/accounting/transaction/template.py:97
msgid "Today"
msgstr "今天"
#: src/accounting/transaction/template.py:99
msgid "Yesterday"
msgstr "昨天"
#: src/accounting/transaction/template.py:101
msgid "Tomorrow"
msgstr "明天"
#: src/accounting/transaction/template.py:105
msgid "The day before yesterday"
msgstr "前天"
#: src/accounting/transaction/template.py:107
msgid "The day after tomorrow"
msgstr "後天"
#: src/accounting/transaction/views.py:108
msgid "The transaction is added successfully"
msgstr "傳票加好了。"
#: src/accounting/transaction/views.py:162
msgid "The transaction was not modified."
msgstr "傳票未異動。"
#: src/accounting/transaction/views.py:167
msgid "The transaction is updated successfully."
msgstr "傳票存好了。"
#: src/accounting/transaction/views.py:183
msgid "The transaction is deleted successfully."
msgstr "傳票刪掉了"
#: src/accounting/utils/pagination.py:206 #: src/accounting/utils/pagination.py:206
msgctxt "Pagination|" msgctxt "Pagination|"
msgid "Previous" msgid "Previous"

View File

@ -0,0 +1,50 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 utility to flash all errors from the forms.
This module should not import any other module from the application.
"""
import typing as t
from flask import flash
from flask_wtf import FlaskForm
def flash_form_errors(form: FlaskForm) -> None:
"""Flash all errors from a form recursively.
:param form: The form.
:return: None.
"""
__flash_errors(form.errors)
def __flash_errors(error: t.Any) -> None:
"""Flash all errors recursively.
:param error: The errors.
:return: None.
"""
if isinstance(error, dict):
for key in error:
__flash_errors(error[key])
elif isinstance(error, list):
for e in error:
__flash_errors(e)
else:
flash(error, "error")

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The utilities to handle the next URL. """The utilities to handle the next URI.
This module should not import any other module from the application. This module should not import any other module from the application.
@ -68,7 +68,7 @@ def __set_next(uri: str, next_uri: str) -> str:
""" """
uri_p: ParseResult = urlparse(uri) uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query) params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] == "next"] params = [x for x in params if x[0] != "next"]
params.append(("next", next_uri)) params.append(("next", next_uri))
parts: list[str] = list(uri_p) parts: list[str] = list(uri_p)
parts[4] = urlencode(params) parts[4] = urlencode(params)

View File

@ -115,7 +115,7 @@ class EmptyPagination(AbstractPagination[T]):
class NonEmptyPagination(AbstractPagination[T]): class NonEmptyPagination(AbstractPagination[T]):
"""The pagination with real data.""" """The pagination with real data."""
PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200] PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
"""The page size options.""" """The page size options."""
def __init__(self, items: list[T], is_reversed: bool = False): def __init__(self, items: list[T], is_reversed: bool = False):
@ -278,7 +278,7 @@ class NonEmptyPagination(AbstractPagination[T]):
return [] return []
return [Link(str(x), self.__uri_size(x), return [Link(str(x), self.__uri_size(x),
is_current=x == self.page_size) is_current=x == self.page_size)
for x in self.PAGE_SIZE_OPTIONS] for x in self.PAGE_SIZE_OPTION_VALUES]
def __uri_size(self, page_size: int) -> str: def __uri_size(self, page_size: int) -> str:
"""Returns the URI of a page size. """Returns the URI of a page size.

View File

@ -19,6 +19,7 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import re
def strip_text(s: str | None) -> str | None: def strip_text(s: str | None) -> str | None:
@ -29,4 +30,17 @@ def strip_text(s: str | None) -> str | None:
""" """
if s is None: if s is None:
return None return None
return s.strip() s = s.strip()
return s if s != "" else None
def strip_multiline_text(s: str | None) -> str | None:
"""The filter to strip a piece of multi-line text.
:param s: The text input string.
:return: The filtered string.
"""
if s is None:
return None
s = re.sub(r"^\s*\n", "", s.rstrip())
return s if s != "" else None

View File

@ -17,8 +17,8 @@
"""The test for the account management. """The test for the account management.
""" """
import time
import unittest import unittest
from datetime import timedelta
import httpx import httpx
import sqlalchemy as sa import sqlalchemy as sa
@ -26,9 +26,12 @@ from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import create_app from test_site import create_app, db
from testlib import get_client, set_locale from testlib import get_client, set_locale
NEXT_URI: str = "/_next"
"""The next URI."""
class AccountData: class AccountData:
"""The account data.""" """The account data."""
@ -75,7 +78,6 @@ class AccountCommandTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
@ -129,7 +131,6 @@ class AccountTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
@ -205,7 +206,7 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/bases/{bank.base_code}", response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": "/next", "next": NEXT_URI,
f"{bank_id}-no": "5"}) f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -254,7 +255,7 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/bases/{bank.base_code}", response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": "/next", "next": NEXT_URI,
f"{bank_id}-no": "5"}) f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -306,17 +307,16 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}", response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": NEXT_URI,
f"{bank_id}-no": "5"}) f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/next") self.assertEqual(response.headers["Location"], NEXT_URI)
def test_add(self) -> None: def test_add(self) -> None:
"""Tests to add the currencies. """Tests to add the currencies.
:return: None. :return: None.
""" """
from accounting import db
from accounting.models import Account from accounting.models import Account
create_uri: str = f"{PREFIX}/create" create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store" store_uri: str = f"{PREFIX}/store"
@ -401,13 +401,13 @@ class AccountTestCase(unittest.TestCase):
"title": stock.title}) "title": stock.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-067") f"{PREFIX}/{stock.base_code}-003")
with self.app.app_context(): with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code, stock.code, {cash.code, bank.code, stock.code,
f"{stock.base_code}-066", f"{stock.base_code}-002",
f"{stock.base_code}-067"}) f"{stock.base_code}-003"})
stock_account: Account = Account.find_by_code(stock.code) stock_account: Account = Account.find_by_code(stock.code)
self.assertEqual(stock_account.base_code, stock.base_code) self.assertEqual(stock_account.base_code, stock.base_code)
@ -492,8 +492,8 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}" detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update" update_uri: str = f"{PREFIX}/{cash.code}/update"
cash_account: Account
response: httpx.Response response: httpx.Response
time.sleep(1)
response = self.client.post(update_uri, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -503,9 +503,12 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code) cash_account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account) self.assertIsNotNone(cash_account)
self.assertEqual(cash_account.created_at, cash_account.updated_at) cash_account.created_at \
= cash_account.created_at - timedelta(seconds=5)
cash_account.updated_at = cash_account.created_at
db.session.commit()
response = self.client.post(update_uri, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -515,9 +518,9 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code) cash_account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account) self.assertIsNotNone(cash_account)
self.assertNotEqual(cash_account.created_at, self.assertLess(cash_account.created_at,
cash_account.updated_at) cash_account.updated_at)
def test_created_updated_by(self) -> None: def test_created_updated_by(self) -> None:
@ -643,12 +646,62 @@ class AccountTestCase(unittest.TestCase):
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_change_base_code(self) -> None:
"""Tests to change the base code of an account.
:return: None.
"""
from accounting.models import Account
response: httpx.Response
for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}")
with self.app.app_context():
account_1: Account = Account.find_by_code("1111-001")
id_1: int = account_1.id
account_2: Account = Account.find_by_code("1111-002")
id_2: int = account_2.id
account_3: Account = Account.find_by_code("1111-003")
id_3: int = account_3.id
account_4: Account = Account.find_by_code("1111-004")
id_4: int = account_4.id
account_5: Account = Account.find_by_code("1111-005")
id_5: int = account_5.id
account_1.no = 3
account_2.no = 5
account_3.no = 8
account_4.base_code = "1112"
account_4.no = 2
account_5.base_code = "1112"
account_5.no = 6
db.session.commit()
response = self.client.post(f"{PREFIX}/1111-005/update",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).no, 1)
self.assertEqual(db.session.get(Account, id_2).no, 3)
self.assertEqual(db.session.get(Account, id_3).no, 2)
self.assertEqual(db.session.get(Account, id_4).no, 1)
self.assertEqual(db.session.get(Account, id_5).no, 2)
def test_reorder(self) -> None: def test_reorder(self) -> None:
"""Tests to reorder the accounts under a same base account. """Tests to reorder the accounts under a same base account.
:return: None. :return: None.
""" """
from accounting import db
from accounting.models import Account from accounting.models import Account
response: httpx.Response response: httpx.Response
@ -671,14 +724,14 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/1111", response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": NEXT_URI,
f"{id_1}-no": "4", f"{id_1}-no": "4",
f"{id_2}-no": "1", f"{id_2}-no": "1",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2", f"{id_4}-no": "2",
f"{id_5}-no": "3"}) f"{id_5}-no": "3"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next") self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-004") self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
@ -698,12 +751,12 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/1111", response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": NEXT_URI,
f"{id_2}-no": "3a", f"{id_2}-no": "3a",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2"}) f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next") self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-003") self.assertEqual(db.session.get(Account, id_1).code, "1111-003")

View File

@ -29,6 +29,11 @@ from flask.testing import FlaskCliRunner
from test_site import create_app from test_site import create_app
from testlib import get_client from testlib import get_client
LIST_URI: str = "/accounting/base-accounts"
"""The list URI."""
DETAIL_URI: str = "/accounting/base-accounts/1111"
"""The detail URI."""
class BaseAccountCommandTestCase(unittest.TestCase): class BaseAccountCommandTestCase(unittest.TestCase):
"""The base account console command test case.""" """The base account console command test case."""
@ -111,10 +116,10 @@ class BaseAccountTestCase(unittest.TestCase):
client, csrf_token = get_client(self.app, "nobody") client, csrf_token = get_client(self.app, "nobody")
response: httpx.Response response: httpx.Response
response = client.get("/accounting/base-accounts") response = client.get(LIST_URI)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get("/accounting/base-accounts/1111") response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None: def test_viewer(self) -> None:
@ -125,10 +130,10 @@ class BaseAccountTestCase(unittest.TestCase):
client, csrf_token = get_client(self.app, "viewer") client, csrf_token = get_client(self.app, "viewer")
response: httpx.Response response: httpx.Response
response = client.get("/accounting/base-accounts") response = client.get(LIST_URI)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.get("/accounting/base-accounts/1111") response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_editor(self) -> None: def test_editor(self) -> None:
@ -139,8 +144,8 @@ class BaseAccountTestCase(unittest.TestCase):
client, csrf_token = get_client(self.app, "editor") client, csrf_token = get_client(self.app, "editor")
response: httpx.Response response: httpx.Response
response = client.get("/accounting/base-accounts") response = client.get(LIST_URI)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.get("/accounting/base-accounts/1111") response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -18,16 +18,16 @@
""" """
import csv import csv
import time
import typing as t import typing as t
import unittest import unittest
from datetime import timedelta
import httpx import httpx
from click.testing import Result from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from test_site import create_app from test_site import create_app, db
from testlib import get_client, set_locale from testlib import get_client, set_locale
@ -71,7 +71,6 @@ class CurrencyCommandTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting import db
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
@ -128,7 +127,6 @@ class CurrencyTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting import db
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
@ -270,7 +268,6 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Currency from accounting.models import Currency
from test_site import db
create_uri: str = f"{PREFIX}/create" create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store" store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{zzc.code}" detail_uri: str = f"{PREFIX}/{zzc.code}"
@ -355,7 +352,6 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Currency from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}" detail_uri: str = f"{PREFIX}/{zza.code}"
edit_uri: str = f"{PREFIX}/{zza.code}/edit" edit_uri: str = f"{PREFIX}/{zza.code}/edit"
update_uri: str = f"{PREFIX}/{zza.code}/update" update_uri: str = f"{PREFIX}/{zza.code}/update"
@ -435,11 +431,10 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Currency from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}" detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update" update_uri: str = f"{PREFIX}/{zza.code}/update"
zza_currency: Currency
response: httpx.Response response: httpx.Response
time.sleep(1)
response = self.client.post(update_uri, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -449,9 +444,12 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code) zza_currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency) self.assertIsNotNone(zza_currency)
self.assertEqual(zza_currency.created_at, zza_currency.updated_at) zza_currency.created_at \
= zza_currency.created_at - timedelta(seconds=5)
zza_currency.updated_at = zza_currency.created_at
db.session.commit()
response = self.client.post(update_uri, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -461,9 +459,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code) zza_currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency) self.assertIsNotNone(zza_currency)
self.assertNotEqual(zza_currency.created_at, self.assertLess(zza_currency.created_at,
zza_currency.updated_at) zza_currency.updated_at)
def test_created_updated_by(self) -> None: def test_created_updated_by(self) -> None:
@ -472,7 +470,6 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Currency from accounting.models import Currency
from test_site import db
editor_username, editor2_username = "editor", "editor2" editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{zza.code}" detail_uri: str = f"{PREFIX}/{zza.code}"
@ -523,7 +520,6 @@ class CurrencyTestCase(unittest.TestCase):
:return: None :return: None
""" """
from accounting.models import Currency from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}" detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update" update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response response: httpx.Response

View File

@ -29,8 +29,8 @@ First written: 2023/1/27
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css"> <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css">
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script> <script src="{{ url_for("babel_catalog") }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/decimal.js/9.0.0/decimal.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
</head> </head>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-06 23:25+0800\n" "POT-Creation-Date: 2023-02-27 10:07+0800\n"
"PO-Revision-Date: 2023-02-06 23:26+0800\n" "PO-Revision-Date: 2023-02-27 10:08+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -31,7 +31,7 @@ msgstr "首頁"
#: tests/test_site/templates/base.html:68 #: tests/test_site/templates/base.html:68
msgid "Log Out" msgid "Log Out"
msgstr "" msgstr "登出"
#: tests/test_site/templates/base.html:78 #: tests/test_site/templates/base.html:78
#: tests/test_site/templates/login.html:24 #: tests/test_site/templates/login.html:24

View File

@ -0,0 +1,327 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
# 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 test for the summary helper.
"""
import unittest
from datetime import date
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client
from testlib_txn import Accounts, NEXT_URI, add_txn
class SummeryHelperTestCase(unittest.TestCase):
"""The summary helper test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Transaction, \
JournalEntry
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Transaction.query.delete()
JournalEntry.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_summary_helper(self) -> None:
"""Test the summary helper.
:return: None.
"""
from accounting.transaction.summary_helper import SummaryHelper
for form in get_form_data(self.csrf_token):
add_txn(self.client, form)
with self.app.app_context():
helper: SummaryHelper = SummaryHelper()
# Debit-General
self.assertEqual(len(helper.debit.general.tags), 2)
self.assertEqual(helper.debit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.debit.general.tags[0].accounts), 2)
self.assertEqual(helper.debit.general.tags[0].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.debit.general.tags[1].accounts), 2)
self.assertEqual(helper.debit.general.tags[1].accounts[0].code,
Accounts.MEAL)
self.assertEqual(helper.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Debit-Travel
self.assertEqual(len(helper.debit.travel.tags), 3)
self.assertEqual(helper.debit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1)
self.assertEqual(helper.debit.travel.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1)
self.assertEqual(helper.debit.travel.tags[1].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.travel.tags[2].name, "Airplane")
self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1)
self.assertEqual(helper.debit.travel.tags[2].accounts[0].code,
Accounts.TRAVEL)
# Debit-Bus
self.assertEqual(len(helper.debit.bus.tags), 2)
self.assertEqual(helper.debit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1)
self.assertEqual(helper.debit.bus.tags[0].accounts[0].code,
Accounts.TRAVEL)
self.assertEqual(helper.debit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1)
self.assertEqual(helper.debit.bus.tags[1].accounts[0].code,
Accounts.TRAVEL)
# Credit-General
self.assertEqual(len(helper.credit.general.tags), 2)
self.assertEqual(helper.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(helper.credit.general.tags[0].accounts), 3)
self.assertEqual(helper.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.general.tags[0].accounts[1].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[0].accounts[2].code,
Accounts.CASH)
self.assertEqual(helper.credit.general.tags[1].name, "Dinner")
self.assertEqual(len(helper.credit.general.tags[1].accounts), 2)
self.assertEqual(helper.credit.general.tags[1].accounts[0].code,
Accounts.BANK)
self.assertEqual(helper.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE)
# Credit-Travel
self.assertEqual(len(helper.credit.travel.tags), 2)
self.assertEqual(helper.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2)
self.assertEqual(helper.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2)
self.assertEqual(helper.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.travel.tags[1].accounts[1].code,
Accounts.CASH)
# Credit-Bus
self.assertEqual(len(helper.credit.bus.tags), 2)
self.assertEqual(helper.credit.bus.tags[0].name, "Train")
self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2)
self.assertEqual(helper.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID)
self.assertEqual(helper.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE)
self.assertEqual(helper.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1)
self.assertEqual(helper.credit.bus.tags[1].accounts[0].code,
Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"""Returns the form data for multiple transaction forms.
:param csrf_token: The CSRF token.
:return: A list of the form data.
"""
txn_date: str = date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-summary": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Fish ",
"currency-0-debit-0-amount": "4.7",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Lunch—Fish ",
"currency-0-credit-0-amount": "4.7",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL,
"currency-0-debit-2-summary": " Dinner—Hamburger ",
"currency-0-debit-2-amount": "4.25",
"currency-0-credit-2-account_code": Accounts.BANK,
"currency-0-credit-2-summary": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Salad ",
"currency-0-debit-0-amount": "4.99",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Lunch—Salad ",
"currency-0-credit-0-amount": "4.99",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE,
"currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-debit-0-amount": "2.5",
"currency-0-credit-0-account_code": Accounts.PREPAID,
"currency-0-credit-0-summary": " Bus—323—Downtown→Museum ",
"currency-0-credit-0-amount": "2.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-debit-1-amount": "3.2",
"currency-0-credit-1-account_code": Accounts.PREPAID,
"currency-0-credit-1-summary": " Train—Blue—Museum→Central ",
"currency-0-credit-1-amount": "3.2",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Train—Green—Central→Mall ",
"currency-0-debit-2-amount": "3.2",
"currency-0-credit-2-account_code": Accounts.PREPAID,
"currency-0-credit-2-summary": " Train—Green—Central→Mall ",
"currency-0-credit-2-amount": "3.2",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-summary": " Taxi—Museum→Office ",
"currency-0-debit-0-amount": "15.5",
"currency-0-credit-0-account_code": Accounts.CASH,
"currency-0-credit-0-summary": " Taxi—Museum→Office ",
"currency-0-credit-0-amount": "15.5",
"currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE,
"currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE,
"currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL,
"currency-0-debit-6-summary": " Bike—Theatre→Home ",
"currency-0-debit-6-amount": "5.5",
"currency-0-credit-6-account_code": Accounts.PREPAID,
"currency-0-credit-6-summary": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE,
"currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE,
"currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK,
"currency-0-credit-1-summary": " Lunch—Pizza ",
"currency-0-credit-1-amount": "5.49"}]

2167
tests/test_transaction.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -21,68 +21,90 @@ import unittest
from urllib.parse import quote_plus from urllib.parse import quote_plus
import httpx import httpx
from flask import Flask, request from flask import Flask, request, render_template_string
from accounting.utils.next_url import append_next, inherit_next, or_next from accounting.utils.next_uri import append_next, inherit_next, or_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords from accounting.utils.query import parse_query_keywords
from test_site import create_app, csrf from test_site import create_app
from testlib import TEST_SERVER
class NextUriTestCase(unittest.TestCase): class NextUriTestCase(unittest.TestCase):
"""The test case for the next URI utilities.""" """The test case for the next URI utilities."""
TARGET: str = "/target"
def test_next_uri(self) -> None: def setUp(self) -> None:
"""Tests the next URI utilities. """Sets up the test.
This is run once per test.
:return: None. :return: None.
""" """
app: Flask = create_app(is_testing=True) self.app: Flask = create_app(is_testing=True)
target: str = "/target"
@app.route("/test-next", methods=["GET", "POST"]) @self.app.get("/test-csrf")
@csrf.exempt def test_csrf() -> str:
def test_next_view() -> str: """The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}")
def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI.
:return: None.
"""
def test_next_uri_view() -> str:
"""The test view with the next URI.""" """The test view with the next URI."""
current_uri: str = request.full_path if request.query_string \ current_uri: str = request.full_path if request.query_string \
else request.path else request.path
self.assertEqual(append_next(target), self.assertEqual(append_next(self.TARGET),
f"{target}?next={quote_plus(current_uri)}") f"{self.TARGET}?next={quote_plus(current_uri)}")
next_uri: str = request.form["next"] if request.method == "POST" \ next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"] else request.args["next"]
self.assertEqual(inherit_next(target), self.assertEqual(inherit_next(self.TARGET),
f"{target}?next={quote_plus(next_uri)}") f"{self.TARGET}?next={quote_plus(next_uri)}")
self.assertEqual(or_next(target), next_uri) self.assertEqual(or_next(self.TARGET), next_uri)
return "" return ""
@app.route("/test-no-next", methods=["GET", "POST"]) self.app.add_url_rule("/test-next", view_func=test_next_uri_view,
@csrf.exempt methods=["GET", "POST"])
def test_no_next_view() -> str: client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
"""The test view without the next URI.""" client.headers["Referer"] = TEST_SERVER
current_uri: str = request.full_path if request.query_string \ csrf_token: str = client.get("/test-csrf").text
else request.path
self.assertEqual(append_next(target),
f"{target}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(target), target)
self.assertEqual(or_next(target), target)
return ""
client: httpx.Client = httpx.Client(app=app,
base_url="https://testserver")
client.headers["Referer"] = "https://testserver"
response: httpx.Response response: httpx.Response
# With the next URI
response = client.get("/test-next?next=/next&q=abc&page-no=4") response = client.get("/test-next?next=/next&q=abc&page-no=4")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"next": "/next", response = client.post("/test-next", data={"csrf_token": csrf_token,
"next": "/next",
"name": "viewer"}) "name": "viewer"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Without the next URI def test_no_next_uri(self) -> None:
"""Tests the next URI utilities without the next URI.
:return: None.
"""
def test_no_next_uri_view() -> str:
"""The test view without the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
self.app.add_url_rule("/test-no-next", view_func=test_no_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = client.get("/test-csrf").text
response: httpx.Response
response = client.get("/test-no-next?q=abc&page-no=4") response = client.get("/test-no-next?q=abc&page-no=4")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.post("/test-no-next", data={"name": "viewer"}) response = client.post("/test-no-next", data={"csrf_token": csrf_token,
"name": "viewer"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -165,8 +187,8 @@ class PaginationTestCase(unittest.TestCase):
self.assertEqual(pagination.list, self.params.result) self.assertEqual(pagination.list, self.params.result)
return "" return ""
self.client = httpx.Client(app=self.app, base_url="https://testserver") self.client = httpx.Client(app=self.app, base_url=TEST_SERVER)
self.client.headers["Referer"] = "https://testserver" self.client.headers["Referer"] = TEST_SERVER
def __test_success(self, query: str, items: range, def __test_success(self, query: str, items: range,
result: range, is_paged: bool = True, result: range, is_paged: bool = True,
@ -271,25 +293,28 @@ class PaginationTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
page_37: str = "q=word&page-no=37&next=%2F"
page_size_15_default: str = "q=word&page-size=15&next=%2F"
# A malformed page size # A malformed page size
self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F", self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F") range(1, 691), page_37)
# A default page size # A default page size
self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}" self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}"
"&page-no=37&next=%2F", "&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F") range(1, 691), page_37)
# An invalid page size # An invalid page size
self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F", self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F") range(1, 691), page_37)
# A malformed page number # A malformed page number
self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F") range(1, 691), page_size_15_default)
# A default page number # A default page number
self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F") range(1, 691), page_size_15_default)
# A default page number, on a reversed list # A default page number, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F", range(1, 691), page_size_15_default,
is_reversed=True) is_reversed=True)
# A page number beyond the last page # A page number beyond the last page
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
@ -298,11 +323,11 @@ class PaginationTestCase(unittest.TestCase):
# A page number beyond the last page, on a reversed list # A page number beyond the last page, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
range(1, 691), range(1, 691),
"q=word&page-size=15&next=%2F", is_reversed=True) page_size_15_default, is_reversed=True)
# A page number before the first page # A page number before the first page
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
range(1, 691), range(1, 691),
"q=word&page-size=15&next=%2F") page_size_15_default)
# A page number before the first page, on a reversed list # A page number before the first page, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F", self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
range(1, 691), range(1, 691),

View File

@ -23,6 +23,9 @@ from html.parser import HTMLParser
import httpx import httpx
from flask import Flask from flask import Flask
TEST_SERVER: str = "https://testserver"
"""The test server URI."""
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
"""Returns a user client. """Returns a user client.
@ -31,8 +34,8 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
:param username: The username. :param username: The username.
:return: A tuple of the client and the CSRF token. :return: A tuple of the client and the CSRF token.
""" """
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver") client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
client.headers["Referer"] = "https://testserver" client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client, "/login") csrf_token: str = get_csrf_token(client, "/login")
response: httpx.Response = client.post("/login", response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,

466
tests/testlib_txn.py Normal file
View File

@ -0,0 +1,466 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# 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 common test libraries for the transaction test cases.
"""
import re
from decimal import Decimal
from datetime import date
from secrets import randbelow
import httpx
from flask import Flask
from test_site import db
NEXT_URI: str = "/_next"
"""The next URI."""
NON_EMPTY_NOTE: str = " This is \n\na test."
"""The stripped content of an non-empty note."""
EMPTY_NOTE: str = " \n\n "
"""The empty note content."""
class Accounts:
"""The shortcuts to the common accounts."""
CASH: str = "1111-001"
BANK: str = "1113-001"
PREPAID: str = "1258-001"
PAYABLE: str = "2141-001"
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
OFFICE: str = "6153-001"
TRAVEL: str = "6154-001"
MEAL: str = "6172-001"
INTEREST: str = "4111-001"
DONATION: str = "7481-001"
RENT: str = "7482-001"
def get_add_form(csrf_token: str) -> dict[str, str]:
"""Returns the form data to add a new transaction.
:param csrf_token: The CSRF token.
:return: The form data to add a new transaction.
"""
return {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": date.today().isoformat(),
"currency-0-code": "USD",
"currency-0-debit-0-no": "16",
"currency-0-debit-0-account_code": Accounts.CASH,
"currency-0-debit-0-summary": " ",
"currency-0-debit-0-amount": " 495.26 ",
"currency-0-debit-6-no": "2",
"currency-0-debit-6-account_code": Accounts.BANK,
"currency-0-debit-6-summary": " Deposit ",
"currency-0-debit-6-amount": "6000",
"currency-0-debit-12-no": "2",
"currency-0-debit-12-account_code": Accounts.OFFICE,
"currency-0-debit-12-summary": " Pens ",
"currency-0-debit-12-amount": "4.99",
"currency-0-credit-2-no": "6",
"currency-0-credit-2-account_code": Accounts.SERVICE,
"currency-0-credit-2-summary": " ",
"currency-0-credit-2-amount": "5500",
"currency-0-credit-7-account_code": Accounts.SALES,
"currency-0-credit-7-summary": " ",
"currency-0-credit-7-amount": "950",
"currency-0-credit-27-account_code": Accounts.INTEREST,
"currency-0-credit-27-summary": " ",
"currency-0-credit-27-amount": "50.25",
"currency-3-no": "2",
"currency-3-code": "JPY",
"currency-3-debit-2-no": "2",
"currency-3-debit-2-account_code": Accounts.CASH,
"currency-3-debit-2-summary": " ",
"currency-3-debit-2-amount": "15000",
"currency-3-debit-9-no": "5",
"currency-3-debit-9-account_code": Accounts.BANK,
"currency-3-debit-9-summary": " Deposit ",
"currency-3-debit-9-amount": "95000",
"currency-3-credit-3-account_code": Accounts.AGENCY,
"currency-3-credit-3-summary": " Realtor ",
"currency-3-credit-3-amount": "65000",
"currency-3-credit-5-no": "4",
"currency-3-credit-5-account_code": Accounts.DONATION,
"currency-3-credit-5-summary": " Donation ",
"currency-3-credit-5-amount": "45000",
"currency-16-code": "TWD",
"currency-16-debit-2-no": "2",
"currency-16-debit-2-account_code": Accounts.CASH,
"currency-16-debit-2-summary": " ",
"currency-16-debit-2-amount": "10000",
"currency-16-debit-9-no": "2",
"currency-16-debit-9-account_code": Accounts.TRAVEL,
"currency-16-debit-9-summary": " Gas ",
"currency-16-debit-9-amount": "30000",
"currency-16-credit-6-no": "6",
"currency-16-credit-6-account_code": Accounts.RENT,
"currency-16-credit-6-summary": " Rent ",
"currency-16-credit-6-amount": "35000",
"currency-16-credit-9-account_code": Accounts.DONATION,
"currency-16-credit-9-summary": " Donation ",
"currency-16-credit-9-amount": "5000",
"note": f"\n \n\n \n{NON_EMPTY_NOTE} \n \n\n "}
def get_unchanged_update_form(txn_id: int, app: Flask, csrf_token: str) \
-> dict[str, str]:
"""Returns the form data to update a transaction, where the data are not
changed.
:param txn_id: The transaction ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:return: The form data to update the transaction, where the data are not
changed.
"""
from accounting.models import Transaction, TransactionCurrency
with app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id)
assert txn is not None
currencies: list[TransactionCurrency] = txn.currencies
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn.date,
"note": " \n \n\n " if txn.note is None
else f"\n \n\n \n \n{txn.note} \n\n "}
currency_indices_used: set[int] = set()
currency_no: int = 0
for currency in currencies:
currency_index: int = __get_new_index(currency_indices_used)
currency_no = currency_no + 3 + randbelow(3)
currency_prefix: str = f"currency-{currency_index}"
form[f"{currency_prefix}-no"] = str(currency_no)
form[f"{currency_prefix}-code"] = currency.code
entry_indices_used: set[int]
entry_no: int
prefix: str
entry_indices_used = set()
entry_no = 0
for entry in currency.debit:
entry_index: int = __get_new_index(entry_indices_used)
entry_no = entry_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-debit-{entry_index}"
form[f"{prefix}-eid"] = str(entry.id)
form[f"{prefix}-no"] = str(entry_no)
form[f"{prefix}-account_code"] = entry.account.code
form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} "
form[f"{prefix}-amount"] = str(entry.amount)
entry_indices_used = set()
entry_no = 0
for entry in currency.credit:
entry_index: int = __get_new_index(entry_indices_used)
entry_no = entry_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-credit-{entry_index}"
form[f"{prefix}-eid"] = str(entry.id)
form[f"{prefix}-no"] = str(entry_no)
form[f"{prefix}-account_code"] = entry.account.code
form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} "
form[f"{prefix}-amount"] = str(entry.amount)
return form
def __get_new_index(indices_used: set[int]) -> int:
"""Returns a new random index that is not used.
:param indices_used: The set of indices that are already used.
:return: The newly-generated random index that is not used.
"""
while True:
index: int = randbelow(100)
if index not in indices_used:
indices_used.add(index)
return index
def get_update_form(txn_id: int, app: Flask,
csrf_token: str, is_debit: bool | None) -> dict[str, str]:
"""Returns the form data to update a transaction, where the data are
changed.
:param txn_id: The transaction ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:param is_debit: True for a cash expense transaction, False for a cash
income transaction, or None for a transfer transaction
:return: The form data to update the transaction, where the data are
changed.
"""
form: dict[str, str] = get_unchanged_update_form(
txn_id, app, csrf_token)
# Mess up the entries in a currency
currency_prefix: str = __get_currency_prefix(form, "USD")
if is_debit is None or is_debit:
form = __mess_up_debit(form, currency_prefix)
if is_debit is None or not is_debit:
form = __mess_up_credit(form, currency_prefix)
# Mess-up the currencies
form = __mess_up_currencies(form)
return form
def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
-> dict[str, str]:
"""Mess up the debit journal entries in the form data.
:param form: The form data.
:param currency_prefix: The key prefix of the currency sub-form.
:return: The messed-up form.
"""
key: str
m: re.Match
# Remove the office expense
key = [x for x in form
if x.startswith(currency_prefix)
and form[x] == Accounts.OFFICE][0]
m = re.match(r"^((.+-)\d+-)account_code$", key)
debit_prefix: str = m.group(2)
entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
# Add a new travel expense
indices: set[int] = set()
for key in form:
m = re.match(r"^.+-(\d+)-amount$", key)
if m is not None:
indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20)
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
and x.startswith(debit_prefix)})
form[f"{debit_prefix}{new_index}-no"] = str(1 + randbelow(min_no - 1))
form[f"{debit_prefix}{new_index}-amount"] = str(amount)
form[f"{debit_prefix}{new_index}-account_code"] = Accounts.TRAVEL
# Swap the cash and the bank order
key_cash: str = __get_entry_no_key(form, currency_prefix, Accounts.CASH)
key_bank: str = __get_entry_no_key(form, currency_prefix, Accounts.BANK)
form[key_cash], form[key_bank] = form[key_bank], form[key_cash]
return form
def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
-> dict[str, str]:
"""Mess up the credit journal entries in the form data.
:param form: The form data.
:param currency_prefix: The key prefix of the currency sub-form.
:return: The messed-up form.
"""
key: str
m: re.Match
# Remove the sales income
key = [x for x in form
if x.startswith(currency_prefix)
and form[x] == Accounts.SALES][0]
m = re.match(r"^((.+-)\d+-)account_code$", key)
credit_prefix: str = m.group(2)
entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
# Add a new agency income
indices: set[int] = set()
for key in form:
m = re.match(r"^.+-(\d+)-amount$", key)
if m is not None:
indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20)
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
and x.startswith(credit_prefix)})
form[f"{credit_prefix}{new_index}-no"] = str(1 + randbelow(min_no - 1))
form[f"{credit_prefix}{new_index}-amount"] = str(amount)
form[f"{credit_prefix}{new_index}-account_code"] = Accounts.AGENCY
# Swap the service and the interest order
key_srv: str = __get_entry_no_key(form, currency_prefix, Accounts.SERVICE)
key_int: str = __get_entry_no_key(form, currency_prefix, Accounts.INTEREST)
form[key_srv], form[key_int] = form[key_int], form[key_srv]
return form
def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
"""Mess up the currency sub-forms in the form data.
:param form: The form data.
:return: The messed-up form.
"""
key: str
m: re.Match
# Remove JPY
currency_prefix: str = __get_currency_prefix(form, "JPY")
form = {x: form[x] for x in form if not x.startswith(currency_prefix)}
# Add AUD
indices: set[int] = set()
for key in form:
m = re.match(r"^currency-(\d+)-code$", key)
if m is not None:
indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20)
min_no: int = min({int(form[x]) for x in form if x.endswith("-no")
and "-debit-" not in x and "-credit-" not in x})
prefix: str = f"currency-{new_index}-"
form.update({
f"{prefix}code": "AUD",
f"{prefix}no": str(1 + randbelow(min_no - 1)),
f"{prefix}debit-0-no": "6",
f"{prefix}debit-0-account_code": Accounts.OFFICE,
f"{prefix}debit-0-summary": " Envelop ",
f"{prefix}debit-0-amount": "5.45",
f"{prefix}debit-14-no": "6",
f"{prefix}debit-14-account_code": Accounts.CASH,
f"{prefix}debit-14-summary": " ",
f"{prefix}debit-14-amount": "14.55",
f"{prefix}credit-16-no": "7",
f"{prefix}credit-16-account_code": Accounts.RENT,
f"{prefix}credit-16-summary": " Bike ",
f"{prefix}credit-16-amount": "19.5",
f"{prefix}credit-22-no": "5",
f"{prefix}credit-22-account_code": Accounts.DONATION,
f"{prefix}credit-22-summary": " Artist ",
f"{prefix}credit-22-amount": "0.5",
})
# Swap the USD and TWD order
usd_prefix: str = __get_currency_prefix(form, "USD")
key_usd: str = f"{usd_prefix}no"
twd_prefix: str = __get_currency_prefix(form, "TWD")
key_twd: str = f"{twd_prefix}no"
form[key_usd], form[key_twd] = form[key_twd], form[key_usd]
# Change TWD to EUR
key = [x for x in form if form[x] == "TWD"][0]
form[key] = "EUR"
return form
def __get_entry_no_key(form: dict[str, str], currency_prefix: str,
code: str) -> str:
"""Returns the key of an entry number in the form data.
:param form: The form data.
:param currency_prefix: The prefix of the currency.
:param code: The code of the account.
:return: The key of the entry number in the form data.
"""
key: str = [x for x in form
if x.startswith(currency_prefix)
and form[x] == code][0]
m: re.Match = re.match(r"^(.+-\d+-)account_code$", key)
return f"{m.group(1)}no"
def __get_currency_prefix(form: dict[str, str], code: str) -> str:
"""Returns the prefix of a currency in the form data.
:param form: The form data.
:param code: The code of the currency.
:return: The prefix of the currency.
"""
key: str = [x for x in form if form[x] == code][0]
m: re.Match = re.match(r"^(.+-)code$", key)
return m.group(1)
def add_txn(client: httpx.Client, form: dict[str, str]) -> int:
"""Adds a transfer transaction.
:param client: The client.
:param form: The form data.
:return: The newly-added transaction ID.
"""
prefix: str = "/accounting/transactions"
txn_type: str = "transfer"
if len({x for x in form if "-debit-" in x}) == 0:
txn_type = "income"
elif len({x for x in form if "-credit-" in x}) == 0:
txn_type = "expense"
store_uri = f"{prefix}/store/{txn_type}"
response: httpx.Response = client.post(store_uri, data=form)
assert response.status_code == 302
return match_txn_detail(response.headers["Location"])
def match_txn_detail(location: str) -> int:
"""Validates if the redirect location is the transaction detail, and
returns the transaction ID on success.
:param location: The redirect location.
:return: The transaction ID.
:raise AssertionError: When the location is not the transaction detail.
"""
m: re.Match = re.match(
r"^/accounting/transactions/(\d+)\?next=%2F_next",
location)
assert m is not None
return int(m.group(1))
def set_negative_amount(form: dict[str, str]) -> None:
"""Sets a negative amount in the form data, keeping the balance.
:param form: The form data.
:return: None.
"""
amount_keys: list[str] = []
prefix: str = ""
for key in form.keys():
m: re.Match = re.match(r"^(.+)-\d+-amount$", key)
if m is None:
continue
if prefix != "" and prefix != m.group(1):
continue
prefix = m.group(1)
amount_keys.append(key)
form[amount_keys[0]] = str(-Decimal(form[amount_keys[0]]))
form[amount_keys[1]] = str(Decimal(form[amount_keys[1]])
+ 2 * Decimal(form[amount_keys[0]]))
def remove_debit_in_a_currency(form: dict[str, str]) -> None:
"""Removes credit entries in a currency sub-form.
:param form: The form data.
:return: None.
"""
key: str = [x for x in form if "-debit-" in x][0]
m: re.Match = re.match(r"^(.+-debit-)", key)
keys: set[str] = {x for x in form if x.startswith(m.group(1))}
for key in keys:
del form[key]
def remove_credit_in_a_currency(form: dict[str, str]) -> None:
"""Removes credit entries in a currency sub-form.
:param form: The form data.
:return: None.
"""
key: str = [x for x in form if "-credit-" in x][0]
m: re.Match = re.match(r"^(.+-credit-)", key)
keys: set[str] = {x for x in form if x.startswith(m.group(1))}
for key in keys:
del form[key]