116 Commits

Author SHA1 Message Date
884e37fe1b Advanced to version 0.6.0. 2023-03-18 23:38:41 +08:00
cc6a73211e Updated the Sphinx documentation. 2023-03-18 23:38:19 +08:00
2299b86d0f Updated the translation. 2023-03-18 23:37:08 +08:00
6d293a1aac Added the JavaScript getInstances method to the SummaryEditor and AccountSelector classes, so that it is easier to deal with the case when the debit and credit versions are not both exist. 2023-03-18 23:36:38 +08:00
a2311aee24 Revised to prevent word wrapping in the button to choose the original entry in the summary editor. 2023-03-18 23:20:49 +08:00
5571c0d01f Renamed all the is_XXX_needed properties to is_need_XXX. For example, especially the is_offset_needed property to is_need_offset, to be clear and understandable. 2023-03-18 22:52:29 +08:00
98e1bad413 Renamed the test_not_needed test to test_not_need in the PaginationTestCase test case. 2023-03-18 22:43:53 +08:00
7ff52d99e6 Removed the unused is_offset_chooser_needed property from the AccountOption data model. 2023-03-18 22:40:56 +08:00
cc440a4110 Renamed the "_is_payable_needed" and "_is_receivable_needed" properties in the TransactionForm form to "_is_need_payable" and "_is_need_receivable", respectively, for readability and understandability. 2023-03-18 22:39:26 +08:00
f5149a0c37 Replaced the long parameter list with the JournalEntrySubForm instance in the onEdit method of the JavaScript JournalEntryEditor, to simplify the code. 2023-03-18 22:36:50 +08:00
ca928636fd Replaces the datasets with object attributes to store the currency code and entry type in the JavaScript OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
4a8297d594 Revised the documentation of the JavaScript OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
915e4408e1 Revised the JavaScript to initialize the OriginalEntrySelector instance in JournalEntryEditor, so that the journal entry editor holds the OriginalEntrySelector instance. It can find the OriginalEntrySelector instance without needing to invoke its static methods. Removed the redundant static methods from the OriginalEntrySelector class. 2023-03-18 22:11:45 +08:00
fd9eac06f6 Merged the code in the #initializeSummaryEditors method into the constructor in the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
403942dfc0 Added a missing semicolon in the saveOriginalEntry method of the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
35dc513760 Revised the #initializeSummaryEditors method of the JavaScript JournalEntryEditor class to construct the SummaryEditor instances with the entry type instead of the form element. Replaced the form element with the entry type in the constructor of the SummaryEditor class. Removed the unused accounting-summary-editor class and data-entry-type attributes from the template of the summary editor. 2023-03-18 22:11:45 +08:00
01861f0b6a Merged the code in the #initializeAccountSelectors method into the constructor in the JavaScript JournalEntryEditor class. 2023-03-18 22:11:45 +08:00
8c10f1e96a Revised the ledger not to show the accumulated balance of the nominal accounts. The accumulated balance does not make sense for nominal accounts. 2023-03-18 22:11:45 +08:00
5f7fc0b8e8 Added the is_real pseudo property to the Account data model, and changed the is_nominal pseudo property to be the opposite of the is_real pseudo property. 2023-03-18 22:11:45 +08:00
700c179774 Applied the is_nominal pseudo property to the __get_brought_forward_entry method of the EntryCollector of the ledger. 2023-03-18 22:11:45 +08:00
cabe02f7d0 Added the is_nominal pseudo property to the Account data model. 2023-03-18 22:11:45 +08:00
5ceb9f2e83 Fixed and renamed the "__query_currency_period" method of the AccountCollector of the balance sheet to "__query_current_period". 2023-03-18 22:11:45 +08:00
fe1c7669b6 Added owner's equity accounts (base code starts with "3") when calculating the change of owner's equity in the specified period. 2023-03-18 22:11:45 +08:00
4eac10981f Added owner's equity (base code starts with "3") to the accounts that can take offset. 2023-03-18 22:11:44 +08:00
c869bccc04 Reordered the methods of the Account data model. 2023-03-18 22:11:44 +08:00
61c111db69 Revised the journal, the ledger, the income and expenses log, and the search result to show the last page first as the default upon pagination. 2023-03-18 22:11:44 +08:00
34f63c1cdf Renamed the "isOriginalEntry", "is-original-entry", "is_original_entry", and "isOriginalEntry()" methods and properties of journal entries to "isNeedOffset", "is-need-offset", "is_need_offset", and "isNeedOffset()", to be clear and understandable. 2023-03-18 22:11:44 +08:00
a643d9e811 Renamed the isAccountOffsetNeeded parameter to isAccountNeedOffset in the saveSummaryWithAccount method of the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
2239ddfad1 Revised the JavaScript to initialize the AccountSelector instances in JournalEntryEditor, so that the journal entry editor holds the AccountSelector instances. It can find the AccountSelector instance without needing to invoke its static methods. Removed the redundant static methods from the AccountSelector class. 2023-03-18 22:11:44 +08:00
12fbe36b9a Revised the JavaScript to initialize the SummaryEditor instances in JournalEntryEditor, so that the journal entry editor holds the SummaryEditor instances. It can find the SummaryEditor instance without needing to invoke its static methods. Removed the redundant static methods from the SummaryEditor class. 2023-03-18 22:11:44 +08:00
46e34bb89a Removed setting the redundant "entryType" dataset from the "onAddNew" and "onEdit" methods of the JournalEntryEditor class. It is not used anymore. 2023-03-18 22:11:44 +08:00
c9453d3023 Removed the redundant "entryType" parameter from the static "start" method of JavaScript AccountSelector. 2023-03-18 22:11:44 +08:00
fc766724c4 Removed the redundant "summary" parameter from the "#onOpen" and static "start" methods of JavaScript SummaryEditor. 2023-03-18 22:11:44 +08:00
38c394c0af Added the TransactionForm instance to the constructor of the JournalEntryEditor instance, so that the journal entry editor holds an instance of the transaction form, too. It does not need to find the transaction form all the way from the side property that may not be available. Retired the redundant getTransactionForm method from the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
67e2b06d37 Revised the JavaScript to initialize the JournalEntryEditor in TransactionForm, so that the transaction form holds the JournalEntryEditor instance. The DebitCreditSideSubForm and JournalEntrySubForm instances can find the JournalEntryEditor instance from the parent form, without needing to invoke its static methods. Removed the redundant static methods from the JournalEntryEditor class. 2023-03-18 22:11:44 +08:00
be10a8d99e Revised the coding style with the JavaScript arrow functions for the transaction form. 2023-03-18 22:11:44 +08:00
fbeec600b7 Replaced the long parameter list with the JournalEntryEditor instance in the save method of the JavaScript JournalEntrySubForm sub-form, to simplify the code. 2023-03-18 22:11:44 +08:00
1a54592d4c Added the amount attribute to the JavaScript JournalEntryEditor class to pass the amount to the JournalEntrySubForm without exposing the amount input element. 2023-03-18 22:11:44 +08:00
94a527caf2 Replaces the datasets with object attributes to store the column values in the JavaScript JournalEntryEditor class. 2023-03-18 22:10:28 +08:00
0a1bbbdd47 Replaced the isOriginalEntry dataset attribute with the isNeedOffset property in the JavaScript JournalEntryEditor. It does not make sense to store that information in the HTML. 2023-03-18 09:47:57 +08:00
82b63e4bd4 Revised the coding style in the JavaScript journal entry editor. 2023-03-18 04:02:48 +08:00
e1d1aff0c1 Simplified the code in the #resetDeleteCurrencyButtons method of the JavaScript TransactionForm form. 2023-03-18 03:55:55 +08:00
2e5f9ee01f Simplified the text data in the TestData clas in testlib_offset.py. 2023-03-18 03:41:51 +08:00
f901a0020f Revised the amount limitation tests in the OffsetTestCase test case, to be clear. 2023-03-18 03:38:07 +08:00
fc2be75c3b Changed the type of the amount property in the testing JournalEntryData data model from string to Decimal. 2023-03-18 03:21:47 +08:00
96c131940b Revised the date limitation tests in the OffsetTestCase test case, to be clear. 2023-03-18 03:08:08 +08:00
b9435a255b Added the "/.errors" route to the application in the "create_test_app" function in testlib.py, to make it easier to test. 2023-03-18 02:59:28 +08:00
56045f0faf Removed the unused __entry pseudo property from the JournalEntryForm form. 2023-03-17 22:52:38 +08:00
08d1e60238 Fixed the journal, the ledger, the income ane expenses log, and the search result to respect the transaction number before the debit/credit and the journal entry nuber. 2023-03-17 22:39:29 +08:00
d88b3ac770 Added to track the net balance and offset of the original entries. 2023-03-17 22:32:01 +08:00
40e329d37f Reordered the validators in the "accounting.transaction.forms.journal_entry" module. 2023-03-16 20:42:32 +08:00
23a0721d8d Added assert in the be function in the "accounting.utils.cast" module, to insure the correctness of the expression received. 2023-03-15 23:23:01 +08:00
2b2c665eb6 Replaced the if checks with assert in the IsBalanced validator of the currency sub-form of the transaction form, the NoOffsetNominalAccount validator of the account form, and the CodeUnique validator of the currency form. 2023-03-15 22:25:24 +08:00
954173a2c2 Removed the unused list-group-item-success class from style.css. 2023-03-15 01:43:49 +08:00
91e6dc6668 Removed an excess tailing blank line from the "accounting.currency.views" module. 2023-03-15 01:10:05 +08:00
e9d8a8fcd8 Added the "accounting.utils.cast" module to cast the things to the expected type in order to supress the warnings from PyCharm. 2023-03-15 01:09:59 +08:00
4c84686395 Removed an unused import from the "accounting" module. 2023-03-15 00:51:56 +08:00
61fd1849ed Removed the annotation future import from the "accounting.transaction.utils.account_option", "accounting.transaction.forms.journal_entry", and "accounting.transaction.forms.reorder" modules. 2023-03-14 21:51:19 +08:00
a67158f8f6 Moved the CodeUnique validator from an inner class of the CurrencyForm form to an independent class, and removed the annotation future import from the "accounting.currency.forms" module. 2023-03-14 21:48:11 +08:00
5c6bfd8b49 Revised the coding style of the NeedSomeCurrencies validator. 2023-03-14 21:42:02 +08:00
d9ecf51c6d Added the "create_test_app" function in testlib.py to replace "create_app" to prevent common mistakes. Added a get_csrf_token_view route to the application, and changed the get_csrf_token function to retrieve the CSRF token with the route without parsing the HTML for the CSRF token. 2023-03-14 21:28:35 +08:00
5d31eb9172 Removed the unnecessary future annotation import from the "accounting.transaction.forms.transaction" module. 2023-03-14 20:44:06 +08:00
fadce244c5 Revised the type hint and the coding style of the NeedSomeCurrencies validator. 2023-03-14 20:43:28 +08:00
cbe7c6ca6d Added dummy.js to .gitignore and MANIFEST.in for exclusion. 2023-03-14 17:03:22 +08:00
b03938fb2e Added test_temp.py to the exclusion in MANIFEST.in. 2023-03-14 17:03:21 +08:00
8061a23fdc Renamed the AbstractUserUtils class to UserUtilityInterface, and added the can_view and can_edit functions to the UserUtilityInterface interface. There is no need to separately supply two additional can_view and can_edit callbacks. 2023-03-14 17:03:18 +08:00
cd8a480cd0 Revised the documentation of the AbstractUserUtils class. 2023-03-14 17:03:16 +08:00
b8b87714eb Revised the documentation of the JavaScript summary editor. 2023-03-14 17:03:12 +08:00
bf2f96621d Revised so that when the account selector finds the codes from the form, the journal entry editor is used to find the form instead of messing-up with the TransactionForm class and its static method that was a shortcut to the private instance method of the same name. 2023-03-14 17:03:10 +08:00
2d771f04be Fixed so that saving the journal entry from the journal entry editor triggers updating the total of the debit or credit side, which in turn triggers validating the balance if it is on a transfer form. This fixed the problem that deleting a journal entry updates total but not re-validating the balance. 2023-03-14 17:03:06 +08:00
3a12472d4b Fixed the indent in the template of the account selector. 2023-03-14 17:03:01 +08:00
d5a686a5d8 Removed trailing blank spaces from the JavaScript summary editor. 2023-03-14 17:02:58 +08:00
690f89e29a Removed the unused accounting-debit-account-code and accounting-credit-account-code HTML classes. 2023-03-14 17:02:54 +08:00
82a6a53dc4 Revised the account selector to find the account codes from the form through the TransactionForm class, but not finding the codes by itself. 2023-03-14 17:02:52 +08:00
cdd31b1047 Added the missing documentation to the static initialize method of JavaScript TransactionForm class. 2023-03-14 17:02:50 +08:00
5bad949cfa Fixed the documentation of the entryType parameter in the constructor of the JavaScript DebitCreditSideSubForm sub-form. 2023-03-14 17:02:48 +08:00
3826646d06 Reordered the journal entry editor and put the summary first and the account later. 2023-03-14 17:02:46 +08:00
74071e8997 Removed the unused static validateAccount method from the JavaScript journal entry editor. 2023-03-14 17:02:43 +08:00
3ce34803f3 Moved the journal entry editor from the transaction-form.js to a new independent JavaScript file journal-entry-editor.js. 2023-03-14 17:02:41 +08:00
232f73172f Removed the prefix from the journal entry sub-form. 2023-03-14 17:02:39 +08:00
ff1bb7142b Removed the unused data-prefix attribute from the currency sub-forms of the transaction form. 2023-03-14 17:02:37 +08:00
7155bf635a Removed the data-entry-type attribute from the journal entry editor form. The entry type is passed by the object. There is no need to store this information in the HTML anymore. 2023-03-14 17:02:35 +08:00
c306ff8009 Revised the JavaScript journal entry editor and account selector so that the account selector work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 17:02:32 +08:00
b344abce06 Added the #prefix property to the journal entry editor to simplify the consistency. 2023-03-14 17:02:29 +08:00
b3c666c872 Fixed the addJournalEntry method of the DebitCreditSideSubForm sub-form to re-validate the whole side after a new journal entry is added. 2023-03-14 17:02:14 +08:00
6a671cac84 Revised the JavaScript journal entry editor and summary editor so that the summary editor work with the journal entry editor and does not get into the detail of the journal entry editor. 2023-03-14 16:59:54 +08:00
fe87c3a7de Fixed the documentation of the #side property of the JavaScript JournalEntryEditor class. 2023-03-14 16:59:51 +08:00
2013f8cbd9 Removed the initializeNewJournalEntry method from the JavaScript SummaryEditor. It does not do meaningful things at all. 2023-03-14 16:59:49 +08:00
2325842471 Fixed the documentation of the JavaScript for the transaction form. 2023-03-14 16:59:48 +08:00
c80e58b049 Renamed the journal entry form to journal entry editor, to be clear. 2023-03-14 16:59:46 +08:00
be0ae5eba4 Replaced the function-based JavaScript with the object-oriented TransactionForm, CurrencySubForm, DebitCreditSideSubForm, JournalEntrySubForm, and JournalEntryForm classes for the transaction form. 2023-03-14 16:59:36 +08:00
2b84f64554 Replaced the function-based JavaScript with the object-oriented AccountForm class for the currency form. 2023-03-12 22:15:56 +08:00
0a658a76e8 Replaced the function-based JavaScript with the object-oriented AccountForm class for the account form. 2023-03-12 22:15:54 +08:00
50dc79d865 Added the missing is-invalid class on errors to the currency field in the currency sub-forms of the transaction form. 2023-03-12 16:51:25 +08:00
8e5377a416 Replaced the payable account with the petty-cash account in the SummeryEditorTestCase test case. 2023-03-12 01:34:47 +08:00
4299fd6fbd Revised the code in the JavaScript initializeBaseAccountSelector function in the account form. 2023-03-12 01:34:45 +08:00
1d6a53f7cd Revised the account form so that the if-offset-needed option is only available for real accounts. 2023-03-12 01:34:42 +08:00
bb2993b0c0 Reordered the code in the "accounting.transaction.forms.journal_entry" module. 2023-03-11 20:36:38 +08:00
f6946c1165 Revised the IsBalanced validator so that it no longer need the __future__ annotation. 2023-03-11 19:10:47 +08:00
8e219d8006 Fixed the type hint of the form parameter in the NeedSomeJournalEntries validator. 2023-03-11 19:10:44 +08:00
53565eb9e6 Changed the IsBalanced validator from an inner class inside the TransferCurrencyForm form to an independent class. 2023-03-11 19:10:42 +08:00
965e78d8ad Revised the rule for the accounts that need offset in the accounting-init-accounts console command. 2023-03-11 17:15:08 +08:00
74b81d3e23 Renamed the offset_original_id column to original_entry_id, and the offset_original relationship to original_entry in the JournalEntry data model. 2023-03-11 16:34:30 +08:00
a0fba6387f Added the order to the search report. 2023-03-11 16:34:30 +08:00
d28bdf2064 Revised the parameter order in the template of the currency sub-form of the transaction form. 2023-03-11 16:34:29 +08:00
edf0c00e34 Shortened the names of the #filterAccountOptions, #getAccountCodeUsedInForm, and #shouldAccountOptionShow methods to #filterOptions, #getCodesUsedInForm, and #shouldOptionShow, respectively, in the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
107d161379 Removed a debug output from the JavaScript AccountSelector class. 2023-03-11 16:34:29 +08:00
f2c184f769 Rewrote the JavaScript AccountSelector to store the page elements in the object. 2023-03-11 16:34:28 +08:00
b45986ecfc Fixed the parameter type for the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
a2c2452ec5 Added a missing blank line to the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
5194258b48 Removed the redundant #init method from the JavaScript AccountSelector class. 2023-03-11 16:34:28 +08:00
3fe7eb41ac Removed the unused "__in_use_account_id" property from the TransactionForm form. 2023-03-11 16:34:28 +08:00
7fb9e2f0a1 Added missing documentation to the OptionLink data model in the "accounting.report.utils.option_link" module. 2023-03-11 16:34:28 +08:00
1d443f7b76 Renamed the "accounting.transaction.form" module to "accounting.transaction.forms". It only contains forms now. 2023-03-11 16:34:28 +08:00
6ad4fba9cd Moved the "accounting.transaction.operators", "accounting.transaction.summary_editor" and "accounting.transaction.form.account_option" modules into the "accounting.transaction.utils" module. 2023-03-11 16:34:28 +08:00
3dda6531b5 Split the "accounting.transaction.forms" module into various submodules in the "accounting.transaction.form" module. 2023-03-11 16:33:51 +08:00
81 changed files with 6421 additions and 2393 deletions

1
.gitignore vendored
View File

@ -38,3 +38,4 @@ excludes
*.mo *.mo
zh_Hans zh_Hans
test_temp.py test_temp.py
dummy.js

View File

@ -15,6 +15,7 @@
# 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.
exclude src/accounting/static/js/dummy.js
include src/accounting/translations/* include src/accounting/translations/*
include src/accounting/translations/*/LC_MESSAGES/* include src/accounting/translations/*/LC_MESSAGES/*
include docs/* include docs/*
@ -22,6 +23,7 @@ include docs/source/*
include docs/source/_static/* include docs/source/_static/*
include docs/source/_templates/* include docs/source/_templates/*
include tests/* include tests/*
exclude tests/test_temp.py
include tests/test_site/* include tests/test_site/*
include tests/test_site/templates/* include tests/test_site/templates/*
include tests/test_site/translations/* include tests/test_site/translations/*

View File

@ -0,0 +1,45 @@
accounting.transaction.forms package
====================================
Submodules
----------
accounting.transaction.forms.currency module
--------------------------------------------
.. automodule:: accounting.transaction.forms.currency
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms.journal\_entry module
--------------------------------------------------
.. automodule:: accounting.transaction.forms.journal_entry
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms.reorder module
-------------------------------------------
.. automodule:: accounting.transaction.forms.reorder
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms.transaction module
-----------------------------------------------
.. automodule:: accounting.transaction.forms.transaction
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,6 +1,15 @@
accounting.transaction package accounting.transaction package
============================== ==============================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.transaction.forms
accounting.transaction.utils
Submodules Submodules
---------- ----------
@ -12,30 +21,6 @@ accounting.transaction.converters module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.transaction.forms module
-----------------------------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.operators module
---------------------------------------
.. automodule:: accounting.transaction.operators
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.summary\_editor module
---------------------------------------------
.. automodule:: accounting.transaction.summary_editor
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template\_filters module accounting.transaction.template\_filters module
----------------------------------------------- -----------------------------------------------

View File

@ -0,0 +1,53 @@
accounting.transaction.utils package
====================================
Submodules
----------
accounting.transaction.utils.account\_option module
---------------------------------------------------
.. automodule:: accounting.transaction.utils.account_option
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.offset\_alias module
-------------------------------------------------
.. automodule:: accounting.transaction.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.operators module
---------------------------------------------
.. automodule:: accounting.transaction.utils.operators
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.original\_entries module
-----------------------------------------------------
.. automodule:: accounting.transaction.utils.original_entries
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.utils.summary\_editor module
---------------------------------------------------
.. automodule:: accounting.transaction.utils.summary_editor
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -4,6 +4,14 @@ accounting.utils package
Submodules Submodules
---------- ----------
accounting.utils.cast module
----------------------------
.. automodule:: accounting.utils.cast
:members:
:undoc-members:
:show-inheritance:
accounting.utils.flash\_errors module accounting.utils.flash\_errors module
------------------------------------- -------------------------------------

View File

@ -17,7 +17,7 @@
[metadata] [metadata]
name = mia-accounting-flask name = mia-accounting-flask
version = 0.5.0 version = 0.6.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

@ -17,13 +17,12 @@
"""The accounting application. """The accounting application.
""" """
import typing as t
from pathlib import Path from pathlib import Path
from flask import Flask, Blueprint from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import AbstractUserUtils from accounting.utils.user import UserUtilityInterface
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
"""The data directory.""" """The data directory."""
def init_app(app: Flask, user_utils: AbstractUserUtils, def init_app(app: Flask, user_utils: UserUtilityInterface,
url_prefix: str = "/accounting", url_prefix: str = "/accounting") -> None:
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initialize the application. """Initialize the application.
:param app: The Flask application. :param app: The Flask application.
:param user_utils: The user utilities. :param user_utils: The user utilities.
:param url_prefix: The URL prefix of the accounting application. :param url_prefix: The URL prefix of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
edit the accounting data.
:return: None. :return: None.
""" """
# The database instance must be set before loading everything # The database instance must be set before loading everything
@ -73,7 +66,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
locale.init_app(app, bp) locale.init_app(app, bp)
from .utils import permission from .utils import permission
permission.init_app(bp, can_view_func, can_edit_func) permission.init_app(bp, user_utils)
from .utils import next_uri from .utils import next_uri
next_uri.init_app(bp) next_uri.init_app(bp)

View File

@ -18,7 +18,6 @@
""" """
import os import os
import re
from secrets import randbelow from secrets import randbelow
import click import click
@ -30,7 +29,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-need-offset) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option, def __validate_username(ctx: click.core.Context, param: click.core.Option,
@ -93,14 +92,36 @@ 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_need_offset: bool = __is_need_offset(base.code)
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_need_offset))
__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.")
def __is_need_offset(base_code: str) -> bool:
"""Checks that whether entries in the account need offset.
:param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise.
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}:
return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
"1581", "1611", "1851", ""}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}:
return False
return True
# Only assets and liabilities need offset
return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None: -> None:
"""Adds the accounts. """Adds the accounts.
@ -113,7 +134,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_need_offset=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

@ -53,6 +53,20 @@ class BaseAccountAvailable:
"The base account is not available.")) "The base account is not available."))
class NoOffsetNominalAccount:
"""The validator to check nominal account is not to be offset."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, AccountForm)
if not field.data:
return
if form.base_code.data is None:
return
if form.base_code.data[0] not in {"1", "2", "3"}:
raise ValidationError(lazy_gettext(
"A nominal account does not need offset."))
class AccountForm(FlaskForm): class AccountForm(FlaskForm):
"""The form to create or edit an account.""" """The form to create or edit an account."""
base_code = StringField( base_code = StringField(
@ -66,7 +80,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_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset.""" """Whether the the entries of this account need offset."""
def populate_obj(self, obj: Account) -> None: def populate_obj(self, obj: Account) -> None:
@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
obj.base_code = self.base_code.data obj.base_code = self.base_code.data
obj.no = count + 1 obj.no = count + 1
obj.title = self.title.data obj.title = self.title.data
obj.is_offset_needed = self.is_offset_needed.data if self.base_code.data[0] in {"1", "2", "3"}:
obj.is_need_offset = self.is_need_offset.data
else:
obj.is_need_offset = False
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

View File

@ -48,7 +48,7 @@ def get_account_query() -> list[Account]:
code.contains(k), code.contains(k),
Account.id.in_(l10n_matches)] Account.id.in_(l10n_matches)]
if k in gettext("Need offset"): if k in gettext("Need offset"):
sub_conditions.append(Account.is_offset_needed) sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\ return Account.query.filter(*conditions)\

View File

@ -27,6 +27,7 @@ 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.cast import s
from accounting.utils.flash_errors import flash_form_errors from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -86,7 +87,7 @@ def add_account() -> redirect:
form.populate_obj(account) form.populate_obj(account)
db.session.add(account) db.session.add(account)
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is added successfully"), "success") flash(s(lazy_gettext("The account is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect:
with db.session.no_autoflush: with db.session.no_autoflush:
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(s(lazy_gettext("The account was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
account.updated_by_id = get_current_user_pk() account.updated_by_id = get_current_user_pk()
account.updated_at = sa.func.now() account.updated_at = sa.func.now()
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is updated successfully."), "success") flash(s(lazy_gettext("The account is updated successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(account))) return redirect(inherit_next(__get_detail_uri(account)))
@ -159,7 +160,7 @@ def delete_account(account: Account) -> redirect:
account.delete() account.delete()
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(s(lazy_gettext("The account is deleted successfully.")), "success")
return redirect(or_next(__get_list_uri())) return redirect(or_next(__get_list_uri()))
@ -186,10 +187,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
form: AccountReorderForm = AccountReorderForm(base) form: AccountReorderForm = AccountReorderForm(base)
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(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_list_uri())) return redirect(or_next(__get_list_uri()))
db.session.commit() db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success") flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_list_uri())) return redirect(or_next(__get_list_uri()))

View File

@ -17,8 +17,6 @@
"""The forms for the currency management. """The forms for the currency management.
""" """
from __future__ import annotations
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
@ -30,22 +28,24 @@ from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
return
if db.session.get(Currency, field.data) is not None:
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
class CurrencyForm(FlaskForm): class CurrencyForm(FlaskForm):
"""The form to create or edit a currency.""" """The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"] CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available.""" """The reserved codes that are not available."""
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: CurrencyForm, field: StringField) -> None:
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
return
if db.session.get(Currency, field.data) is not None:
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
code = StringField( code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")), validators=[DataRequired(lazy_gettext("Please fill in the code.")),

View File

@ -27,6 +27,7 @@ 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.cast import s
from accounting.utils.flash_errors import flash_form_errors from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -88,7 +89,7 @@ def add_currency() -> redirect:
form.populate_obj(currency) form.populate_obj(currency)
db.session.add(currency) db.session.add(currency)
db.session.commit() db.session.commit()
flash(lazy_gettext("The currency is added successfully"), "success") flash(s(lazy_gettext("The currency is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect:
with db.session.no_autoflush: with db.session.no_autoflush:
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(s(lazy_gettext("The currency was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
currency.updated_by_id = get_current_user_pk() currency.updated_by_id = get_current_user_pk()
currency.updated_at = sa.func.now() currency.updated_at = sa.func.now()
db.session.commit() db.session.commit()
flash(lazy_gettext("The currency is updated successfully."), "success") flash(s(lazy_gettext("The currency is updated successfully.")), "success")
return redirect(inherit_next(__get_detail_uri(currency))) return redirect(inherit_next(__get_detail_uri(currency)))
@ -161,7 +162,7 @@ def delete_currency(currency: Currency) -> redirect:
""" """
currency.delete() currency.delete()
db.session.commit() db.session.commit()
flash(lazy_gettext("The currency is deleted successfully."), "success") flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
return redirect(or_next(url_for("accounting.currency.list"))) return redirect(or_next(url_for("accounting.currency.list")))
@ -182,4 +183,3 @@ def __get_detail_uri(currency: Currency) -> str:
:return: The detail URI of the currency. :return: The detail URI of the currency.
""" """
return url_for("accounting.currency.detail", currency=currency) return url_for("accounting.currency.detail", currency=currency)

View File

@ -21,6 +21,7 @@ from __future__ import annotations
import re import re
import typing as t import typing as t
from datetime import date
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -113,7 +114,7 @@ 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_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset.""" """Whether the entries of this account need offset."""
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())
@ -197,6 +198,52 @@ class Account(db.Model):
return return
self.l10n.append(AccountL10n(locale=current_locale, title=value)) self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_real(self) -> bool:
"""Returns whether the account is a real account.
:return: True if the account is a real account, or False otherwise.
"""
return self.base_code[0] in {"1", "2", "3"}
@property
def is_nominal(self) -> bool:
"""Returns whether the account is a nominal account.
:return: True if the account is a nominal account, or False otherwise.
"""
return not self.is_real
@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]
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this account.
:return: None.
"""
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
@classmethod @classmethod
def find_by_code(cls, code: str) -> t.Self | None: def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an account by its code. """Finds an account by its code.
@ -251,14 +298,6 @@ 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.
@ -275,28 +314,6 @@ class Account(db.Model):
""" """
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE) return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this account.
:return: None.
"""
AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.id == self.id).delete()
class AccountL10n(db.Model): class AccountL10n(db.Model):
"""A localized account title.""" """A localized account title."""
@ -568,6 +585,21 @@ class Transaction(db.Model):
return False return False
return True return True
@property
def can_delete(self) -> bool:
"""Returns whether the transaction can be deleted.
:return: True if the transaction can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for entry in self.entries:
if len(entry.offsets) > 0:
return True
return False
setattr(self, "__can_delete", not has_offset())
return getattr(self, "__can_delete")
def delete(self) -> None: def delete(self) -> None:
"""Deletes the transaction. """Deletes the transaction.
@ -597,14 +629,14 @@ class JournalEntry(db.Model):
"""True for a debit entry, or False for a credit entry.""" """True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False) no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit.""" """The entry number under the transaction and debit or credit."""
offset_original_id = db.Column(db.Integer, original_entry_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"), db.ForeignKey(id, onupdate="CASCADE"),
nullable=True) nullable=True)
"""The ID of the original entry to offset.""" """The ID of the original entry."""
offset_original = db.relationship("JournalEntry", back_populates="offsets", original_entry = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True) remote_side=id, passive_deletes=True)
"""The original entry to offset.""" """The original entry."""
offsets = db.relationship("JournalEntry", back_populates="offset_original") offsets = db.relationship("JournalEntry", back_populates="original_entry")
"""The offset entries.""" """The offset entries."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),
@ -624,6 +656,21 @@ class JournalEntry(db.Model):
amount = db.Column(db.Numeric(14, 2), nullable=False) amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount.""" """The amount."""
def __str__(self) -> str:
"""Returns the string representation of the journal entry.
:return: The string representation of the journal entry.
"""
if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(summary)s %(amount)s",
date=format_date(self.transaction.date),
summary="" if self.summary is None
else self.summary,
amount=format_amount(self.amount)))
return getattr(self, "__str")
@property @property
def eid(self) -> int | None: def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the """Returns the journal entry ID. This is the alternative name of the
@ -649,6 +696,20 @@ class JournalEntry(db.Model):
""" """
return self.amount if self.is_debit else None return self.amount if self.is_debit else None
@property
def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset.
:return: True if the entry needs offset, or False otherwise.
"""
if not self.account.is_need_offset:
return False
if self.account.base_code[0] == "1" and not self.is_debit:
return False
if self.account.base_code[0] == "2" and self.is_debit:
return False
return True
@property @property
def credit(self) -> Decimal | None: def credit(self) -> Decimal | None:
"""Returns the credit amount. """Returns the credit amount.
@ -656,3 +717,45 @@ class JournalEntry(db.Model):
:return: The credit amount, or None if this is not a credit entry. :return: The credit amount, or None if this is not a credit entry.
""" """
return None if self.is_debit else self.amount return None if self.is_debit else self.amount
@property
def net_balance(self) -> Decimal:
"""Returns the net balance.
:return: The net balance.
"""
if not hasattr(self, "__net_balance"):
setattr(self, "__net_balance", self.amount + sum(
[x.amount if x.is_debit == self.is_debit else -x.amount
for x in self.offsets]))
return getattr(self, "__net_balance")
@net_balance.setter
def net_balance(self, net_balance: Decimal) -> None:
"""Sets the net balance.
:param net_balance: The net balance.
:return: None.
"""
setattr(self, "__net_balance", net_balance)
@property
def query_values(self) -> tuple[list[str], list[str]]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
def format_amount(value: Decimal) -> str:
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:]
txn_day: date = self.transaction.date
summary: str = "" if self.summary is None else self.summary
return ([summary],
[str(txn_day.year),
"{}/{}".format(txn_day.year, txn_day.month),
"{}/{}".format(txn_day.month, txn_day.day),
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])

View File

@ -188,10 +188,10 @@ class AccountCollector:
:return: None. :return: None.
""" """
self.__add_owner_s_equity(Account.NET_CHANGE_CODE, self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
self.__query_currency_period(), self.__query_current_period(),
self.__period) self.__period)
def __query_currency_period(self) -> Decimal | None: def __query_current_period(self) -> Decimal | None:
"""Queries and returns the net income or loss for current period. """Queries and returns the net income or loss for current period.
:return: The net income or loss for current period. :return: The net income or loss for current period.
@ -213,7 +213,7 @@ class AccountCollector:
:return: The balance. :return: The balance.
""" """
conditions.extend([sa.not_(Account.base_code.startswith(x)) conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2"}]) for x in {"1", "2", "3"}])
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))

View File

@ -37,6 +37,7 @@ from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url from accounting.report.utils.urls import income_expenses_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -120,7 +121,7 @@ class EntryCollector:
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\ .join(Transaction).join(Account)\
.filter(JournalEntry.currency_code == self.__currency.code, .filter(be(JournalEntry.currency_code == self.__currency.code),
self.__account_condition, self.__account_condition,
Transaction.date < self.__period.start) Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
@ -159,6 +160,7 @@ class EntryCollector:
JournalEntry.currency_code == self.__currency.code, JournalEntry.currency_code == self.__currency.code,
sa.not_(self.__account_condition)) sa.not_(self.__account_condition))
.order_by(Transaction.date, .order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit, JournalEntry.is_debit,
JournalEntry.no) JournalEntry.no)
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
@ -342,7 +344,7 @@ class PageParams(BasePageParams):
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\ in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.join(Account)\ .join(Account)\
.filter(JournalEntry.currency_code == self.currency.code, .filter(be(JournalEntry.currency_code == self.currency.code),
sa.or_(Account.base_code.startswith("11"), sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"), Account.base_code.startswith("12"),
Account.base_code.startswith("21"), Account.base_code.startswith("21"),
@ -433,7 +435,7 @@ class IncomeExpenses(BaseReport):
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries) = Pagination[ReportEntry](all_entries, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_entries) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportEntry | None = None

View File

@ -188,6 +188,7 @@ class Journal(BaseReport):
return JournalEntry.query.join(Transaction)\ return JournalEntry.query.join(Transaction)\
.filter(*conditions)\ .filter(*conditions)\
.order_by(Transaction.date, .order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(), JournalEntry.is_debit.desc(),
JournalEntry.no)\ JournalEntry.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
@ -208,7 +209,7 @@ class Journal(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
pagination: Pagination[JournalEntry] \ pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries) = Pagination[JournalEntry](self.__entries, is_reversed=True)
params: PageParams = PageParams(period=self.__period, params: PageParams = PageParams(period=self.__period,
pagination=pagination, pagination=pagination,
entries=pagination.list) entries=pagination.list)

View File

@ -36,6 +36,7 @@ from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url from accounting.report.utils.urls import ledger_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -110,14 +111,14 @@ class EntryCollector:
""" """
if self.__period.start is None: if self.__period.start is None:
return None return None
if self.__account.base_code[0] not in {"1", "2", "3"}: if self.__account.is_nominal:
return None return None
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntry.is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)) else_=-JournalEntry.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\ select: sa.Select = sa.Select(balance_func).join(Transaction)\
.filter(JournalEntry.currency_code == self.__currency.code, .filter(be(JournalEntry.currency_code == self.__currency.code),
JournalEntry.account_id == self.__account.id, be(JournalEntry.account_id == self.__account.id),
Transaction.date < self.__period.start) Transaction.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
@ -148,6 +149,7 @@ class EntryCollector:
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction) return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
.filter(*conditions) .filter(*conditions)
.order_by(Transaction.date, .order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit.desc(), JournalEntry.is_debit.desc(),
JournalEntry.no) JournalEntry.no)
.options(selectinload(JournalEntry.transaction)).all()] .options(selectinload(JournalEntry.transaction)).all()]
@ -176,6 +178,8 @@ class EntryCollector:
:return: None. :return: None.
""" """
if self.__account.is_nominal:
return None
balance: Decimal = 0 if self.brought_forward is None \ balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance else self.brought_forward.balance
for entry in self.entries: for entry in self.entries:
@ -303,7 +307,7 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
in_use: sa.Select = sa.Select(JournalEntry.account_id)\ in_use: sa.Select = sa.Select(JournalEntry.account_id)\
.filter(JournalEntry.currency_code == self.currency.code)\ .filter(be(JournalEntry.currency_code == self.currency.code))\
.group_by(JournalEntry.account_id) .group_by(JournalEntry.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period), return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id) x.id == self.account.id)
@ -382,7 +386,7 @@ class Ledger(BaseReport):
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_entries.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportEntry] \
= Pagination[ReportEntry](all_entries) = Pagination[ReportEntry](all_entries, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_entries: list[ReportEntry] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_entries) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportEntry | None = None

View File

@ -32,6 +32,7 @@ from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows from .journal import get_csv_rows
@ -68,7 +69,11 @@ class EntryCollector:
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.filter(*conditions)\ return JournalEntry.query.join(Transaction).filter(*conditions)\
.order_by(Transaction.date,
Transaction.no,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency), selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntry.transaction)).all()
@ -92,7 +97,7 @@ class EntryCollector:
code.contains(k), code.contains(k),
Account.id.in_(select_l10n)] Account.id.in_(select_l10n)]
if k in gettext("Need offset"): if k in gettext("Need offset"):
conditions.append(Account.is_offset_needed) conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions)) return sa.select(Account.id).filter(sa.or_(*conditions))
@staticmethod @staticmethod
@ -121,7 +126,7 @@ class EntryCollector:
try: try:
txn_date = datetime.strptime(k, "%Y") txn_date = datetime.strptime(k, "%Y")
conditions.append( conditions.append(
sa.extract("year", Transaction.date) == txn_date.year) be(sa.extract("year", Transaction.date) == txn_date.year))
except ValueError: except ValueError:
pass pass
try: try:
@ -194,7 +199,7 @@ class Search(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
pagination: Pagination[JournalEntry] \ pagination: Pagination[JournalEntry] \
= Pagination[JournalEntry](self.__entries) = Pagination[JournalEntry](self.__entries, is_reversed=True)
params: PageParams = PageParams(pagination=pagination, params: PageParams = PageParams(pagination=pagination,
entries=pagination.list) entries=pagination.list)
return render_template("accounting/report/search.html", return render_template("accounting/report/search.html",

View File

@ -27,10 +27,15 @@ class OptionLink:
"""Constructs an option link. """Constructs an option link.
:param title: The title. :param title: The title.
:param url: The URI. :param url: The URL.
:param is_active: True if active, or False otherwise :param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
""" """
self.title: str = title self.title: str = title
"""The title."""
self.url: str = url self.url: str = url
"""The URL."""
self.is_active: bool = is_active self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -31,6 +31,9 @@
color: #141619; color: #141619;
background-color: #D3D3D4; background-color: #D3D3D4;
} }
.form-control.accounting-disabled {
background-color: #e9ecef;
}
/** The toolbar */ /** The toolbar */
.accounting-toolbar { .accounting-toolbar {
@ -113,6 +116,33 @@
border-bottom: thick double slategray; border-bottom: thick double slategray;
} }
/* Links between objects */
.accounting-original-entry {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-entry a {
color: inherit;
text-decoration: none;
}
.accounting-original-entry a:hover {
color: inherit;
}
.accounting-offset-entries {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-entries ul li {
list-style: none;
}
.accounting-offset-entries ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-entries ul li a:hover {
color: inherit;
}
/** The option selector */ /** The option selector */
.accounting-selector-list { .accounting-selector-list {
height: 20rem; height: 20rem;
@ -136,9 +166,6 @@
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) { .accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2; 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 { .accounting-list-group-hover .list-group-item:hover {
background-color: #ececec; background-color: #ececec;
} }
@ -153,6 +180,9 @@
font-weight: bolder; font-weight: bolder;
border-top: thick double slategray; border-top: thick double slategray;
} }
.accounting-entry-editor-original-entry-content {
width: calc(100% - 3rem);
}
/* The report table */ /* The report table */
.accounting-report-table-header, .accounting-report-table-footer { .accounting-report-table-header, .accounting-report-table-footer {
@ -191,12 +221,18 @@ a.accounting-report-table-row {
.accounting-journal-table .accounting-report-table-row { .accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr; grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
} }
.accounting-ledger-table .accounting-report-table-row { .accounting-ledger-real-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr 1fr; grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
} }
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row { .accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 5fr 1fr 1fr 1fr; grid-template-columns: 5fr 1fr 1fr 1fr;
} }
.accounting-ledger-nominal-table .accounting-report-table-row {
grid-template-columns: 1fr 4fr 1fr 1fr;
}
.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
grid-template-columns: 5fr 1fr 1fr;
}
.accounting-income-expenses-table .accounting-report-table-row { .accounting-income-expenses-table .accounting-report-table-row {
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr; grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
} }

View File

@ -24,161 +24,335 @@
// Initializes the page JavaScript. // Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initializeBaseAccountSelector(); AccountForm.initialize();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("accounting-form")
.onsubmit = validateForm;
}); });
/** /**
* Initializes the base account selector. * The account form.
* *
* @private * @private
*/ */
function initializeBaseAccountSelector() { class AccountForm {
const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base"); /**
const baseCode = document.getElementById("accounting-base-code"); * The base account selector
const baseContent = document.getElementById("accounting-base-content"); * @type {BaseAccountSelector}
const options = Array.from(document.getElementsByClassName("accounting-base-option")); */
const btnClear = document.getElementById("accounting-btn-clear-base"); #baseAccountSelector;
selector.addEventListener("show.bs.modal", () => {
base.classList.add("accounting-not-empty"); /**
for (const option of options) { * The form element
option.classList.remove("active"); * @type {HTMLFormElement}
} */
const selected = document.getElementById("accounting-base-option-" + baseCode.value); #formElement;
if (selected !== null) {
selected.classList.add("active"); /**
} * The control of the base account
}); * @type {HTMLDivElement}
selector.addEventListener("hidden.bs.modal", () => { */
if (baseCode.value === "") { #baseControl;
base.classList.remove("accounting-not-empty");
} /**
}); * The input of the base account
for (const option of options) { * @type {HTMLInputElement}
option.onclick = () => { */
baseCode.value = option.dataset.code; #baseCode;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger"); /**
btnClear.classList.remove("btn-secondary") * The base account
btnClear.disabled = false; * @type {HTMLDivElement}
validateBase(); */
bootstrap.Modal.getInstance(selector).hide(); #base;
/**
* The error message for the base account
* @type {HTMLDivElement}
*/
#baseError;
/**
* The title
* @type {HTMLInputElement}
*/
#title;
/**
* The error message of the title
* @type {HTMLDivElement}
*/
#titleError;
/**
* The control of the is-need-offset option
* @type {HTMLDivElement}
*/
#isNeedOffsetControl;
/**
* The is-need-offset option
* @type {HTMLInputElement}
*/
#isNeedOffset;
/**
* Constructs the account form.
*
*/
constructor() {
this.#baseAccountSelector = new BaseAccountSelector(this);
this.#formElement = document.getElementById("accounting-form");
this.#baseControl = document.getElementById("accounting-base-control");
this.#baseCode = document.getElementById("accounting-base-code");
this.#base = document.getElementById("accounting-base");
this.#baseError = document.getElementById("accounting-base-error");
this.#title = document.getElementById("accounting-title");
this.#titleError = document.getElementById("accounting-title-error");
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
this.#formElement.onsubmit = () => {
return this.#validateForm();
};
this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty");
this.#baseAccountSelector.onOpen(this.#baseCode.value);
}; };
} }
btnClear.onclick = () => {
baseCode.value = ""; /**
baseContent.innerText = ""; * The callback when the base account selector is closed.
btnClear.classList.add("btn-secondary") *
btnClear.classList.remove("btn-danger"); */
btnClear.disabled = true; onBaseAccountSelectorClosed() {
validateBase(); if (this.#baseCode.value === "") {
bootstrap.Modal.getInstance(selector).hide(); this.#baseControl.classList.remove("accounting-not-empty");
}
}
/**
* Sets the base account.
*
* @param code {string} the base account code
* @param text {string} the text for the base account
*/
setBaseAccount(code, text) {
this.#baseCode.value = code;
this.#base.innerText = text;
if (["1", "2", "3"].includes(code.substring(0, 1))) {
this.#isNeedOffsetControl.classList.remove("d-none");
this.#isNeedOffset.disabled = false;
} else {
this.#isNeedOffsetControl.classList.add("d-none");
this.#isNeedOffset.disabled = true;
this.#isNeedOffset.checked = false;
}
this.#validateBase();
}
/**
* Clears the base account.
*
*/
clearBaseAccount() {
this.#baseCode.value = "";
this.#base.innerText = "";
this.#validateBase();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateForm() {
let isValid = true;
isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateBase() {
if (this.#baseCode.value === "") {
this.#baseControl.classList.add("is-invalid");
this.#baseError.innerText = A_("Please select the base account.");
return false;
}
this.#baseControl.classList.remove("is-invalid");
this.#baseError.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateTitle() {
this.#title.value = this.#title.value.trim();
if (this.#title.value === "") {
this.#title.classList.add("is-invalid");
this.#titleError.innerText = A_("Please fill in the title.");
return false;
}
this.#title.classList.remove("is-invalid");
this.#titleError.innerText = "";
return true;
}
/**
* The account form
* @type {AccountForm} the form
*/
static #form;
static initialize() {
this.#form = new AccountForm();
} }
initializeBaseAccountQuery();
} }
/** /**
* Initializes the query on the base account options. * The base account selector.
* *
* @private * @private
*/ */
function initializeBaseAccountQuery() { class BaseAccountSelector {
const query = document.getElementById("accounting-base-selector-query");
const optionList = document.getElementById("accounting-base-option-list"); /**
const options = Array.from(document.getElementsByClassName("accounting-base-option")); * The account form
const queryNoResult = document.getElementById("accounting-base-option-no-result"); * @type {AccountForm}
query.addEventListener("input", () => { */
if (query.value === "") { #form;
for (const option of options) {
option.classList.remove("d-none"); /**
} * The selector modal
optionList.classList.remove("d-none"); * @type {HTMLDivElement}
queryNoResult.classList.add("d-none"); */
return #modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The button to clear the base account value
* @type {HTMLButtonElement}
*/
#clearButton;
/**
* Constructs the base account selector.
*
* @param form {AccountForm} the form
*/
constructor(form) {
this.#form = form;
this.#modal = document.getElementById("accounting-base-selector-modal");
this.#query = document.getElementById("accounting-base-selector-query");
this.#optionList = document.getElementById("accounting-base-selector-option-list");
// noinspection JSValidateTypes
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
this.#clearButton = document.getElementById("accounting-base-selector-clear");
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
this.#modal.addEventListener("hidden.bs.modal", () => {
this.#form.onBaseAccountSelectorClosed();
});
for (const option of this.#options) {
option.onclick = () => {
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
};
} }
let hasAnyMatched = false; this.#clearButton.onclick = () => {
for (const option of options) { this.#form.clearBaseAccount();
const queryValues = JSON.parse(option.dataset.queryValues); };
let isMatched = false; this.#initializeBaseAccountQuery();
for (const queryValue of queryValues) { }
if (queryValue.includes(query.value)) {
isMatched = true; /**
break; * Initializes the query.
*
*/
#initializeBaseAccountQuery() {
this.#query.addEventListener("input", () => {
if (this.#query.value === "") {
for (const option of this.#options) {
option.classList.remove("d-none");
}
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(this.#query.value)) {
isMatched = true;
break;
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
} }
} }
if (isMatched) { if (!hasAnyMatched) {
option.classList.remove("d-none"); this.#optionList.classList.add("d-none");
hasAnyMatched = true; this.#queryNoResult.classList.remove("d-none");
} else { } else {
option.classList.add("d-none"); this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
});
}
/**
* The callback when the base account selector is shown.
*
* @param baseCode {string} the active base code
*/
onOpen(baseCode) {
for (const option of this.#options) {
if (option.dataset.code === baseCode) {
option.classList.add("active");
} else {
option.classList.remove("active");
} }
} }
if (!hasAnyMatched) { if (baseCode === "") {
optionList.classList.add("d-none"); this.#clearButton.classList.add("btn-secondary")
queryNoResult.classList.remove("d-none"); this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else { } else {
optionList.classList.remove("d-none"); this.#clearButton.classList.add("btn-danger");
queryNoResult.classList.add("d-none"); this.#clearButton.classList.remove("btn-secondary")
this.#clearButton.disabled = false;
} }
});
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
let isValid = true;
isValid = validateBase() && isValid;
isValid = validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateBase() {
const field = document.getElementById("accounting-base-code");
const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("accounting-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
error.innerText = A_("Please select the base account.");
return false;
} }
displayField.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateTitle() {
const field = document.getElementById("accounting-title");
const error = document.getElementById("accounting-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the title.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
} }

View File

@ -22,22 +22,23 @@
*/ */
"use strict"; "use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
AccountSelector.initialize();
});
/** /**
* The account selector. * The account selector.
* *
*/ */
class AccountSelector { class AccountSelector {
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryEditor;
/** /**
* The entry type * The entry type
* @type {string} * @type {string}
*/ */
#entryType; entryType;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class
@ -45,78 +46,81 @@ class AccountSelector {
*/ */
#prefix; #prefix;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/** /**
* Constructs an account selector. * Constructs an account selector.
* *
* @param modal {HTMLFormElement} the account selector modal * @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
*/ */
constructor(modal) { constructor(entryEditor, entryType) {
this.#entryType = modal.dataset.entryType; this.#entryEditor = entryEditor
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; this.entryType = entryType;
this.#init(); this.#prefix = "accounting-account-selector-" + entryType;
} this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
/** this.#optionList = document.getElementById(this.#prefix + "-option-list");
* Initializes the account selector. // noinspection JSValidateTypes
* this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
*/ this.#more = document.getElementById(this.#prefix + "-more");
#init() { this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
const formAccountControl = document.getElementById("accounting-entry-form-account-control"); this.#more.onclick = () => {
const formAccount = document.getElementById("accounting-entry-form-account"); this.#more.classList.add("d-none");
const more = document.getElementById(this.#prefix + "-more"); this.#filterOptions();
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
more.onclick = () => {
more.classList.add("d-none");
this.#filterAccountOptions();
}; };
this.#initializeAccountQuery(); this.#clearButton.onclick = () => this.#entryEditor.clearAccount();
btnClear.onclick = () => { for (const option of this.#options) {
formAccountControl.classList.remove("accounting-not-empty"); option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
for (const option of options) {
option.onclick = () => {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
} }
} this.#query.addEventListener("input", () => {
this.#filterOptions();
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
}); });
} }
/** /**
* Filters the account options. * Filters the options.
* *
*/ */
#filterAccountOptions() { #filterOptions() {
const query = document.getElementById(this.#prefix + "-query"); const codesInUse = this.#getCodesUsedInForm();
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; let shouldAnyShow = false;
for (const option of options) { for (const option of this.#options) {
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query); const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
if (shouldShow) { if (shouldShow) {
option.classList.remove("d-none"); option.classList.remove("d-none");
shouldAnyShow = true; shouldAnyShow = true;
@ -124,12 +128,12 @@ class AccountSelector {
option.classList.add("d-none"); option.classList.add("d-none");
} }
} }
if (!shouldAnyShow && more.classList.contains("d-none")) { if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
optionList.classList.add("d-none"); this.#optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none"); this.#queryNoResult.classList.remove("d-none");
} else { } else {
optionList.classList.remove("d-none"); this.#optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none"); this.#queryNoResult.classList.add("d-none");
} }
} }
@ -138,26 +142,24 @@ class AccountSelector {
* *
* @return {string[]} the account codes that are used in the form * @return {string[]} the account codes that are used in the form
*/ */
#getAccountCodeUsedInForm() { #getCodesUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code")); const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
const formAccount = document.getElementById("accounting-entry-form-account"); if (this.#entryEditor.accountCode !== null) {
const inUse = [formAccount.dataset.code]; inUse.push(this.#entryEditor.accountCode);
for (const accountCode of accountCodes) {
inUse.push(accountCode.value);
} }
return inUse return inUse
} }
/** /**
* Returns whether an account option should show. * Returns whether an option should show.
* *
* @param option {HTMLLIElement} the account option * @param option {HTMLLIElement} the option
* @param more {HTMLLIElement} the more account element * @param more {HTMLLIElement} the more element
* @param inUse {string[]} the account codes that are used in the form * @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any * @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise * @return {boolean} true if the option should show, or false otherwise
*/ */
#shouldAccountOptionShow(option, more, inUse, query) { #shouldOptionShow(option, more, inUse, query) {
const isQueryMatched = () => { const isQueryMatched = () => {
if (query.value === "") { if (query.value === "") {
return true; return true;
@ -180,71 +182,43 @@ class AccountSelector {
} }
/** /**
* Initializes the account selector when it is shown. * The callback when the account selector is shown.
* *
*/ */
initShow() { onOpen() {
const formAccount = document.getElementById("accounting-entry-form-account"); this.#query.value = "";
const query = document.getElementById(this.#prefix + "-query") this.#more.classList.remove("d-none");
const more = document.getElementById(this.#prefix + "-more"); this.#filterOptions();
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); for (const option of this.#options) {
const btnClear = document.getElementById(this.#prefix + "-btn-clear"); if (option.dataset.code === this.#entryEditor.accountCode) {
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"); option.classList.add("active");
} else { } else {
option.classList.remove("active"); option.classList.remove("active");
} }
} }
if (formAccount.dataset.code === "") { if (this.#entryEditor.accountCode === null) {
btnClear.classList.add("btn-secondary"); this.#clearButton.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
btnClear.disabled = true; this.#clearButton.disabled = true;
} else { } else {
btnClear.classList.add("btn-danger"); this.#clearButton.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary"); this.#clearButton.classList.remove("btn-secondary");
btnClear.disabled = false; this.#clearButton.disabled = false;
} }
} }
/** /**
* The account selectors. * Returns the account selector instances.
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
static #selectors = {}
/**
* Initializes the account selectors.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @return {{debit: AccountSelector, credit: AccountSelector}}
*/ */
static initialize() { static getInstances(entryEditor) {
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal")); const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) { for (const modal of modals) {
const selector = new AccountSelector(modal); selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
this.#selectors[selector.#entryType] = selector;
} }
this.#initializeTransactionForm(); return selectors;
}
/**
* Initializes the transaction form.
*
*/
static #initializeTransactionForm() {
const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.onclick = () => this.#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

@ -24,152 +24,151 @@
// Initializes the page JavaScript. // Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
document.getElementById("accounting-code") CurrencyForm.initialize();
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
}); });
/** /**
* The asynchronous validation result * The currency form.
* @type {object}
* @private
*/
let isAsyncValid = {};
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
* *
* @private * @private
*/ */
function submitFormIfAllAsyncValid() { class CurrencyForm {
let isValid = true;
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
}
if (isValid) {
document.getElementById("accounting-form").submit()
}
}
/** /**
* Validates the code. * The form.
* * @type {HTMLFormElement}
* @param changeEvent {Event} the change event, if invoked from onchange */
* @returns {boolean} true if valid, or false otherwise #formElement;
* @private
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
return true;
}
/** /**
* Validates asynchronously whether the code is duplicated. * The code
* The boolean validation result is stored in isAsyncValid[key]. * @type {HTMLInputElement}
* */
* @param isSubmission {boolean} whether this is invoked from a form submission #code;
* @param key {string} the key to store the result in isAsyncValid
* @private /**
*/ * The error message of the code
function validateAsyncCodeIsDuplicated(isSubmission, key) { * @type {HTMLDivElement}
const field = document.getElementById("accounting-code"); */
const error = document.getElementById("accounting-code-error"); #codeError;
const url = field.dataset.existsUrl;
const onLoad = function () { /**
if (this.status === 200) { * The name
const result = JSON.parse(this.responseText); * @type {HTMLInputElement}
if (result["exists"]) { */
field.classList.add("is-invalid"); #name;
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) { /**
isAsyncValid[key] = false; * The error message of the name
* @type {HTMLDivElement}
*/
#nameError;
/**
* Constructs the currency form.
*
*/
constructor() {
this.#formElement = document.getElementById("accounting-form");
this.#code = document.getElementById("accounting-code");
this.#codeError = document.getElementById("accounting-code-error");
this.#name = document.getElementById("accounting-name");
this.#nameError = document.getElementById("accounting-name-error");
this.#code.onchange = () => {
this.#validateCode().then();
};
this.#name.onchange = () => {
this.#validateName();
};
this.#formElement.onsubmit = () => {
this.#validateForm().then((isValid) => {
if (isValid) {
this.#formElement.submit();
} }
return; });
} return false;
field.classList.remove("is-invalid"); };
error.innerText = ""; }
if (isSubmission) {
isAsyncValid[key] = true; /**
submitFormIfAllAsyncValid(); * Validates the form.
*
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
async #validateForm() {
let isValid = true;
isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid;
return isValid;
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
async #validateCode(changeEvent = null) {
this.#code.value = this.#code.value.trim();
if (this.#code.value === "") {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(this.#code.dataset.blocklist);
if (blocklist.includes(this.#code.value)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("This code is not available.");
return false;
}
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = this.#code.dataset.original;
if (original === "" || this.#code.value !== original) {
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
const data = await response.json();
if (data["exists"]) {
this.#code.classList.add("is-invalid");
this.#codeError.innerText = A_("Code conflicts with another currency.");
return false;
} }
} }
}; this.#code.classList.remove("is-invalid");
const request = new XMLHttpRequest(); this.#codeError.innerText = "";
request.onload = onLoad; return true;
request.open("GET", url + "?q=" + encodeURIComponent(field.value)); }
request.send();
} /**
* Validates the name.
/** *
* Validates the name. * @returns {boolean} true if valid, or false otherwise
* */
* @returns {boolean} true if valid, or false otherwise #validateName() {
* @private this.#name.value = this.#name.value.trim();
*/ if (this.#name.value === "") {
function validateName() { this.#name.classList.add("is-invalid");
const field = document.getElementById("accounting-name"); this.#nameError.innerText = A_("Please fill in the name.");
const error = document.getElementById("accounting-name-error"); return false;
field.value = field.value.trim(); }
if (field.value === "") { this.#name.classList.remove("is-invalid");
field.classList.add("is-invalid"); this.#nameError.innerText = "";
error.innerText = A_("Please fill in the name."); return true;
return false; }
/**
* The form
* @type {CurrencyForm}
*/
static #form;
/**
* Initializes the currency form.
*
*/
static initialize() {
this.#form = new CurrencyForm();
} }
field.classList.remove("is-invalid");
error.innerText = "";
return true;
} }

View File

@ -0,0 +1,596 @@
/* The Mia! Accounting Flask Project
* journal-entry-editor.js: The JavaScript for the journal entry editor
*/
/* 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
*/
"use strict";
/**
* The journal entry editor.
*
*/
class JournalEntryEditor {
/**
* The transaction form
* @type {TransactionForm}
*/
form;
/**
* The journal entry editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
entryType;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-entry-editor"
/**
* The container of the original entry
* @type {HTMLDivElement}
*/
#originalEntryContainer;
/**
* The control of the original entry
* @type {HTMLDivElement}
*/
#originalEntryControl;
/**
* The original entry
* @type {HTMLDivElement}
*/
#originalEntry;
/**
* The error message of the original entry
* @type {HTMLDivElement}
*/
#originalEntryError;
/**
* The delete button of the original entry
* @type {HTMLButtonElement}
*/
#originalEntryDelete;
/**
* The control of the summary
* @type {HTMLDivElement}
*/
#summaryControl;
/**
* The summary
* @type {HTMLDivElement}
*/
#summary;
/**
* The error message of the summary
* @type {HTMLDivElement}
*/
#summaryError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#account;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amount;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry to edit
* @type {JournalEntrySubForm|null}
*/
entry;
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
*/
#side;
/**
* Whether the journal entry needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original entry
* @type {string|null}
*/
originalEntryId = null;
/**
* The date of the original entry
* @type {string|null}
*/
originalEntryDate = null;
/**
* The text of the original entry
* @type {string|null}
*/
originalEntryText = null;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The summary
* @type {string|null}
*/
summary = null;
/**
* The amount
* @type {string}
*/
amount = "";
/**
* The summary editors
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
#summaryEditors;
/**
* The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
#accountSelectors;
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
originalEntrySelector;
/**
* Constructs a new journal entry editor.
*
* @param form {TransactionForm} the transaction form
*/
constructor(form) {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#account = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#summaryEditors = SummaryEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalEntrySelector = new OriginalEntrySelector();
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId)
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
this.#amount.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.entry === null) {
this.entry = this.#side.addJournalEntry();
}
this.amount = this.#amount.value;
this.entry.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original entry from the original entry selector.
*
* @param originalEntry {OriginalEntry} the original entry
*/
saveOriginalEntry(originalEntry) {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.originalEntryId = originalEntry.id;
this.originalEntryDate = originalEntry.date;
this.originalEntryText = originalEntry.text;
this.#originalEntry.innerText = originalEntry.text;
this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
this.#summary.innerText = originalEntry.summary;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalEntry.accountCode;
this.accountText = originalEntry.accountText;
this.#account.innerText = originalEntry.accountText;
this.#amount.value = String(originalEntry.netBalance);
this.#amount.max = String(originalEntry.netBalance);
this.#amount.min = "0";
this.#validate();
}
/**
* Clears the original entry.
*
*/
clearOriginalEntry() {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#amount.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#side.currency.getCurrencyCode();
}
/**
* Saves the summary from the summary editor.
*
* @param summary {string} the summary
*/
saveSummary(summary) {
if (summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = summary === ""? null: summary;
this.#summary.innerText = summary;
this.#validateSummary();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the summary with the suggested account from the summary editor.
*
* @param summary {string} the summary
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise
*/
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#account.innerText = accountText;
this.#validateAccount();
this.saveSummary(summary)
}
/**
* Clears the account.
*
*/
clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the journal entries in the account need offset or false otherwise
*/
saveAccount(code, text, isNeedOffset) {
this.isNeedOffset = isNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = code;
this.accountText = text;
this.#account.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalEntry() && isValid;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original entry.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalEntry() {
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = "";
return true;
}
/**
* Validates the summary.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateSummary() {
this.#summary.classList.remove("is-invalid");
this.#summaryError.innerText = "";
return true;
}
/**
* Validates the account.
*
* @return {boolean} true if valid, or false otherwise
*/
#validateAccount() {
if (this.accountCode === null) {
this.#accountControl.classList.add("is-invalid");
this.#accountError.innerText = A_("Please select the account.");
return false;
}
this.#accountControl.classList.remove("is-invalid");
this.#accountError.innerText = "";
return true;
}
/**
* Validates the amount.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateAmount() {
this.#amount.value = this.#amount.value.trim();
this.#amount.classList.remove("is-invalid");
if (this.#amount.value === "") {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amount.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amount.max !== "") {
if (amount.greaterThan(new Decimal(this.#amount.max))) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
return false;
}
}
if (this.#amount.min !== "") {
const min = new Decimal(this.#amount.min);
if (amount.lessThan(min)) {
this.#amount.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
return false;
}
}
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
return true;
}
/**
* The callback when adding a new journal entry.
*
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
*/
onAddNew(side) {
this.entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntry.innerText = "";
this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid");
this.summary = null;
this.#summary.innerText = ""
this.#summaryError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.accountCode = null;
this.accountText = null;
this.#account.innerText = "";
this.#accountError.innerText = "";
this.#amount.value = "";
this.#amount.max = "";
this.#amount.min = "0";
this.#amount.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* The callback when editing a journal entry.
*
* @param entry {JournalEntrySubForm} the journal entry sub-form
*/
onEdit(entry) {
this.entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.isNeedOffset = entry.isNeedOffset();
this.originalEntryId = entry.getOriginalEntryId();
this.originalEntryDate = entry.getOriginalEntryDate();
this.originalEntryText = entry.getOriginalEntryText();
this.#originalEntry.innerText = this.originalEntryText;
if (this.originalEntryId === null) {
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
} else {
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
}
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
this.summary = entry.getSummary();
if (this.summary === null) {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summary.innerText = this.summary === null? "": this.summary;
if (entry.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = entry.getAccountCode();
this.accountText = entry.getAccountText();
this.#account.innerText = this.accountText;
this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount());
const maxAmount = this.#getMaxAmount();
this.#amount.max = maxAmount === null? "": maxAmount;
this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin());
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalEntryId === null) {
return null;
}
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId);
}
/**
* Sets the enable status of the summary and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableSummaryAccount(isEnabled) {
if (isEnabled) {
this.#summaryControl.dataset.bsToggle = "modal";
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
this.#summaryControl.classList.remove("accounting-disabled");
this.#summaryControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#summaryControl.dataset.bsToggle = "";
this.#summaryControl.dataset.bsTarget = "";
this.#summaryControl.classList.add("accounting-disabled");
this.#summaryControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled");
this.#accountControl.classList.remove("accounting-clickable");
}
}
}

View File

@ -0,0 +1,417 @@
/* The Mia! Accounting Flask Project
* original-entry-selector.js: The JavaScript for the original entry 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/3/10
*/
"use strict";
/**
* The original entry selector.
*
*/
class OriginalEntrySelector {
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-entry-selector";
/**
* The modal of the original entry editor
* @type {HTMLDivElement}
*/
#modal;
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {OriginalEntry[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalEntry>}
*/
#optionById;
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
entryEditor;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The entry
*/
#entryType;
/**
* Constructs an original entry selector.
*
*/
constructor() {
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element));
this.#optionById = {};
for (const option of this.#options) {
this.#optionById[option.id] = option;
}
this.#query.addEventListener("input", () => {
this.#filterOptions();
});
}
/**
* Returns the net balance for an original entry.
*
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
* @param form {TransactionForm} the transaction form
* @param originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
*/
getNetBalance(currentEntry, form, originalEntryId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) {
if (otherEntry.getOriginalEntryId() === originalEntryId) {
const amount = otherEntry.getAmount();
if (amount !== null) {
otherOffset = otherOffset.plus(amount);
}
}
}
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
}
/**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
*
*/
#updateNetBalances() {
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry);
const otherOffsets = {}
for (const otherEntry of otherEntries) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
const amount = otherEntry.getAmount();
if (otherOriginalEntryId === null || amount === null) {
continue;
}
if (!(otherOriginalEntryId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0");
}
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
}
for (const option of this.#options) {
if (option.id in otherOffsets) {
option.updateNetBalance(otherOffsets[option.id]);
} else {
option.resetNetBalance();
}
}
}
/**
* Filters the options.
*
*/
#filterOptions() {
let hasAnyMatched = false;
for (const option of this.#options) {
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) {
option.setShown(true);
hasAnyMatched = true;
} else {
option.setShown(false);
}
}
if (!hasAnyMatched) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {
this.#optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none");
}
}
/**
* The callback when the original entry selector is shown.
*
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @param originalEntryId {string|null} the ID of the original entry
*/
onOpen(entryEditor, originalEntryId = null) {
this.entryEditor = entryEditor
this.#currencyCode = entryEditor.getCurrencyCode();
this.#entryType = entryEditor.entryType;
for (const option of this.#options) {
option.setActive(option.id === originalEntryId);
}
this.#query.value = "";
this.#updateNetBalances();
this.#filterOptions();
}
}
/**
* An original entry.
*
*/
class OriginalEntry {
/**
* The original entry selector
* @type {OriginalEntrySelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The ID
* @type {string}
*/
id;
/**
* The date
* @type {string}
*/
date;
/**
* The entry type, either "debit" or "credit"
* @type {string}
*/
#entryType;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The account code
* @type {string}
*/
accountCode;
/**
* The account text
* @type {string}
*/
accountText;
/**
* The summary
* @type {string}
*/
summary;
/**
* The net balance, without the offset amounts on the form
* @type {Decimal}
*/
bareNetBalance;
/**
* The net balance
* @type {Decimal}
*/
netBalance;
/**
* The text of the net balance
* @type {HTMLSpanElement}
*/
netBalanceText;
/**
* The text representation of the original entry
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[][]}
*/
#queryValues;
/**
* Constructs an original entry.
*
* @param selector {OriginalEntrySelector} the original entry selector
* @param element {HTMLLIElement} the element
*/
constructor(selector, element) {
this.#selector = selector;
this.#element = element;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#entryType = element.dataset.entryType;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this);
}
/**
* Resets the net balance to its initial value, without the offset amounts on the form.
*
*/
resetNetBalance() {
if (this.netBalance !== this.bareNetBalance) {
this.netBalance = this.bareNetBalance;
this.#updateNetBalanceText();
}
}
/**
* Updates the net balance with an offset.
*
* @param offset {Decimal} the offset to be added to the net balance
*/
updateNetBalance(offset) {
this.netBalance = this.bareNetBalance.minus(offset);
this.#updateNetBalanceText();
}
/**
* Updates the text display of the net balance.
*
*/
#updateNetBalanceText() {
this.netBalanceText.innerText = formatDecimal(this.netBalance);
}
/**
* Returns whether the original matches.
*
* @param entryType {string} the entry type, either "debit" or "credit"
* @param currencyCode {string} the currency code
* @param query {string|null} the query term
*/
isMatched(entryType, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.form.getDate()
&& this.#isEntryTypeMatches(entryType)
&& this.#currencyCode === currencyCode
&& this.#isQueryMatches(query);
}
/**
* Returns whether the original entry matches the entry type.
*
* @param entryType {string} the entry type, either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise
*/
#isEntryTypeMatches(entryType) {
return (entryType === "debit" && this.#entryType === "credit")
|| (entryType === "credit" && this.#entryType === "debit");
}
/**
* Returns whether the original entry matches the query.
*
* @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise
*/
#isQueryMatches(query) {
if (query === "") {
return true;
}
for (const queryValue of this.#queryValues[0]) {
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
return true;
}
}
for (const queryValue of this.#queryValues[1]) {
if (queryValue === query) {
return true;
}
}
return false;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
} else {
this.#element.classList.remove("active");
}
}
}

View File

@ -22,17 +22,18 @@
*/ */
"use strict"; "use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
SummaryEditor.initialize();
});
/** /**
* A summary editor. * A summary editor.
* *
*/ */
class SummaryEditor { class SummaryEditor {
/**
* The journal entry editor
* @type {JournalEntryEditor}
*/
#entryEditor;
/** /**
* The summary editor form * The summary editor form
* @type {HTMLFormElement} * @type {HTMLFormElement}
@ -55,28 +56,34 @@ class SummaryEditor {
* The entry type, either "debit" or "credit" * The entry type, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
#entryType; entryType;
/** /**
* The current tab. * The current tab
* @type {TabPlane} * @type {TabPlane}
*/ */
currentTab; currentTab;
/** /**
* The summary input. * The summary input
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
summary; summary;
/** /**
* The number input. * The button to the original entry selector
* @type {HTMLButtonElement}
*/
#offsetButton;
/**
* The number input
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
number; number;
/** /**
* The note. * The note
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
note; note;
@ -93,36 +100,6 @@ class SummaryEditor {
*/ */
#selectedAccount = null; #selectedAccount = null;
/**
* The modal of the journal entry form
* @type {HTMLDivElement}
*/
#entryFormModal;
/**
* The control of the account on the journal entry form
* @type {HTMLDivElement}
*/
#formAccountControl;
/**
* The account on the journal entry form
* @type {HTMLDivElement}
*/
#formAccount;
/**
* The control of the summary on the journal entry form
* @type {HTMLDivElement}
*/
#formSummaryControl;
/**
* The summary on the journal entry form
* @type {HTMLDivElement}
*/
#formSummary;
/** /**
* The tab planes * The tab planes
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}} * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
@ -132,26 +109,22 @@ class SummaryEditor {
/** /**
* Constructs a summary editor. * Constructs a summary editor.
* *
* @param form {HTMLFormElement} the summary editor form * @param entryEditor {JournalEntryEditor} the journal entry editor
* @param entryType {string} the entry type, either "debit" or "credit"
*/ */
constructor(form) { constructor(entryEditor, entryType) {
this.#form = form; this.#entryEditor = entryEditor;
this.#entryType = form.dataset.entryType; this.entryType = entryType;
this.prefix = "accounting-summary-editor-" + form.dataset.entryType; this.prefix = "accounting-summary-editor-" + entryType;
this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(this.prefix + "-modal"); this.#modal = document.getElementById(this.prefix + "-modal");
this.summary = document.getElementById(this.prefix + "-summary"); this.summary = document.getElementById(this.prefix + "-summary");
this.#offsetButton = document.getElementById(this.prefix + "-offset");
this.number = document.getElementById(this.prefix + "-annotation-number"); this.number = document.getElementById(this.prefix + "-annotation-number");
this.note = document.getElementById(this.prefix + "-annotation-note"); this.note = document.getElementById(this.prefix + "-annotation-note");
// noinspection JSValidateTypes // noinspection JSValidateTypes
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account")); this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
// Things from the entry form
this.#entryFormModal = document.getElementById("accounting-entry-form-modal");
this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
this.#formAccount = document.getElementById("accounting-entry-form-account");
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
this.#formSummary = document.getElementById("accounting-entry-form-summary");
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) { for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
const tab = new cls(this); const tab = new cls(this);
this.tabPlanes[tab.tabId()] = tab; this.tabPlanes[tab.tabId()] = tab;
@ -159,6 +132,7 @@ class SummaryEditor {
this.currentTab = this.tabPlanes.general; this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts(); this.#initializeSuggestedAccounts();
this.summary.onchange = () => this.#onSummaryChange(); this.summary.onchange = () => this.#onSummaryChange();
this.#offsetButton.onclick = () => this.#entryEditor.originalEntrySelector.onOpen(this.#entryEditor);
this.#form.onsubmit = () => { this.#form.onsubmit = () => {
if (this.currentTab.validate()) { if (this.currentTab.validate()) {
this.#submit(); this.#submit();
@ -239,30 +213,21 @@ class SummaryEditor {
* *
*/ */
#submit() { #submit() {
if (this.summary.value === "") {
this.#formSummaryControl.classList.remove("accounting-not-empty");
} else {
this.#formSummaryControl.classList.add("accounting-not-empty");
}
if (this.#selectedAccount !== null) {
this.#formAccountControl.classList.add("accounting-not-empty");
this.#formAccount.dataset.code = this.#selectedAccount.dataset.code;
this.#formAccount.dataset.text = this.#selectedAccount.dataset.text;
this.#formAccount.innerText = this.#selectedAccount.dataset.text;
}
this.#formSummary.dataset.value = this.summary.value;
this.#formSummary.innerText = this.summary.value;
bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show(); if (this.#selectedAccount !== null) {
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else {
this.#entryEditor.saveSummary(this.summary.value);
}
} }
/** /**
* The callback when the summary editor is shown. * The callback when the summary editor is shown.
* *
*/ */
#onOpen() { onOpen() {
this.#reset(); this.#reset();
this.summary.value = this.#formSummary.dataset.value; this.summary.value = this.#entryEditor.summary === null? "": this.#entryEditor.summary;
this.#onSummaryChange(); this.#onSummaryChange();
} }
@ -279,33 +244,18 @@ class SummaryEditor {
} }
/** /**
* The summary editors. * Returns the summary editor instances.
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
static #editors = {}
/**
* Initializes the summary editors.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor
* @return {{debit: SummaryEditor, credit: SummaryEditor}}
*/ */
static initialize() { static getInstances(entryEditor) {
const editors = {}
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor")); const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
const entryForm = document.getElementById("accounting-entry-form");
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
for (const form of forms) { for (const form of forms) {
const editor = new SummaryEditor(form); editors[form.dataset.entryType] = new SummaryEditor(entryEditor, form.dataset.entryType);
this.#editors[editor.#entryType] = editor;
} }
formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen() return editors;
}
/**
* Initializes the summary editor for a new journal entry.
*
* @param entryType {string} the entry type, either "debit" or "credit"
*/
static initializeNewJournalEntry(entryType) {
this.#editors[entryType].#onOpen();
} }
} }
@ -547,7 +497,7 @@ class TagTabPlane extends TabPlane {
errorContainer.innerText = ""; errorContainer.innerText = "";
return true; return true;
} }
/** /**
* Resets the tab plane input. * Resets the tab plane input.
* *

File diff suppressed because it is too large Load Diff

View File

@ -85,7 +85,7 @@ 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_need_offset %}
<div> <div>
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span> <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
</div> </div>

View File

@ -41,9 +41,9 @@ 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="{{ form.base_code.data|accounting_default }}"> <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
<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"> <div id="accounting-base-control" 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">
{% if form.base_code.data %} {% if form.base_code.data %}
{% if form.base_code.errors %} {% if form.base_code.errors %}
{{ A_("(Unknown)") }} {{ A_("(Unknown)") }}
@ -53,7 +53,7 @@ First written: 2023/2/1
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div> <div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div> </div>
<div class="form-floating mb-3"> <div class="form-floating mb-3">
@ -62,9 +62,9 @@ First written: 2023/2/1
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div> </div>
<div class="form-check form-switch mb-3"> <div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
<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-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-offset-needed"> <label class="form-check-label" for="accounting-is-need-offset">
{{ A_("The entries in the account need offset.") }} {{ A_("The entries in the account need offset.") }}
</label> </label>
</div> </div>
@ -99,21 +99,21 @@ First written: 2023/2/1
</label> </label>
</div> </div>
<ul id="accounting-base-option-list" class="list-group accounting-selector-list"> <ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
{% for base in form.base_options %} {% for base in form.base_options %}
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}"> <li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
{{ base }} {{ base }}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> <p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %} {% if form.base_code.data %}
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button> <button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
{% else %} {% else %}
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button> <button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@ -58,7 +58,7 @@ 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_need_offset %}
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span> <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
{% endif %} {% endif %}
</a> </a>

View File

@ -23,4 +23,6 @@ First written: 2023/3/8
<div>{{ entry.summary|accounting_default }}</div> <div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> {% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% endif %}

View File

@ -37,5 +37,7 @@ First written: 2023/3/5
{% if entry.credit %} {% if entry.credit %}
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
{% endif %} {% endif %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span> {% if report.account.is_real %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
{% endif %}
</div> </div>

View File

@ -49,14 +49,16 @@ First written: 2023/3/5
{% include "accounting/include/pagination.html" %} {% include "accounting/include/pagination.html" %}
{% endwith %} {% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-ledger-table"> <div class="d-none d-md-block accounting-report-table {% if report.account.is_real %} accounting-ledger-real-table {% else %} accounting-ledger-nominal-table {% endif %}">
<div class="accounting-report-table-header"> <div class="accounting-report-table-header">
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div> <div>{{ A_("Date") }}</div>
<div>{{ A_("Summary") }}</div> <div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div> <div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div> <div class="accounting-amount">{{ A_("Credit") }}</div>
<div class="accounting-amount">{{ A_("Balance") }}</div> {% if report.account.is_real %}
<div class="accounting-amount">{{ A_("Balance") }}</div>
{% endif %}
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
@ -80,7 +82,9 @@ First written: 2023/3/5
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> {% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
{% endif %}
</div> </div>
</div> </div>
{% endwith %} {% endwith %}

View File

@ -35,19 +35,9 @@ First written: 2023/2/26
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <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> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %} {% with entries = currency.debit %}
<li class="list-group-item accounting-transaction-entry"> {% include "accounting/transaction/include/detail-entries.html" %}
<div class="d-flex justify-content-between"> {% endwith %}
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>

View File

@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 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 }}"> <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> <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="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content"> <div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code"> <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %} {% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ 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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div> </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 }}"> <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i> <i class="fas fa-minus"></i>
</button> </button>
</div> </div>
@ -55,9 +56,17 @@ First written: 2023/2/25
account_text = entry_form.account_text, account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
@ -70,7 +79,7 @@ First written: 2023/2/25
</div> </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"> <button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors, currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data, currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors, currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit, debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors, debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount %} debit_total = currency_form.form.debit_total|accounting_format_amount %}

View File

@ -19,12 +19,12 @@ account-selector-modal.html: The modal for the account selector
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 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 id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector" 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-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-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1> <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> <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-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">
@ -37,17 +37,17 @@ First written: 2023/2/25
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list"> <ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %} {% 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"> <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 %} {% if account.is_need_offset %} accounting-account-is-need-offset {% 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-editor-modal">
{{ account }} {{ account }}
</li> </li>
{% endfor %} {% endfor %}
<li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> <li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul> </ul>
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> <p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div> </div>
<div class="modal-footer"> <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 type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-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> <button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Clear") }}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,75 @@
{#
The Mia! Accounting Flask Project
detail-entries-item: The journal entries in the transaction 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/3/14
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %}
<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 %}
{% if entry.original_entry %}
<div class="fst-italic small accounting-original-entry">
<a href="{{ url_for("accounting.transaction.detail", txn=entry.original_entry.transaction)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }}
</a>
</div>
{% endif %}
{% if entry.is_need_offset %}
<div class="fst-italic small accounting-offset-entries">
{% if entry.offsets %}
<div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in entry.offsets %}
<li>
<a href="{{ url_for("accounting.transaction.detail", txn=offset.transaction)|accounting_append_next }}">
{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% if entry.balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ entry.balance|accounting_format_amount }}</div>
</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Fully offset") }}</div>
</div>
{% endif %}
{% else %}
<div class="d-flex justify-content-between">
{{ A_("Unmatched") }}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -42,10 +42,17 @@ First written: 2023/2/26
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %} {% block to_transfer %}{% endblock %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> {% if obj.can_delete %}
<i class="fa-solid fa-trash"></i> <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
{{ A_("Delete") }} <i class="fa-solid fa-trash"></i>
</button> {{ A_("Delete") }}
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
{% endif %} {% endif %}
</div> </div>
@ -57,7 +64,7 @@ First written: 2023/2/26
</div> </div>
{% endif %} {% endif %}
{% if accounting_can_edit() %} {% if accounting_can_edit() and obj.can_delete %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post"> <form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %} {% if request.args.next %}

View File

@ -1,60 +0,0 @@
{#
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

@ -20,19 +20,45 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
{# <ul> For SonarQube not to complain about incorrect HTML #} {# <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 }}"> <li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ entry_type }} {% if offset_entries %} accounting-matched-entry {% endif %}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" {% if is_need_offset %} data-is-need-offset="true" {% endif %}>
{% if entry_id %} {% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %} {% 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 }}-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 }}-original-entry-id" class="accounting-original-entry-id" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original_entry_id" value="{{ original_entry_id_data }}" data-date="{{ original_entry_date }}" data-text="{{ original_entry_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-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 }}-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 }}"> <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}" data-min="{{ offset_total }}">
<div class="accounting-entry-content"> <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 id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div> <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 }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div> <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-text" class="fst-italic small accounting-original-entry {% if not original_entry_text %} d-none {% endif %}">
{% if original_entry_text %}{{ A_("Offset %(entry)s", entry=original_entry_text) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-offsets" class="fst-italic small accounting-offset-entries {% if not is_need_offset %} d-none {% endif %}">
{% if offset_entries %}
<div class="d-flex justify-content-between {% if not offset_entries %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in offset_entries %}
<li>{{ offset.transaction.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if net_balance_data == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ net_balance_text }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</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><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div> </div>
@ -40,7 +66,7 @@ First written: 2023/2/25
</div> </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"> <button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form or offset_entries %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<i class="fas fa-minus"></i> <i class="fas fa-minus"></i>
</button> </button>
</div> </div>

View File

@ -24,7 +24,9 @@ First written: 2023/2/26
{% block accounting_scripts %} {% 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/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/transaction-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-entry-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
{% endblock %} {% endblock %}
@ -44,7 +46,7 @@ First written: 2023/2/26
{% endif %} {% endif %}
<div class="form-floating mb-3"> <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="{{ form.date.data|accounting_default }}" placeholder=" " required="required"> <input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" max="{{ form.max_date|accounting_default }}" min="{{ form.min_date|accounting_default }}" placeholder=" " required="required">
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label> <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 id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
</div> </div>
@ -57,7 +59,7 @@ First written: 2023/2/26
</div> </div>
<div> <div>
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button"> <button id="accounting-add-currency" class="btn btn-primary" type="button">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
@ -86,7 +88,8 @@ First written: 2023/2/26
</div> </div>
</form> </form>
{% include "accounting/transaction/include/entry-form-modal.html" %} {% include "accounting/transaction/include/journal-entry-editor-modal.html" %}
{% block form_modals %}{% endblock %} {% block form_modals %}{% endblock %}
{% include "accounting/transaction/include/original-entry-selector-modal.html" %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,76 @@
{#
The Mia! Accounting Flask Project
journal-entry-editor-modal.html: The modal of the journal entry editor
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-editor">
<div id="accounting-entry-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-editor-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-editor-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 id="accounting-entry-editor-original-entry-container" class="d-flex justify-content-between mb-3">
<div class="accounting-entry-editor-original-entry-content">
<div id="accounting-entry-editor-original-entry-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal">
<label class="form-label" for="accounting-entry-editor-original-entry">{{ A_("Original Entry") }}</label>
<div id="accounting-entry-editor-original-entry"></div>
</div>
<div id="accounting-entry-editor-original-entry-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-entry-editor-original-entry-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-entry-editor-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-editor-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-editor-summary"></div>
</div>
<div id="accounting-entry-editor-summary-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-entry-editor-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-editor-account">{{ A_("Account") }}</label>
<div id="accounting-entry-editor-account"></div>
</div>
<div id="accounting-entry-editor-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-editor-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 type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,56 @@
{#
The Mia! Accounting Flask Project
original-entry-selector-modal.html: The modal of the original entry 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-original-entry-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-entry-selector-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-original-entry-selector-modal-label">{{ A_("Select Original Entry") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-entry-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-original-entry-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-entry-selector-option-list" class="list-group accounting-selector-list">
{% for entry in form.original_entry_options %}
<li id="accounting-original-entry-selector-option-{{ entry.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-entry-selector-option" data-id="{{ entry.id }}" data-date="{{ entry.transaction.date }}" data-entry-type="{{ "debit" if entry.is_debit else "credit" }}" data-currency-code="{{ entry.currency.code }}" data-account-code="{{ entry.account_code }}" data-account-text="{{ entry.account }}" data-summary="{{ entry.summary|accounting_default }}" data-net-balance="{{ entry.net_balance|accounting_txn_format_amount_input }}" data-text="{{ entry }}" data-query-values="{{ entry.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>{{ entry.transaction.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div>
<div>
<span class="badge bg-primary rounded-pill">
<span id="accounting-original-entry-selector-option-{{ entry.id }}-net-balance">{{ entry.net_balance|accounting_format_amount }}</span>
/ {{ entry.amount|accounting_format_amount }}
</span>
</div>
</li>
{% endfor %}
</ul>
<p id="accounting-original-entry-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
</div>

View File

@ -27,11 +27,14 @@ First written: 2023/2/28
<h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> <h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
<label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label> <label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label>
</h1> </h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="d-flex justify-content-between mb-3">
<input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> <input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
<button id="accounting-summary-editor-{{ summary_editor.type }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal">
{{ A_("Offset...") }}
</button>
</div> </div>
{# Tab navigation #} {# Tab navigation #}
@ -174,14 +177,14 @@ First written: 2023/2/28
{# The suggested accounts #} {# The suggested accounts #}
<div class="mt-3"> <div class="mt-3">
{% for account in summary_editor.accounts %} {% for account in summary_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> <button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }} {{ account }}
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button> <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> <button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div> </div>
</div> </div>

View File

@ -35,19 +35,9 @@ First written: 2023/2/26
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <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> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.credit %} {% with entries = currency.credit %}
<li class="list-group-item accounting-transaction-entry"> {% include "accounting/transaction/include/detail-entries.html" %}
<div class="d-flex justify-content-between"> {% endwith %}
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>

View File

@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash income transaction for
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 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 }}"> <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> <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="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content"> <div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code"> <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %} {% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ 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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div> </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 }}"> <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i> <i class="fas fa-minus"></i>
</button> </button>
</div> </div>
@ -55,9 +56,17 @@ First written: 2023/2/25
account_text = entry_form.account_text, account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
@ -70,7 +79,7 @@ First written: 2023/2/25
</div> </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"> <button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors, currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data, currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors, currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
credit_forms = currency_form.credit, credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors, credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_format_amount %} credit_total = currency_form.form.credit_total|accounting_format_amount %}

View File

@ -31,19 +31,9 @@ First written: 2023/2/26
<div class="col-sm-6 mb-2"> <div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <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> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
{% for entry in currency.debit %} {% with entries = currency.debit %}
<li class="list-group-item accounting-transaction-entry"> {% include "accounting/transaction/include/detail-entries.html" %}
<div class="d-flex justify-content-between"> {% endwith %}
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
@ -57,19 +47,9 @@ First written: 2023/2/26
<div class="col-sm-6 mb-2"> <div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <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> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
{% for entry in currency.credit %} {% with entries = currency.credit %}
<li class="list-group-item accounting-transaction-entry"> {% include "accounting/transaction/include/detail-entries.html" %}
<div class="d-flex justify-content-between"> {% endwith %}
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>

View File

@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the transfer transaction form
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 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 }}"> <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> <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="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content"> <div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code"> <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
{% for currency in accounting_currency_options() %} {% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %} {% endfor %}
</select> </select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ 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 id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div> </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 }}"> <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i> <i class="fas fa-minus"></i>
</button> </button>
</div> </div>
@ -57,9 +58,17 @@ First written: 2023/2/25
account_text = entry_form.account_text, account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default,
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
@ -72,7 +81,7 @@ First written: 2023/2/25
</div> </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"> <button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
@ -88,18 +97,26 @@ First written: 2023/2/25
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %} {% for entry_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit", entry_type = "credit",
entry_index = loop.index, entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text, account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input, amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"), amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %} {% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
@ -112,7 +129,7 @@ First written: 2023/2/25
</div> </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"> <button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -29,6 +29,7 @@ First written: 2023/2/25
currency_errors = currency_form.whole_form.errors, currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data, currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors, currency_code_errors = currency_form.code.errors,
currency_code_is_locked = currency_form.is_code_locked,
debit_forms = currency_form.debit, debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors, debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_format_amount, debit_total = currency_form.form.debit_total|accounting_format_amount,

View File

@ -20,10 +20,10 @@
from datetime import date from datetime import date
from flask import abort from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from accounting.models import Transaction, JournalEntry
from accounting.models import Transaction
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
@ -37,7 +37,13 @@ class TransactionConverter(BaseConverter):
:param value: The transaction ID. :param value: The transaction ID.
:return: The corresponding transaction. :return: The corresponding transaction.
""" """
transaction: Transaction | None = db.session.get(Transaction, value) transaction: Transaction | None = Transaction.query\
.join(JournalEntry)\
.filter(Transaction.id == value)\
.options(selectinload(Transaction.entries)
.selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction))\
.first()
if transaction is None: if transaction is None:
abort(404) abort(404)
return transaction return transaction

View File

@ -0,0 +1,22 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 .reorder import sort_transactions_in, TransactionReorderForm
from .transaction import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm

View File

@ -0,0 +1,294 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 currency sub-forms for the transaction management.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, JournalEntry
from accounting.transaction.utils.offset_alias import offset_alias
from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty."""
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 SameCurrencyAsOriginalEntries:
"""The validator to check if the currency is the same as the original
entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
original_entry_id: set[int] = {x.original_entry_id.data
for x in form.entries
if x.original_entry_id.data is not None}
if len(original_entry_id) == 0:
return
original_entry_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code)
.filter(JournalEntry.id.in_(original_entry_id))).all())
for currency_code in original_entry_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
"The currency must be the same as the original entry."))
class KeepCurrencyWhenHavingOffset:
"""The validator to check if the currency is the same when there is
offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
offset: sa.Alias = offset_alias()
original_entries: list[JournalEntry] = JournalEntry.query\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
isouter=True)\
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries
if x.eid.data is not None}))\
.group_by(JournalEntry.id, JournalEntry.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
for original_entry in original_entries:
if original_entry.currency_code != field.data:
raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some journal entries."))
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, TransferCurrencyForm)
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."))
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."""
@property
def entries(self) -> list[JournalEntryForm]:
"""Returns the journal entry sub-forms.
:return: The journal entry sub-forms.
"""
entry_forms: list[JournalEntryForm] = []
if isinstance(self, IncomeCurrencyForm):
entry_forms.extend([x.form for x in self.credit])
elif isinstance(self, ExpenseCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit])
entry_forms.extend([x.form for x in self.credit])
return entry_forms
@property
def is_code_locked(self) -> bool:
"""Returns whether the currency code should not be changed.
:return: True if the currency code should not be changed, or False
otherwise
"""
entry_forms: list[JournalEntryForm] = self.entries
original_entry_id: set[int] \
= {x.original_entry_id.data for x in entry_forms
if x.original_entry_id.data is not None}
if len(original_entry_id) > 0:
return True
entry_id: set[int] = {x.eid.data for x in entry_forms
if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\
.filter(JournalEntry.original_entry_id.in_(entry_id))
return db.session.scalar(select) > 0
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=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""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 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=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""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 TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalEntries(),
KeepCurrencyWhenHavingOffset()])
"""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)]

View File

@ -0,0 +1,524 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 journal entry sub-forms for the transaction management.
"""
import re
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import DataRequired, Optional
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry
from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class OriginalEntryExists:
"""The validator to check if the original entry exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
if db.session.get(JournalEntry, field.data) is None:
raise ValidationError(lazy_gettext(
"The original entry does not exist."))
class OriginalEntryOppositeSide:
"""The validator to check if the original entry is on the opposite side."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if isinstance(form, CreditEntryForm) and original_entry.is_debit:
return
if isinstance(form, DebitEntryForm) and not original_entry.is_debit:
return
raise ValidationError(lazy_gettext(
"The original entry is on the same side."))
class OriginalEntryNeedOffset:
"""The validator to check if the original entry needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if not original_entry.account.is_need_offset:
raise ValidationError(lazy_gettext(
"The original entry does not need offset."))
class OriginalEntryNotOffset:
"""The validator to check if the original entry is not itself an offset
entry."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, field.data)
if original_entry is None:
return
if original_entry.original_entry_id is not None:
raise ValidationError(lazy_gettext(
"The original entry cannot be an offset entry."))
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 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 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 SameAccountAsOriginalEntry:
"""The validator to check if the account is the same as the original
entry."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
return
if field.data != original_entry.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original entry."))
class KeepAccountWhenHavingOffset:
"""The validator to check if the account is the same when having offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.eid.data is None:
return
entry: JournalEntry | None = db.session.query(JournalEntry)\
.filter(JournalEntry.id == form.eid.data)\
.options(selectinload(JournalEntry.offsets)).first()
if entry is None or len(entry.offsets) == 0:
return
if field.data != entry.account_code:
raise ValidationError(lazy_gettext(
"The account must not be changed when there is offset."))
class NotStartPayableFromDebit:
"""The validator to check that a payable journal entry does not start from
the debit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitEntryForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_entry_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"A payable entry cannot start from the debit side."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable journal entry does not start
from the credit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditEntryForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_entry_id.data is not None:
return
account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext(
"A receivable entry cannot start from the credit side."))
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 NotExceedingOriginalEntryNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original entry."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.original_entry_id.data is None:
return
original_entry: JournalEntry | None \
= db.session.get(JournalEntry, form.original_entry_id.data)
if original_entry is None:
return
is_debit: bool = isinstance(form, DebitEntryForm)
existing_entry_id: set[int] = set()
if form.txn_form.obj is not None:
existing_entry_id = {x.id for x in form.txn_form.obj.entries}
offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntry.is_debit == is_debit), JournalEntry.amount),
else_=-JournalEntry.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(be(JournalEntry.original_entry_id == original_entry.id),
JournalEntry.id.not_in(existing_entry_id)))
if offset_total_but_form is None:
offset_total_but_form = Decimal("0")
offset_total_on_form: Decimal = sum(
[x.amount.data for x in form.txn_form.entries
if x.original_entry_id.data == original_entry.id
and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_entry.amount - offset_total_but_form \
- offset_total_on_form
if field.data > net_balance:
raise ValidationError(lazy_gettext(
"The amount must not exceed the net balance %(balance)s of the"
" original entry.", balance=format_amount(net_balance)))
class NotLessThanOffsetTotal:
"""The validator to check if the amount is less than the offset total."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm)
if field.data is None or form.eid.data is None:
return
is_debit: bool = isinstance(form, DebitEntryForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntry.is_debit != is_debit, JournalEntry.amount),
else_=-JournalEntry.amount)))\
.filter(be(JournalEntry.original_entry_id == form.eid.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext(
"The amount must not be less than the offset total %(total)s.",
total=format_amount(offset_total)))
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."""
original_entry_id = IntegerField()
"""The Id of the original entry."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
def __init__(self, *args, **kwargs):
"""Constructs a base transaction form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
from .transaction import TransactionForm
self.txn_form: TransactionForm | None = None
"""The source transaction form."""
@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 __original_entry(self) -> JournalEntry | None:
"""Returns the original entry.
:return: The original entry.
"""
if not hasattr(self, "____original_entry"):
def get_entry() -> JournalEntry | None:
if self.original_entry_id.data is None:
return None
return db.session.get(JournalEntry,
self.original_entry_id.data)
setattr(self, "____original_entry", get_entry())
return getattr(self, "____original_entry")
@property
def original_entry_date(self) -> date | None:
"""Returns the text representation of the original entry.
:return: The text representation of the original entry.
"""
return None if self.__original_entry is None \
else self.__original_entry.transaction.date
@property
def original_entry_text(self) -> str | None:
"""Returns the text representation of the original entry.
:return: The text representation of the original entry.
"""
return None if self.__original_entry is None \
else str(self.__original_entry)
@property
def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset.
:return: True if the entry needs offset, or False otherwise.
"""
if self.account_code.data is None:
return False
if self.account_code.data[0] == "1":
if isinstance(self, CreditEntryForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitEntryForm):
return False
else:
return False
account: Account | None = Account.find_by_code(self.account_code.data)
return account is not None and account.is_need_offset
@property
def offsets(self) -> list[JournalEntry]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntry]:
if not self.is_need_offset or self.eid.data is None:
return []
return JournalEntry.query\
.filter(JournalEntry.original_entry_id == self.eid.data)\
.options(selectinload(JournalEntry.transaction),
selectinload(JournalEntry.account),
selectinload(JournalEntry.offsets)
.selectinload(JournalEntry.transaction)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")
@property
def offset_total(self) -> Decimal | None:
"""Returns the total amount of the offsets.
:return: The total amount of the offsets.
"""
if not hasattr(self, "__offset_total"):
def get_offset_total():
if not self.is_need_offset or self.eid.data is None:
return None
is_debit: bool = isinstance(self, DebitEntryForm)
return sum([x.amount if x.is_debit != is_debit else -x.amount
for x in self.offsets])
setattr(self, "__offset_total", get_offset_total())
return getattr(self, "__offset_total")
@property
def net_balance(self) -> Decimal | None:
"""Returns the net balance.
:return: The net balance.
"""
if not self.is_need_offset or self.eid.data is None \
or self.amount.data is None:
return None
return self.amount.data - self.offset_total
@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."""
original_entry_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(),
SameAccountAsOriginalEntry(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
offset_original_entry_id = IntegerField()
"""The Id of the original entry."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotLessThanOffsetTotal()])
"""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.original_entry_id = self.original_entry_id.data
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 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."""
original_entry_id = IntegerField(
validators=[Optional(),
OriginalEntryExists(),
OriginalEntryOppositeSide(),
OriginalEntryNeedOffset(),
OriginalEntryNotOffset()])
"""The Id of the original entry."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(),
SameAccountAsOriginalEntry(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(),
NotLessThanOffsetTotal()])
"""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.original_entry_id = self.original_entry_id.data
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

View File

@ -0,0 +1,92 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 reorder forms for the transaction management.
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import Transaction
def sort_transactions_in(txn_date: date, exclude: int | None = None) -> 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.
"""
conditions: list[sa.BinaryExpression] = [Transaction.date == txn_date]
if exclude is not None:
conditions.append(Transaction.id != exclude)
transactions: list[Transaction] = Transaction.query\
.filter(*conditions)\
.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

@ -14,268 +14,95 @@
# 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 forms for the transaction management. """The transaction forms for the transaction management.
""" """
from __future__ import annotations import datetime as dt
import re
import typing as t import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import request
from flask_babel import LazyString from flask_babel import LazyString
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import DateField, StringField, FieldList, FormField, \ from wtforms import DateField, FieldList, FormField, TextAreaField, \
IntegerField, TextAreaField, DecimalField, BooleanField BooleanField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \ from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency TransactionCurrency
from accounting.transaction.summary_editor import SummaryEditor from accounting.transaction.utils.account_option import AccountOption
from accounting.transaction.utils.original_entries import \
get_selectable_original_entries
from accounting.transaction.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text, strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \
TransferCurrencyForm
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm
from .reorder import sort_transactions_in
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."""
DATE_REQUIRED: DataRequired = DataRequired( DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date.")) lazy_gettext("Please fill in the date."))
"""The validator to check if the date is empty.""" """The validator to check if the date is empty."""
class NotBeforeOriginalEntries:
"""The validator to check if the date is not before the original
entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm)
if field.data is None:
return
min_date: dt.date | None = form.min_date
if min_date is None:
return
if field.data < min_date:
raise ValidationError(lazy_gettext(
"The date cannot be earlier than the original entries."))
class NotAfterOffsetEntries:
"""The validator to check if the date is not after the offset entries."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, TransactionForm)
if field.data is None:
return
max_date: dt.date | None = form.max_date
if max_date is None:
return
if field.data > max_date:
raise ValidationError(lazy_gettext(
"The date cannot be later than the offset entries."))
class NeedSomeCurrencies: class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form.""" """The validator to check if there is any currency sub-form."""
def __call__(self, form: CurrencyForm, field: FieldList) \ def __call__(self, form: FlaskForm, field: FieldList) -> None:
-> None:
if len(field) == 0: if len(field) == 0:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext("Please add some currencies."))
"Please add some currencies."))
class CurrencyExists: class CannotDeleteOriginalEntriesWithOffset:
"""The validator to check if the account exists.""" """The validator to check the original entries with offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: FieldList) -> None:
if field.data is None: assert isinstance(form, TransactionForm)
if form.obj is None:
return return
if db.session.get(Currency, field.data) is None: existing_matched_original_entry_id: set[int] \
raise ValidationError(lazy_gettext( = {x.id for x in form.obj.entries if len(x.offsets) > 0}
"The currency does not exist.")) entry_id_in_form: set[int] \
= {x.eid.data for x in form.entries if x.eid.data is not None}
for entry_id in existing_matched_original_entry_id:
class NeedSomeJournalEntries: if entry_id not in entry_id_in_form:
"""The validator to check if there is any journal entry sub-form.""" raise ValidationError(lazy_gettext(
"Journal entries with offset cannot be deleted."))
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.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str
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): class TransactionForm(FlaskForm):
@ -300,8 +127,19 @@ class TransactionForm(FlaskForm):
"""The journal entry collector. The default is the base abstract """The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
self.__in_use_account_id: set[int] | None = None self.obj: Transaction | None = kwargs.get("obj")
"""The ID of the accounts that are in use.""" """The transaction, when editing an existing one."""
self._is_need_payable: bool = False
"""Whether we need the payable original entries."""
self._is_need_receivable: bool = False
"""Whether we need the receivable original entries."""
self.__original_entry_options: list[JournalEntry] | None = None
"""The options of the original entries."""
self.__net_balance_exceeded: dict[int, LazyString] | None = None
"""The original entries whose net balances were exceeded by the
amounts in the journal entry sub-forms."""
for entry in self.entries:
entry.txn_form = self
def populate_obj(self, obj: Transaction) -> None: def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object. """Populates the form data into a transaction object.
@ -312,6 +150,7 @@ class TransactionForm(FlaskForm):
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(Transaction) obj.id = new_id(Transaction)
self.date: DateField
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
@ -333,8 +172,18 @@ class TransactionForm(FlaskForm):
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
@staticmethod @property
def __set_date(obj: Transaction, new_date: date) -> None: def entries(self) -> list[JournalEntryForm]:
"""Collects and returns the journal entry sub-forms.
:return: The journal entry sub-forms.
"""
entries: list[JournalEntryForm] = []
for currency in self.currencies:
entries.extend(currency.entries)
return entries
def __set_date(self, obj: Transaction, new_date: dt.date) -> None:
"""Sets the transaction date and number. """Sets the transaction date and number.
:param obj: The transaction object. :param obj: The transaction object.
@ -344,11 +193,23 @@ class TransactionForm(FlaskForm):
if obj.date is None or obj.date != new_date: if obj.date is None or obj.date != new_date:
if obj.date is not None: if obj.date is not None:
sort_transactions_in(obj.date, obj.id) sort_transactions_in(obj.date, obj.id)
sort_transactions_in(new_date, obj.id) if self.max_date is not None and new_date == self.max_date:
count: int = Transaction.query\ db_min_no: int | None = db.session.scalar(
.filter(Transaction.date == new_date).count() sa.select(sa.func.min(Transaction.no))
obj.date = new_date .filter(Transaction.date == new_date))
obj.no = count + 1 if db_min_no is None:
obj.date = new_date
obj.no = 1
else:
obj.date = new_date
obj.no = db_min_no - 1
sort_transactions_in(new_date)
else:
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 @property
def debit_account_options(self) -> list[AccountOption]: def debit_account_options(self) -> list[AccountOption]:
@ -357,7 +218,8 @@ class TransactionForm(FlaskForm):
:return: The selectable debit accounts. :return: The selectable debit accounts.
""" """
accounts: list[AccountOption] \ accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()] = [AccountOption(x) for x in Account.debit()
if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id) sa.select(JournalEntry.account_id)
.filter(JournalEntry.is_debit) .filter(JournalEntry.is_debit)
@ -373,7 +235,8 @@ class TransactionForm(FlaskForm):
:return: The selectable credit accounts. :return: The selectable credit accounts.
""" """
accounts: list[AccountOption] \ accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()] = [AccountOption(x) for x in Account.credit()
if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id) sa.select(JournalEntry.account_id)
.filter(sa.not_(JournalEntry.is_debit)) .filter(sa.not_(JournalEntry.is_debit))
@ -399,6 +262,46 @@ class TransactionForm(FlaskForm):
""" """
return SummaryEditor() return SummaryEditor()
@property
def original_entry_options(self) -> list[JournalEntry]:
"""Returns the selectable original entries.
:return: The selectable original entries.
"""
if self.__original_entry_options is None:
self.__original_entry_options = get_selectable_original_entries(
{x.eid.data for x in self.entries if x.eid.data is not None},
self._is_need_payable, self._is_need_receivable)
return self.__original_entry_options
@property
def min_date(self) -> dt.date | None:
"""Returns the minimal available date.
:return: The minimal available date.
"""
original_entry_id: set[int] \
= {x.original_entry_id.data for x in self.entries
if x.original_entry_id.data is not None}
if len(original_entry_id) == 0:
return None
select: sa.Select = sa.select(sa.func.max(Transaction.date))\
.join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id))
return db.session.scalar(select)
@property
def max_date(self) -> dt.date | None:
"""Returns the maximum available date.
:return: The maximum available date.
"""
entry_id: set[int] = {x.eid.data for x in self.entries
if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.min(Transaction.date))\
.join(JournalEntry)\
.filter(JournalEntry.original_entry_id.in_(entry_id))
return db.session.scalar(select)
T = t.TypeVar("T", bound=TransactionForm) T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant.""" """A transaction form variant."""
@ -538,53 +441,25 @@ class JournalEntryCollector(t.Generic[T], ABC):
ord_by_form.get(x))) 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): class IncomeTransactionForm(TransactionForm):
"""The form to create or edit a cash income transaction.""" """The form to create or edit a cash income transaction."""
date = DateField(validators=[DATE_REQUIRED]) date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date.""" """The date."""
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency", currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
"""The note.""" """The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_receivable = True
class Collector(JournalEntryCollector[IncomeTransactionForm]): class Collector(JournalEntryCollector[IncomeTransactionForm]):
"""The journal entry collector for the cash income transactions.""" """The journal entry collector for the cash income transactions."""
@ -611,53 +486,25 @@ class IncomeTransactionForm(TransactionForm):
self.collector = Collector 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): class ExpenseTransactionForm(TransactionForm):
"""The form to create or edit a cash expense transaction.""" """The form to create or edit a cash expense transaction."""
date = DateField(validators=[DATE_REQUIRED]) date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date.""" """The date."""
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency", currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
"""The note.""" """The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_payable = True
class Collector(JournalEntryCollector[ExpenseTransactionForm]): class Collector(JournalEntryCollector[ExpenseTransactionForm]):
"""The journal entry collector for the cash expense """The journal entry collector for the cash expense
@ -685,88 +532,26 @@ class ExpenseTransactionForm(TransactionForm):
self.collector = Collector 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): class TransferTransactionForm(TransactionForm):
"""The form to create or edit a transfer transaction.""" """The form to create or edit a transfer transaction."""
date = DateField(validators=[DATE_REQUIRED]) date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(),
NotAfterOffsetEntries()])
"""The date.""" """The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency", currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
"""The note.""" """The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_payable = True
self._is_need_receivable = True
class Collector(JournalEntryCollector[TransferTransactionForm]): class Collector(JournalEntryCollector[TransferTransactionForm]):
"""The journal entry collector for the transfer transactions.""" """The journal entry collector for the transfer transactions."""
@ -795,67 +580,3 @@ class TransferTransactionForm(TransactionForm):
self._credit_no = self._credit_no + 1 self._credit_no = self._credit_no + 1
self.collector = Collector 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,19 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 utilities for the transaction management.
"""

View File

@ -0,0 +1,49 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 account option for the transaction management.
"""
from accounting.models import Account
class AccountOption:
"""An account option."""
def __init__(self, account: Account):
"""Constructs an account option.
:param account: The account.
"""
self.id: str = account.id
"""The account ID."""
self.code: str = account.code
"""The account code."""
self.query_values: list[str] = account.query_values
"""The values to be queried."""
self.__str: str = str(account)
"""The string representation of the account option."""
self.is_in_use: bool = False
"""True if this account is in use, or False otherwise."""
self.is_need_offset: bool = account.is_need_offset
"""True if this account needs offset, or False otherwise."""
def __str__(self) -> str:
"""Returns the string representation of the account option.
:return: The string representation of the account option.
"""
return self.__str

View File

@ -0,0 +1,39 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
# 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 SQLAlchemy alias for the offset entries.
"""
import typing as t
import sqlalchemy as sa
from accounting.models import JournalEntry
def offset_alias() -> sa.Alias:
"""Returns the SQLAlchemy alias for the offset entries.
:return: The SQLAlchemy alias for the offset entries.
"""
def as_from(model_cls: t.Any) -> sa.FromClause:
return model_cls
def as_alias(alias: t.Any) -> sa.Alias:
return alias
return as_alias(sa.alias(as_from(JournalEntry), name="offset"))

View File

@ -26,8 +26,8 @@ from flask_wtf import FlaskForm
from accounting.models import Transaction from accounting.models import Transaction
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from .forms import TransactionForm, IncomeTransactionForm, \ from accounting.transaction.forms import TransactionForm, \
ExpenseTransactionForm, TransferTransactionForm IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm
class TransactionOperator(ABC): class TransactionOperator(ABC):

View File

@ -0,0 +1,82 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 selectable original entries.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, Transaction, JournalEntry
from accounting.transaction.forms.journal_entry import JournalEntryForm
from accounting.utils.cast import be
from .offset_alias import offset_alias
def get_selectable_original_entries(
entry_id_on_form: set[int], is_payable: bool, is_receivable: bool) \
-> list[JournalEntry]:
"""Queries and returns the selectable original entries, with their net
balances. The offset amounts of the form is excluded.
:param entry_id_on_form: The ID of the journal entries on the form.
:param is_payable: True to check the payable original entries, or False
otherwise.
:param is_receivable: True to check the receivable original entries, or
False otherwise.
:return: The selectable original entries, with their net balances.
"""
assert is_payable or is_receivable
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntry.amount + sa.func.sum(sa.case(
(offset.c.id.in_(entry_id_on_form), 0),
(be(offset.c.is_debit == JournalEntry.is_debit), offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntry.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntry.is_debit))
conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select = sa.select(JournalEntry.id, net_balance)\
.join(Account)\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntry.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.id.in_({x for x in net_balances}))\
.join(Transaction)\
.order_by(Transaction.date, JournalEntry.is_debit, JournalEntry.no)\
.options(selectinload(JournalEntry.currency),
selectinload(JournalEntry.account),
selectinload(JournalEntry.transaction)).all()
for entry in entries:
entry.net_balance = entry.amount if net_balances[entry.id] is None \
else net_balances[entry.id]
return entries

View File

@ -218,7 +218,8 @@ class SummaryEditor:
JournalEntry.account_id, JournalEntry.account_id,
sa.func.count().label("freq"))\ sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None), .filter(JournalEntry.summary.is_not(None),
JournalEntry.summary.like("_%—_%"))\ JournalEntry.summary.like("_%—_%"),
JournalEntry.original_entry_id.is_(None))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id) .group_by(entry_type, tag_type, tag, JournalEntry.account_id)
result: list[sa.Row] = db.session.execute(select).all() result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \

View File

@ -28,15 +28,16 @@ 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 Transaction from accounting.models import Transaction
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
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.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import sort_transactions_in, TransactionReorderForm from .forms import sort_transactions_in, TransactionReorderForm
from .operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
text2html text2html
from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
bp: Blueprint = Blueprint("transaction", __name__) bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management.""" """The view blueprint for the transaction management."""
@ -87,7 +88,7 @@ def add_transaction(txn_type: TransactionType) -> redirect:
form.populate_obj(txn) form.populate_obj(txn)
db.session.add(txn) db.session.add(txn)
db.session.commit() db.session.commit()
flash(lazy_gettext("The transaction is added successfully"), "success") flash(s(lazy_gettext("The transaction is added successfully")), "success")
return redirect(inherit_next(__get_detail_uri(txn))) return redirect(inherit_next(__get_detail_uri(txn)))
@ -116,6 +117,7 @@ def show_transaction_edit_form(txn: Transaction) -> str:
if "form" in session: if "form" in session:
form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"]))) form = txn_op.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"] del session["form"]
form.obj = txn
form.validate() form.validate()
else: else:
form = txn_op.form(obj=txn) form = txn_op.form(obj=txn)
@ -133,6 +135,7 @@ def update_transaction(txn: Transaction) -> redirect:
""" """
txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True) txn_op: TransactionOperator = get_txn_op(txn, is_check_as=True)
form: txn_op.form = txn_op.form(request.form) form: txn_op.form = txn_op.form(request.form)
form.obj = txn
if not form.validate(): if not form.validate():
flash_form_errors(form) flash_form_errors(form)
session["form"] = urlencode(list(request.form.items())) session["form"] = urlencode(list(request.form.items()))
@ -141,12 +144,13 @@ def update_transaction(txn: Transaction) -> redirect:
with db.session.no_autoflush: with db.session.no_autoflush:
form.populate_obj(txn) form.populate_obj(txn)
if not form.is_modified: if not form.is_modified:
flash(lazy_gettext("The transaction was not modified."), "success") flash(s(lazy_gettext("The transaction was not modified.")), "success")
return redirect(inherit_next(__get_detail_uri(txn))) return redirect(inherit_next(__get_detail_uri(txn)))
txn.updated_by_id = get_current_user_pk() txn.updated_by_id = get_current_user_pk()
txn.updated_at = sa.func.now() txn.updated_at = sa.func.now()
db.session.commit() db.session.commit()
flash(lazy_gettext("The transaction is updated successfully."), "success") flash(s(lazy_gettext("The transaction is updated successfully.")),
"success")
return redirect(inherit_next(__get_detail_uri(txn))) return redirect(inherit_next(__get_detail_uri(txn)))
@ -162,7 +166,8 @@ def delete_transaction(txn: Transaction) -> redirect:
txn.delete() txn.delete()
sort_transactions_in(txn.date, txn.id) sort_transactions_in(txn.date, txn.id)
db.session.commit() db.session.commit()
flash(lazy_gettext("The transaction is deleted successfully."), "success") flash(s(lazy_gettext("The transaction is deleted successfully.")),
"success")
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))
@ -193,10 +198,10 @@ def sort_transactions(txn_date: date) -> redirect:
form: TransactionReorderForm = TransactionReorderForm(txn_date) form: TransactionReorderForm = TransactionReorderForm(txn_date)
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(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))
db.session.commit() db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success") flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
# 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 cast a SQLAlchemy column into the column type, to avoid
warnings from the IDE.
This module should not import any other module from the application.
"""
import typing as t
import sqlalchemy as sa
def be(expression: t.Any) -> sa.BinaryExpression:
"""Casts the SQLAlchemy binary expression to the binary expression type.
:param expression: The binary expression.
:return: The binary expression itself.
"""
assert isinstance(expression, sa.BinaryExpression)
return expression
def s(message: t.Any) -> str:
"""Casts the LazyString message to the string type.
:param message: The message.
:return: The binary expression itself.
"""
return message

View File

@ -23,7 +23,7 @@ import typing as t
from flask import abort, Blueprint from flask import abort, Blueprint
from accounting.utils.user import get_current_user from accounting.utils.user import get_current_user, UserUtilityInterface
def has_permission(rule: t.Callable[[], bool]) -> t.Callable: def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
@ -87,22 +87,15 @@ def can_edit() -> bool:
return __can_edit_func() return __can_edit_func()
def init_app(bp: Blueprint, def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initializes the application. """Initializes the application.
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:param can_view_func: A callback that returns whether the current user can :param user_utils: The user utilities.
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
edit the accounting data.
:return: None. :return: None.
""" """
global __can_view_func, __can_edit_func global __can_view_func, __can_edit_func
if can_view_func is not None: __can_view_func = user_utils.can_view
__can_view_func = can_view_func __can_edit_func = user_utils.can_edit
if can_edit_func is not None: bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
__can_edit_func = can_edit_func bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")
bp.add_app_template_global(can_view, "accounting_can_view")
bp.add_app_template_global(can_edit, "accounting_can_edit")

View File

@ -29,15 +29,33 @@ from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model) T = t.TypeVar("T", bound=Model)
class AbstractUserUtils(t.Generic[T], ABC): class UserUtilityInterface(t.Generic[T], ABC):
"""The abstract user utilities.""" """The interface for the user utilities."""
@abstractmethod
def can_view(self) -> bool:
"""Returns whether the currently logged-in user can view the accounting
data.
:return: True if the currently logged-in user can view the accounting
data, or False otherwise.
"""
@abstractmethod
def can_edit(self) -> bool:
"""Returns whether the currently logged-in user can edit the accounting
data.
:return: True if the currently logged-in user can edit the accounting
data, or False otherwise.
"""
@property @property
@abstractmethod @abstractmethod
def cls(self) -> t.Type[T]: def cls(self) -> t.Type[T]:
"""Returns the user class. """Returns the class of the user data model.
:return: The user class. :return: The class of the user data model.
""" """
@property @property
@ -66,13 +84,13 @@ class AbstractUserUtils(t.Generic[T], ABC):
@abstractmethod @abstractmethod
def get_pk(self, user: T) -> int: def get_pk(self, user: T) -> int:
"""Returns the primary key of the user. """Returns the primary key of the user, as an integer.
:return: The primary key of the user. :return: The primary key of the user, as an integer.
""" """
__user_utils: AbstractUserUtils __user_utils: UserUtilityInterface
"""The user utilities.""" """The user utilities."""
user_cls: t.Type[Model] = Model user_cls: t.Type[Model] = Model
"""The user class.""" """The user class."""
@ -80,7 +98,7 @@ user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class.""" """The primary key column of the user class."""
def init_user_utils(utils: AbstractUserUtils) -> None: def init_user_utils(utils: UserUtilityInterface) -> None:
"""Initializes the user utilities. """Initializes the user utilities.
:param utils: The user utilities. :param utils: The user utilities.

View File

@ -26,8 +26,8 @@ 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, db from test_site import db
from testlib import get_client, set_locale from testlib import create_test_app, get_client, set_locale
NEXT_URI: str = "/_next" NEXT_URI: str = "/_next"
"""The next URI.""" """The next URI."""
@ -74,7 +74,7 @@ class AccountCommandTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -127,7 +127,7 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -372,6 +372,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A nominal account that needs offset
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"is_need_offset": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.client.post(store_uri, response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -470,6 +479,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A nominal account that needs offset
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": stock.title,
"is_need_offset": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account # Change the base account
response = self.client.post(update_uri, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,

View File

@ -26,8 +26,7 @@ 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 testlib import create_test_app, get_client
from testlib import get_client
LIST_URI: str = "/accounting/base-accounts" LIST_URI: str = "/accounting/base-accounts"
"""The list URI.""" """The list URI."""
@ -45,7 +44,7 @@ class BaseAccountCommandTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import BaseAccount, BaseAccountL10n from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -98,7 +97,7 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import BaseAccount from accounting.models import BaseAccount
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():

View File

@ -27,8 +27,8 @@ 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, db from test_site import db
from testlib import get_client, set_locale from testlib import create_test_app, get_client, set_locale
class CurrencyData: class CurrencyData:
@ -67,7 +67,7 @@ class CurrencyCommandTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -123,7 +123,7 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():

685
tests/test_offset.py Normal file
View File

@ -0,0 +1,685 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
# 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 offset.
"""
import unittest
from decimal import Decimal
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client
from testlib_offset import TestData, JournalEntryData, TransactionData, \
CurrencyData
from testlib_txn import Accounts, match_txn_detail
PREFIX: str = "/accounting/transactions"
"""The URL prefix for the transaction management."""
class OffsetTestCase(unittest.TestCase):
"""The offset test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
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")
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
def test_add_receivable_offset(self) -> None:
"""Tests to add the receivable offset.
:return: None.
"""
from accounting.models import Account, Transaction
create_uri: str = f"{PREFIX}/create/income?next=%2F_next"
store_uri: str = f"{PREFIX}/store/income"
form: dict[str, str]
old_amount: Decimal
response: httpx.Response
txn_data: TransactionData = TransactionData(
self.data.e_r_or3d.txn.days, [CurrencyData(
"USD",
[],
[JournalEntryData(Accounts.RECEIVABLE,
self.data.e_r_or1d.summary, "300",
original_entry=self.data.e_r_or1d),
JournalEntryData(Accounts.RECEIVABLE,
self.data.e_r_or1d.summary, "100",
original_entry=self.data.e_r_or1d),
JournalEntryData(Accounts.RECEIVABLE,
self.data.e_r_or3d.summary, "100",
original_entry=self.data.e_r_or3d)])])
# Non-existing original entry ID
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same side
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(store_uri,
data=txn_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = True
db.session.commit()
# The original entry is also an offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = txn_data.new_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount + Decimal("0.01"))
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = txn_data.new_form(self.csrf_token)
form["currency-1-credit-3-amount"] \
= str(txn_data.currencies[0].credit[2].amount + Decimal("0.01"))
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries
old_days = txn_data.days
txn_data.days = old_days + 1
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = old_days
# Success
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"])
with self.app.app_context():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].credit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_receivable_offset(self) -> None:
"""Tests to edit the receivable offset.
:return: None.
"""
from accounting.models import Account
txn_data: TransactionData = self.data.t_r_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_r_or2.days
txn_data.currencies[0].debit[0].amount = Decimal("600")
txn_data.currencies[0].credit[0].amount = Decimal("600")
txn_data.currencies[0].debit[2].amount = Decimal("600")
txn_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original entry ID
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same side
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(update_uri,
data=txn_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = True
db.session.commit()
# The original entry is also an offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount + Decimal("0.01"))
form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount + Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-3-amount"] \
= str(txn_data.currencies[0].debit[2].amount + Decimal("0.01"))
form["currency-1-credit-3-amount"] \
= str(txn_data.currencies[0].credit[2].amount + Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = old_days + 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Success
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next")
def test_edit_receivable_original_entry(self) -> None:
"""Tests to edit the receivable original entry.
:return: None.
"""
from accounting.models import Transaction
txn_data: TransactionData = self.data.t_r_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_r_of1.days
txn_data.currencies[0].debit[0].amount = Decimal("800")
txn_data.currencies[0].credit[0].amount = Decimal("800")
txn_data.currencies[0].debit[1].amount = Decimal("3.4")
txn_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount - Decimal("0.01"))
form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount - Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-2-amount"] \
= str(txn_data.currencies[0].debit[1].amount - Decimal("0.01"))
form["currency-1-credit-2-amount"] \
= str(txn_data.currencies[0].credit[1].amount - Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries
old_days: int = txn_data.days
txn_data.days = old_days - 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Not deleting matched original entries
form = txn_data.update_form(self.csrf_token)
del form["currency-1-debit-1-eid"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they
# happen in the same day.
with self.app.app_context():
txn_or: Transaction | None = db.session.get(
Transaction, txn_data.id)
self.assertIsNotNone(txn_or)
txn_of: Transaction | None = db.session.get(
Transaction, self.data.t_r_of1.id)
self.assertIsNotNone(txn_of)
self.assertEqual(txn_or.date, txn_of.date)
self.assertLess(txn_or.no, txn_of.no)
def test_add_payable_offset(self) -> None:
"""Tests to add the payable offset.
:return: None.
"""
from accounting.models import Account, Transaction
create_uri: str = f"{PREFIX}/create/expense?next=%2F_next"
store_uri: str = f"{PREFIX}/store/expense"
form: dict[str, str]
response: httpx.Response
txn_data: TransactionData = TransactionData(
self.data.e_p_or3c.txn.days, [CurrencyData(
"USD",
[JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "500",
original_entry=self.data.e_p_or1c),
JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "300",
original_entry=self.data.e_p_or1c),
JournalEntryData(Accounts.PAYABLE,
self.data.e_p_or3c.summary, "120",
original_entry=self.data.e_p_or3c)],
[])])
# Non-existing original entry ID
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same side
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(store_uri,
data=txn_data.new_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = True
db.session.commit()
# The original entry is also an offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = txn_data.new_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount + Decimal("0.01"))
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = txn_data.new_form(self.csrf_token)
form["currency-1-debit-3-amount"] \
= str(txn_data.currencies[0].debit[2].amount + Decimal("0.01"))
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = old_days + 1
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
txn_data.days = old_days
# Success
form = txn_data.new_form(self.csrf_token)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"])
with self.app.app_context():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_offset(self) -> None:
"""Tests to edit the payable offset.
:return: None.
"""
from accounting.models import Account, Transaction
txn_data: TransactionData = self.data.t_p_of2
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_p_or2.days
txn_data.currencies[0].debit[0].amount = Decimal("1100")
txn_data.currencies[0].credit[0].amount = Decimal("1100")
txn_data.currencies[0].debit[2].amount = Decimal("900")
txn_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original entry ID
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same side
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The original entry does not need offset
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False
db.session.commit()
response = self.client.post(update_uri,
data=txn_data.update_form(self.csrf_token))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = True
db.session.commit()
# The original entry is also an offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount + Decimal("0.01"))
form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount + Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-3-amount"] \
= str(txn_data.currencies[0].debit[2].amount + Decimal("0.01"))
form["currency-1-credit-3-amount"] \
= str(txn_data.currencies[0].credit[2].amount + Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries
old_days: int = txn_data.days
txn_data.days = old_days + 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Success
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
txn_id: int = match_txn_detail(response.headers["Location"])
with self.app.app_context():
txn = db.session.get(Transaction, txn_id)
for offset in txn.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id)
def test_edit_payable_original_entry(self) -> None:
"""Tests to edit the payable original entry.
:return: None.
"""
from accounting.models import Transaction
txn_data: TransactionData = self.data.t_p_or1
edit_uri: str = f"{PREFIX}/{txn_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{txn_data.id}/update"
form: dict[str, str]
response: httpx.Response
txn_data.days = self.data.t_p_of1.days
txn_data.currencies[0].debit[0].amount = Decimal("1200")
txn_data.currencies[0].credit[0].amount = Decimal("1200")
txn_data.currencies[0].debit[1].amount = Decimal("0.9")
txn_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency
form = txn_data.update_form(self.csrf_token)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = txn_data.update_form(self.csrf_token)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-1-amount"] \
= str(txn_data.currencies[0].debit[0].amount - Decimal("0.01"))
form["currency-1-credit-1-amount"] \
= str(txn_data.currencies[0].credit[0].amount - Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = txn_data.update_form(self.csrf_token)
form["currency-1-debit-2-amount"] \
= str(txn_data.currencies[0].debit[1].amount - Decimal("0.01"))
form["currency-1-credit-2-amount"] \
= str(txn_data.currencies[0].credit[1].amount - Decimal("0.01"))
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries
old_days: int = txn_data.days
txn_data.days = old_days - 1
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
txn_data.days = old_days
# Not deleting matched original entries
form = txn_data.update_form(self.csrf_token)
del form["currency-1-credit-1-eid"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = txn_data.update_form(self.csrf_token)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{txn_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they
# happen in the same day
with self.app.app_context():
txn_or: Transaction | None = db.session.get(
Transaction, txn_data.id)
self.assertIsNotNone(txn_or)
txn_of: Transaction | None = db.session.get(
Transaction, self.data.t_p_of1.id)
self.assertIsNotNone(txn_of)
self.assertEqual(txn_or.date, txn_of.date)
self.assertLess(txn_or.no, txn_of.no)

View File

@ -29,8 +29,6 @@ from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from sqlalchemy import Column from sqlalchemy import Column
import accounting.utils.user
bp: Blueprint = Blueprint("home", __name__) bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS() babel_js: BabelJS = BabelJS()
csrf: CSRFProtect = CSRFProtect() csrf: CSRFProtect = CSRFProtect()
@ -69,7 +67,16 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth from . import auth
auth.init_app(app) auth.init_app(app)
class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]): class UserUtilities(accounting.UserUtilityInterface[auth.User]):
def can_view(self) -> bool:
return auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor",
"editor2"]
def can_edit(self) -> bool:
return auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"]
@property @property
def cls(self) -> t.Type[auth.User]: def cls(self) -> t.Type[auth.User]:
@ -90,12 +97,7 @@ def create_app(is_testing: bool = False) -> Flask:
def get_pk(self, user: auth.User) -> int: def get_pk(self, user: auth.User) -> int:
return user.id return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \ accounting.init_app(app, user_utils=UserUtilities())
and auth.current_user().username in ["viewer", "editor", "editor2"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"]
accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit)
return app return app

View File

@ -24,8 +24,7 @@ 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 testlib import create_test_app, get_client
from testlib import get_client
from testlib_txn import Accounts, NEXT_URI, add_txn from testlib_txn import Accounts, NEXT_URI, add_txn
@ -38,7 +37,7 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -66,7 +65,7 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.transaction.summary_editor import SummaryEditor from accounting.transaction.utils.summary_editor import SummaryEditor
for form in get_form_data(self.csrf_token): for form in get_form_data(self.csrf_token):
add_txn(self.client, form) add_txn(self.client, form)
with self.app.app_context(): with self.app.app_context():
@ -79,13 +78,13 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.debit.general.tags[0].accounts[0].code, self.assertEqual(editor.debit.general.tags[0].accounts[0].code,
Accounts.MEAL) Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[0].accounts[1].code, self.assertEqual(editor.debit.general.tags[0].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.debit.general.tags[1].name, "Dinner") self.assertEqual(editor.debit.general.tags[1].name, "Dinner")
self.assertEqual(len(editor.debit.general.tags[1].accounts), 2) self.assertEqual(len(editor.debit.general.tags[1].accounts), 2)
self.assertEqual(editor.debit.general.tags[1].accounts[0].code, self.assertEqual(editor.debit.general.tags[1].accounts[0].code,
Accounts.MEAL) Accounts.MEAL)
self.assertEqual(editor.debit.general.tags[1].accounts[1].code, self.assertEqual(editor.debit.general.tags[1].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
# Debit-Travel # Debit-Travel
self.assertEqual(len(editor.debit.travel.tags), 3) self.assertEqual(len(editor.debit.travel.tags), 3)
@ -118,7 +117,7 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.general.tags[0].name, "Lunch") self.assertEqual(editor.credit.general.tags[0].name, "Lunch")
self.assertEqual(len(editor.credit.general.tags[0].accounts), 3) self.assertEqual(len(editor.credit.general.tags[0].accounts), 3)
self.assertEqual(editor.credit.general.tags[0].accounts[0].code, self.assertEqual(editor.credit.general.tags[0].accounts[0].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.general.tags[0].accounts[1].code, self.assertEqual(editor.credit.general.tags[0].accounts[1].code,
Accounts.BANK) Accounts.BANK)
self.assertEqual(editor.credit.general.tags[0].accounts[2].code, self.assertEqual(editor.credit.general.tags[0].accounts[2].code,
@ -128,20 +127,20 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.general.tags[1].accounts[0].code, self.assertEqual(editor.credit.general.tags[1].accounts[0].code,
Accounts.BANK) Accounts.BANK)
self.assertEqual(editor.credit.general.tags[1].accounts[1].code, self.assertEqual(editor.credit.general.tags[1].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
# Credit-Travel # Credit-Travel
self.assertEqual(len(editor.credit.travel.tags), 2) self.assertEqual(len(editor.credit.travel.tags), 2)
self.assertEqual(editor.credit.travel.tags[0].name, "Bike") self.assertEqual(editor.credit.travel.tags[0].name, "Bike")
self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2) self.assertEqual(len(editor.credit.travel.tags[0].accounts), 2)
self.assertEqual(editor.credit.travel.tags[0].accounts[0].code, self.assertEqual(editor.credit.travel.tags[0].accounts[0].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.travel.tags[0].accounts[1].code, self.assertEqual(editor.credit.travel.tags[0].accounts[1].code,
Accounts.PREPAID) Accounts.PREPAID)
self.assertEqual(editor.credit.travel.tags[1].name, "Taxi") self.assertEqual(editor.credit.travel.tags[1].name, "Taxi")
self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2) self.assertEqual(len(editor.credit.travel.tags[1].accounts), 2)
self.assertEqual(editor.credit.travel.tags[1].accounts[0].code, self.assertEqual(editor.credit.travel.tags[1].accounts[0].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.travel.tags[1].accounts[1].code, self.assertEqual(editor.credit.travel.tags[1].accounts[1].code,
Accounts.CASH) Accounts.CASH)
@ -152,7 +151,7 @@ class SummeryEditorTestCase(unittest.TestCase):
self.assertEqual(editor.credit.bus.tags[0].accounts[0].code, self.assertEqual(editor.credit.bus.tags[0].accounts[0].code,
Accounts.PREPAID) Accounts.PREPAID)
self.assertEqual(editor.credit.bus.tags[0].accounts[1].code, self.assertEqual(editor.credit.bus.tags[0].accounts[1].code,
Accounts.PAYABLE) Accounts.PETTY_CASH)
self.assertEqual(editor.credit.bus.tags[1].name, "Bus") self.assertEqual(editor.credit.bus.tags[1].name, "Bus")
self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1) self.assertEqual(len(editor.credit.bus.tags[1].accounts), 1)
self.assertEqual(editor.credit.bus.tags[1].accounts[0].code, self.assertEqual(editor.credit.bus.tags[1].accounts[0].code,
@ -186,7 +185,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-1-account_code": Accounts.MEAL, "currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Fries ", "currency-0-debit-1-summary": " Lunch—Fries ",
"currency-0-debit-1-amount": "2.15", "currency-0-debit-1-amount": "2.15",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Lunch—Fries ", "currency-0-credit-1-summary": " Lunch—Fries ",
"currency-0-credit-1-amount": "2.15", "currency-0-credit-1-amount": "2.15",
"currency-0-debit-2-account_code": Accounts.MEAL, "currency-0-debit-2-account_code": Accounts.MEAL,
@ -208,7 +207,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-1-account_code": Accounts.MEAL, "currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Dinner—Steak ", "currency-0-debit-1-summary": " Dinner—Steak ",
"currency-0-debit-1-amount": "8.28", "currency-0-debit-1-amount": "8.28",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Dinner—Steak ", "currency-0-credit-1-summary": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"}, "currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
@ -218,13 +217,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
"currency-0-debit-0-summary": " Lunch—Pizza ", "currency-0-debit-0-summary": " Lunch—Pizza ",
"currency-0-debit-0-amount": "5.49", "currency-0-debit-0-amount": "5.49",
"currency-0-credit-0-account_code": Accounts.PAYABLE, "currency-0-credit-0-account_code": Accounts.PETTY_CASH,
"currency-0-credit-0-summary": " Lunch—Pizza ", "currency-0-credit-0-summary": " Lunch—Pizza ",
"currency-0-credit-0-amount": "5.49", "currency-0-credit-0-amount": "5.49",
"currency-0-debit-1-account_code": Accounts.MEAL, "currency-0-debit-1-account_code": Accounts.MEAL,
"currency-0-debit-1-summary": " Lunch—Noodles ", "currency-0-debit-1-summary": " Lunch—Noodles ",
"currency-0-debit-1-amount": "7.47", "currency-0-debit-1-amount": "7.47",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Lunch—Noodles ", "currency-0-credit-1-summary": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"}, "currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
@ -259,7 +258,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-3-account_code": Accounts.TRAVEL, "currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Train—Red—Mall→Museum ", "currency-0-debit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-debit-3-amount": "4.4", "currency-0-debit-3-amount": "4.4",
"currency-0-credit-3-account_code": Accounts.PAYABLE, "currency-0-credit-3-account_code": Accounts.PETTY_CASH,
"currency-0-credit-3-summary": " Train—Red—Mall→Museum ", "currency-0-credit-3-summary": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"}, "currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
@ -275,31 +274,31 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-debit-1-account_code": Accounts.TRAVEL, "currency-0-debit-1-account_code": Accounts.TRAVEL,
"currency-0-debit-1-summary": " Taxi—Office→Restaurant ", "currency-0-debit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-debit-1-amount": "12", "currency-0-debit-1-amount": "12",
"currency-0-credit-1-account_code": Accounts.PAYABLE, "currency-0-credit-1-account_code": Accounts.PETTY_CASH,
"currency-0-credit-1-summary": " Taxi—Office→Restaurant ", "currency-0-credit-1-summary": " Taxi—Office→Restaurant ",
"currency-0-credit-1-amount": "12", "currency-0-credit-1-amount": "12",
"currency-0-debit-2-account_code": Accounts.TRAVEL, "currency-0-debit-2-account_code": Accounts.TRAVEL,
"currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ", "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-debit-2-amount": "8", "currency-0-debit-2-amount": "8",
"currency-0-credit-2-account_code": Accounts.PAYABLE, "currency-0-credit-2-account_code": Accounts.PETTY_CASH,
"currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ", "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ",
"currency-0-credit-2-amount": "8", "currency-0-credit-2-amount": "8",
"currency-0-debit-3-account_code": Accounts.TRAVEL, "currency-0-debit-3-account_code": Accounts.TRAVEL,
"currency-0-debit-3-summary": " Bike—City Hall→Office ", "currency-0-debit-3-summary": " Bike—City Hall→Office ",
"currency-0-debit-3-amount": "3.5", "currency-0-debit-3-amount": "3.5",
"currency-0-credit-3-account_code": Accounts.PAYABLE, "currency-0-credit-3-account_code": Accounts.PETTY_CASH,
"currency-0-credit-3-summary": " Bike—City Hall→Office ", "currency-0-credit-3-summary": " Bike—City Hall→Office ",
"currency-0-credit-3-amount": "3.5", "currency-0-credit-3-amount": "3.5",
"currency-0-debit-4-account_code": Accounts.TRAVEL, "currency-0-debit-4-account_code": Accounts.TRAVEL,
"currency-0-debit-4-summary": " Bike—Restaurant→Office ", "currency-0-debit-4-summary": " Bike—Restaurant→Office ",
"currency-0-debit-4-amount": "4", "currency-0-debit-4-amount": "4",
"currency-0-credit-4-account_code": Accounts.PAYABLE, "currency-0-credit-4-account_code": Accounts.PETTY_CASH,
"currency-0-credit-4-summary": " Bike—Restaurant→Office ", "currency-0-credit-4-summary": " Bike—Restaurant→Office ",
"currency-0-credit-4-amount": "4", "currency-0-credit-4-amount": "4",
"currency-0-debit-5-account_code": Accounts.TRAVEL, "currency-0-debit-5-account_code": Accounts.TRAVEL,
"currency-0-debit-5-summary": " Bike—Office→Theatre ", "currency-0-debit-5-summary": " Bike—Office→Theatre ",
"currency-0-debit-5-amount": "1.5", "currency-0-debit-5-amount": "1.5",
"currency-0-credit-5-account_code": Accounts.PAYABLE, "currency-0-credit-5-account_code": Accounts.PETTY_CASH,
"currency-0-credit-5-summary": " Bike—Office→Theatre ", "currency-0-credit-5-summary": " Bike—Office→Theatre ",
"currency-0-credit-5-amount": "1.5", "currency-0-credit-5-amount": "1.5",
"currency-0-debit-6-account_code": Accounts.TRAVEL, "currency-0-debit-6-account_code": Accounts.TRAVEL,
@ -312,13 +311,13 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"next": NEXT_URI, "next": NEXT_URI,
"date": txn_date, "date": txn_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PAYABLE, "currency-0-debit-0-account_code": Accounts.PETTY_CASH,
"currency-0-debit-0-summary": " Dinner—Steak ", "currency-0-debit-0-summary": " Dinner—Steak ",
"currency-0-debit-0-amount": "8.28", "currency-0-debit-0-amount": "8.28",
"currency-0-credit-0-account_code": Accounts.BANK, "currency-0-credit-0-account_code": Accounts.BANK,
"currency-0-credit-0-summary": " Dinner—Steak ", "currency-0-credit-0-summary": " Dinner—Steak ",
"currency-0-credit-0-amount": "8.28", "currency-0-credit-0-amount": "8.28",
"currency-0-debit-1-account_code": Accounts.PAYABLE, "currency-0-debit-1-account_code": Accounts.PETTY_CASH,
"currency-0-debit-1-summary": " Lunch—Pizza ", "currency-0-debit-1-summary": " Lunch—Pizza ",
"currency-0-debit-1-amount": "5.49", "currency-0-debit-1-amount": "5.49",
"currency-0-credit-1-account_code": Accounts.BANK, "currency-0-credit-1-account_code": Accounts.BANK,

View File

@ -26,8 +26,8 @@ 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, db from test_site import db
from testlib import get_client from testlib import create_test_app, get_client
from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \ from testlib_txn import Accounts, get_add_form, get_unchanged_update_form, \
get_update_form, match_txn_detail, set_negative_amount, \ get_update_form, match_txn_detail, set_negative_amount, \
remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \ remove_debit_in_a_currency, remove_credit_in_a_currency, NEXT_URI, \
@ -48,7 +48,7 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -229,6 +229,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Negative amount # Negative amount
form = self.__get_add_form() form = self.__get_add_form()
set_negative_amount(form) set_negative_amount(form)
@ -380,6 +389,15 @@ class CashIncomeTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Negative amount # Negative amount
form: dict[str, str] = form_0.copy() form: dict[str, str] = form_0.copy()
set_negative_amount(form) set_negative_amount(form)
@ -600,7 +618,7 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -781,6 +799,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Negative amount # Negative amount
form = self.__get_add_form() form = self.__get_add_form()
set_negative_amount(form) set_negative_amount(form)
@ -935,6 +962,15 @@ class CashExpenseTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Negative amount # Negative amount
form: dict[str, str] = form_0.copy() form: dict[str, str] = form_0.copy()
set_negative_amount(form) set_negative_amount(form)
@ -1159,7 +1195,7 @@ class TransferTransactionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
@ -1356,6 +1392,24 @@ class TransferTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Negative amount # Negative amount
form = self.__get_add_form() form = self.__get_add_form()
set_negative_amount(form) set_negative_amount(form)
@ -1537,6 +1591,24 @@ class TransferTransactionTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A receivable entry cannot start from the credit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A payable entry cannot start from the debit side
form = self.__get_add_form()
key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0]
form[key] = Accounts.PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Negative amount # Negative amount
form: dict[str, str] = form_0.copy() form: dict[str, str] = form_0.copy()
set_negative_amount(form) set_negative_amount(form)
@ -1973,7 +2045,7 @@ class TransactionReorderTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():

View File

@ -21,13 +21,12 @@ import unittest
from urllib.parse import quote_plus from urllib.parse import quote_plus
import httpx import httpx
from flask import Flask, request, render_template_string from flask import Flask, request
from accounting.utils.next_uri 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 from testlib import TEST_SERVER, create_test_app, get_csrf_token
from testlib import TEST_SERVER
class NextUriTestCase(unittest.TestCase): class NextUriTestCase(unittest.TestCase):
@ -40,12 +39,7 @@ class NextUriTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
@self.app.get("/test-csrf")
def test_csrf() -> str:
"""The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}")
def test_next_uri(self) -> None: def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI. """Tests the next URI utilities with the next URI.
@ -69,7 +63,7 @@ class NextUriTestCase(unittest.TestCase):
methods=["GET", "POST"]) methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = client.get("/test-csrf").text csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
response = client.get("/test-next?next=/next&q=abc&page-no=4") response = client.get("/test-next?next=/next&q=abc&page-no=4")
@ -98,7 +92,7 @@ class NextUriTestCase(unittest.TestCase):
methods=["GET", "POST"]) methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = client.get("/test-csrf").text csrf_token: str = get_csrf_token(client)
response: httpx.Response 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")
@ -158,7 +152,7 @@ class PaginationTestCase(unittest.TestCase):
:param items: All the items in the list. :param items: All the items in the list.
:param is_reversed: Whether the default page is the last page. :param is_reversed: Whether the default page is the last page.
:param result: The expected items on the page. :param result: The expected items on the page.
:param is_paged: Whether the pagination is needed. :param is_paged: Whether we need pagination.
""" """
self.items: list[int] = items self.items: list[int] = items
self.is_reversed: bool | None = is_reversed self.is_reversed: bool | None = is_reversed
@ -171,7 +165,7 @@ class PaginationTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_test_app()
self.params = self.Params([], None, [], True) self.params = self.Params([], None, [], True)
@self.app.get("/test-pagination") @self.app.get("/test-pagination")
@ -198,7 +192,7 @@ class PaginationTestCase(unittest.TestCase):
:param query: The query string. :param query: The query string.
:param items: The original items. :param items: The original items.
:param result: The expected page content. :param result: The expected page content.
:param is_paged: Whether the pagination is needed. :param is_paged: Whether we need pagination.
:param is_reversed: Whether the list is reversed. :param is_reversed: Whether the list is reversed.
:return: None. :return: None.
""" """
@ -253,8 +247,8 @@ class PaginationTestCase(unittest.TestCase):
self.__test_success("page-no=46&page-size=15", range(1, 687), self.__test_success("page-no=46&page-size=15", range(1, 687),
range(676, 687)) range(676, 687))
def test_not_needed(self) -> None: def test_not_need(self) -> None:
"""Tests the pagination that is not needed. """Tests that the data does not need pagination.
:return: None. :return: None.
""" """

View File

@ -18,15 +18,46 @@
""" """
import typing as t import typing as t
from html.parser import HTMLParser
import httpx import httpx
from flask import Flask from flask import Flask, render_template_string
from test_site import create_app
TEST_SERVER: str = "https://testserver" TEST_SERVER: str = "https://testserver"
"""The test server URI.""" """The test server URI."""
def create_test_app() -> Flask:
"""Creates and returns the testing Flask application.
:return: The testing Flask application.
"""
app: Flask = create_app(is_testing=True)
@app.get("/.csrf-token")
def get_csrf_token_view() -> str:
"""The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}")
@app.get("/.errors")
def get_errors_view() -> str:
"""The test view to return the CSRF token."""
return render_template_string("{{get_flashed_messages()|tojson}}")
return app
def get_csrf_token(client: httpx.Client) -> str:
"""Returns the CSRF token.
:param client: The httpx client.
:return: The CSRF token.
"""
return client.get("/.csrf-token").text
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.
@ -36,7 +67,7 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
""" """
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER) client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client, "/login") csrf_token: str = get_csrf_token(client)
response: httpx.Response = client.post("/login", response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"username": username}) "username": username})
@ -45,38 +76,6 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
return client, csrf_token return client, csrf_token
def get_csrf_token(client: httpx.Client, uri: str) -> str:
"""Returns the CSRF token from a form in a URI.
:param client: The httpx client.
:param uri: The URI.
:return: The CSRF token.
"""
class CsrfParser(HTMLParser):
"""The CSRF token parser."""
def __init__(self):
"""Constructs the CSRF token parser."""
super().__init__()
self.csrf_token: str | None = None
"""The CSRF token."""
def handle_starttag(self, tag: str,
attrs: list[tuple[str, str | None]]) -> None:
"""Handles when a start tag is found."""
attrs_dict: dict[str, str] = dict(attrs)
if attrs_dict.get("name") == "csrf_token":
self.csrf_token = attrs_dict["value"]
response: httpx.Response = client.get(uri)
assert response.status_code == 200
parser: CsrfParser = CsrfParser()
parser.feed(response.text)
assert parser.csrf_token is not None
return parser.csrf_token
def set_locale(client: httpx.Client, csrf_token: str, def set_locale(client: httpx.Client, csrf_token: str,
locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None: locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
"""Sets the current locale. """Sets the current locale.

309
tests/testlib_offset.py Normal file
View File

@ -0,0 +1,309 @@
# 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 offset test cases.
"""
from __future__ import annotations
from datetime import date, timedelta
from decimal import Decimal
import httpx
from flask import Flask
from test_site import db
from testlib_txn import Accounts, match_txn_detail, NEXT_URI
class JournalEntryData:
"""The journal entry data."""
def __init__(self, account: str, summary: str, amount: str,
original_entry: JournalEntryData | None = None):
"""Constructs the journal entry data.
:param account: The account code.
:param summary: The summary.
:param amount: The amount.
:param original_entry: The original entry.
"""
self.txn: TransactionData | None = None
self.id: int = -1
self.no: int = -1
self.original_entry: JournalEntryData | None = original_entry
self.account: str = account
self.summary: str = summary
self.amount: Decimal = Decimal(amount)
def form(self, prefix: str, entry_type: str, index: int, is_update: bool) \
-> dict[str, str]:
"""Returns the journal entry as form data.
:param prefix: The prefix of the form fields.
:param entry_type: The entry type, either "debit" or "credit".
:param index: The entry index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix = f"{prefix}-{entry_type}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-summary": self.summary,
f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1:
form[f"{prefix}-eid"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_entry is not None:
assert self.original_entry.id != -1
form[f"{prefix}-original_entry_id"] = str(self.original_entry.id)
return form
class CurrencyData:
"""The transaction currency data."""
def __init__(self, currency: str, debit: list[JournalEntryData],
credit: list[JournalEntryData]):
"""Constructs the transaction currency data.
:param currency: The currency code.
:param debit: The debit journal entries.
:param credit: The credit journal entries.
"""
self.code: str = currency
self.debit: list[JournalEntryData] = debit
self.credit: list[JournalEntryData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
:param index: The currency index.
:param is_update: True for an update operation, or False otherwise
:return: The form data.
"""
prefix: str = f"currency-{index}"
form: dict[str, str] = {f"{prefix}-code": self.code}
for i in range(len(self.debit)):
form.update(self.debit[i].form(prefix, "debit", i + 1, is_update))
for i in range(len(self.credit)):
form.update(self.credit[i].form(prefix, "credit", i + 1,
is_update))
return form
class TransactionData:
"""The transaction data."""
def __init__(self, days: int, currencies: list[CurrencyData]):
"""Constructs a transaction.
:param days: The number of days before today.
:param currencies: The transaction currency data.
"""
self.id: int = -1
self.days: int = days
self.currencies: list[CurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for entry in currency.debit:
entry.txn = self
for entry in currency.credit:
entry.txn = self
def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the transaction as a form.
:param csrf_token: The CSRF token.
:return: The transaction as a form.
"""
return self.__form(csrf_token, is_update=False)
def update_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the transaction as a form.
:param csrf_token: The CSRF token.
:return: The transaction as a form.
"""
return self.__form(csrf_token, is_update=True)
def __form(self, csrf_token: str, is_update: bool = False) \
-> dict[str, str]:
"""Returns the transaction as a form.
:param csrf_token: The CSRF token.
:param is_update: True for an update operation, or False otherwise
:return: The transaction as a form.
"""
txn_date: date = date.today() - timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": NEXT_URI,
"date": txn_date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None:
form["note"] = self.note
return form
class TestData:
"""The test data."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(summary: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryData, JournalEntryData]:
"""Returns a couple of debit-credit journal entries.
:param summary: The summary.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit journal entry and credit journal entry.
"""
return JournalEntryData(debit, summary, amount),\
JournalEntryData(credit, summary, amount)
# Receivable original entries
self.e_r_or1d, self.e_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.e_r_or2d, self.e_r_or2c = couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.e_r_or3d, self.e_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.e_r_or4d, self.e_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original entries
self.e_p_or1d, self.e_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.e_p_or2d, self.e_p_or2c = couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.e_p_or3d, self.e_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.e_p_or4d, self.e_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original transactions
self.t_r_or1: TransactionData = TransactionData(
50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
[self.e_r_or1c, self.e_r_or4c])])
self.t_r_or2: TransactionData = TransactionData(
30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d],
[self.e_r_or2c, self.e_r_or3c])])
self.t_p_or1: TransactionData = TransactionData(
40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d],
[self.e_p_or1c, self.e_p_or4c])])
self.t_p_or2: TransactionData = TransactionData(
20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d],
[self.e_p_or2c, self.e_p_or3c])])
self.__add_txn(self.t_r_or1)
self.__add_txn(self.t_r_or2)
self.__add_txn(self.t_p_or1)
self.__add_txn(self.t_p_or2)
# Receivable offset entries
self.e_r_of1d, self.e_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of1c.original_entry = self.e_r_or1d
self.e_r_of2d, self.e_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of2c.original_entry = self.e_r_or1d
self.e_r_of3d, self.e_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of3c.original_entry = self.e_r_or1d
self.e_r_of4d, self.e_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of4c.original_entry = self.e_r_or2d
self.e_r_of5d, self.e_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of5c.original_entry = self.e_r_or4d
# Payable offset entries
self.e_p_of1d, self.e_p_of1c = couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of1d.original_entry = self.e_p_or1c
self.e_p_of2d, self.e_p_of2c = couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of2d.original_entry = self.e_p_or1c
self.e_p_of3d, self.e_p_of3c = couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of3d.original_entry = self.e_p_or1c
self.e_p_of4d, self.e_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of4d.original_entry = self.e_p_or2c
self.e_p_of5d, self.e_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of5d.original_entry = self.e_p_or4c
# Offset transactions
self.t_r_of1: TransactionData = TransactionData(
25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])])
self.t_r_of2: TransactionData = TransactionData(
20, [CurrencyData("USD",
[self.e_r_of2d, self.e_r_of3d, self.e_r_of4d],
[self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])])
self.t_r_of3: TransactionData = TransactionData(
15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])])
self.t_p_of1: TransactionData = TransactionData(
15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])])
self.t_p_of2: TransactionData = TransactionData(
10, [CurrencyData("USD",
[self.e_p_of2d, self.e_p_of3d, self.e_p_of4d],
[self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])])
self.t_p_of3: TransactionData = TransactionData(
5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
self.__add_txn(self.t_r_of1)
self.__add_txn(self.t_r_of2)
self.__add_txn(self.t_r_of3)
self.__add_txn(self.t_p_of1)
self.__add_txn(self.t_p_of2)
self.__add_txn(self.t_p_of3)
def __add_txn(self, txn_data: TransactionData) -> None:
"""Adds a transaction.
:param txn_data: The transaction data.
:return: None.
"""
from accounting.models import Transaction
store_uri: str = "/accounting/transactions/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=txn_data.new_form(self.csrf_token))
assert response.status_code == 302
txn_id: int = match_txn_detail(response.headers["Location"])
txn_data.id = txn_id
with self.app.app_context():
txn: Transaction | None = db.session.get(Transaction, txn_id)
assert txn is not None
for i in range(len(txn.currencies)):
for j in range(len(txn.currencies[i].debit)):
txn_data.currencies[i].debit[j].id \
= txn.currencies[i].debit[j].id
for j in range(len(txn.currencies[i].credit)):
txn_data.currencies[i].credit[j].id \
= txn.currencies[i].credit[j].id

View File

@ -38,8 +38,12 @@ EMPTY_NOTE: str = " \n\n "
class Accounts: class Accounts:
"""The shortcuts to the common accounts.""" """The shortcuts to the common accounts."""
CASH: str = "1111-001" CASH: str = "1111-001"
PETTY_CASH: str = "1112-001"
BANK: str = "1113-001" BANK: str = "1113-001"
NOTES_RECEIVABLE: str = "1131-001"
RECEIVABLE: str = "1141-001"
PREPAID: str = "1258-001" PREPAID: str = "1258-001"
NOTES_PAYABLE: str = "2131-001"
PAYABLE: str = "2141-001" PAYABLE: str = "2141-001"
SALES: str = "4111-001" SALES: str = "4111-001"
SERVICE: str = "4611-001" SERVICE: str = "4611-001"
@ -47,7 +51,7 @@ class Accounts:
OFFICE: str = "6153-001" OFFICE: str = "6153-001"
TRAVEL: str = "6154-001" TRAVEL: str = "6154-001"
MEAL: str = "6172-001" MEAL: str = "6172-001"
INTEREST: str = "4111-001" INTEREST: str = "7111-001"
DONATION: str = "7481-001" DONATION: str = "7481-001"
RENT: str = "7482-001" RENT: str = "7482-001"