150 Commits

Author SHA1 Message Date
2253ec7e6d Advanced to version 0.7.0. 2023-03-21 00:54:44 +08:00
32aa532548 Updated the Sphinx documentation. 2023-03-21 00:54:26 +08:00
56138f7de3 Updated the translation. 2023-03-21 00:53:52 +08:00
21ef944259 Fixed the error in the navigation menu when there is no matching endpoint. 2023-03-21 00:53:27 +08:00
760f1c2877 Fixed the query to be case-insensitive in the base account selector in the account form and the account selector in the journal entry form. 2023-03-20 23:54:50 +08:00
e377eac407 Fixed the capitalization of the currencies, base accounts, and accounts. 2023-03-20 23:54:49 +08:00
77787eee9f Fixed the search queries to be case-insensitive. 2023-03-20 23:54:38 +08:00
03265a1232 Fixed the text in the buttons to add new journal entries. 2023-03-20 23:16:57 +08:00
079dc1ab6d Renamed the "eid" field to "id" in the LineItemForm form, since the problem is found. It was the "id" property of the enclosing FormField. If we extract the form from FormField, we can still access the "id" field. 2023-03-20 23:06:57 +08:00
d4fe91ec4a Fixed the capitalization of the shortcut periods in the period chooser. 2023-03-20 22:57:04 +08:00
acc5b4d6ea Fixed the capitalization of the label of the number of items in the description editor. 2023-03-20 22:52:35 +08:00
19a93cb4c3 Fixed the text message in the add_journal_entry view. 2023-03-20 22:44:55 +08:00
116089d1d2 Fixed the text message in the add_currency view. 2023-03-20 22:44:26 +08:00
50dd6078c7 Replaced "Need offset" with "Needs Offset" as the text of the badge. 2023-03-20 22:43:53 +08:00
9a4531b26c Revised the title of the delete confirmation modal from "Delete XXX Confirmation" to "Confirm Delete XXX", as suggested by ChatGPT. 2023-03-20 22:38:35 +08:00
b1af1d7425 Renamed "voucher" to "journal entry". 2023-03-20 22:33:14 +08:00
8f909965a9 Renamed "voucher line item" to "journal entry line item". 2023-03-20 20:52:35 +08:00
e26af6f3fc Renamed "side" to "debit-credit". 2023-03-20 20:35:10 +08:00
02fffc3400 Removed the unused offset_original_line_item_id field from the DebitLineItemForm form. 2023-03-20 18:56:38 +08:00
d7d6929bf2 Fixed the parameter passed to the credit line item subform in the transfer voucher form. 2023-03-20 18:53:16 +08:00
e4cc61552e Simplified the parameter passed to the "form-line-item.html" template for the line item subform of the voucher form, to be less error-prone. 2023-03-20 18:53:11 +08:00
d18dd7d4d2 Renamed "summary" to "description" in the voucher line item. 2023-03-20 18:45:50 +08:00
3251660092 Added the SVG favicon from Font Awesome 6 to the test site. 2023-03-20 08:41:37 +08:00
c1235608d8 Renamed "journal entry" to "voucher line item", and "entry type" to "side". 2023-03-19 22:09:40 +08:00
25c45b16ae Removed the unused imports from the "accounting.voucher.utils.original_entries" module. 2023-03-19 14:15:50 +08:00
78f570b81b Removed an excess trailing blank line in test_summary_editor.py. 2023-03-19 14:05:36 +08:00
5db13393cc Renamed "transaction" to "voucher", "cash expense transaction" to "cash disbursement voucher", and "cash income transaction" to "cash receipt voucher". 2023-03-19 13:56:46 +08:00
1e286fbeba Renamed the #originalEntry, #summary, #account, and #amount private attributes of the JavaScript JournalEntryEditor class to #originalEntryText, #summaryText, #accountText, and #amountInput, to avoid confusion with the public attributes with similar names. 2023-03-19 10:42:37 +08:00
d4b3fe67b9 Removed the originalEntryId parameter from the onOpen method of the JavaScript OriginalEntrySelector class. It can be obtained from the JournalEntryEditor instance, and the parameter is not needed anymore. 2023-03-19 10:25:33 +08:00
5d0757c845 Added the JavaScript JournalEntryEditor instance to the parameters of the constructor of the JavaScript OriginalEntrySelector class, so that it always have access to the JournalEntryEditor instance. Removed the JournalEntryEditor instance from the parameters of the onOpen method of the JournalEntryEditor class. 2023-03-19 10:22:18 +08:00
b69a519904 Updated tempus-dominus from 6.2.10 to 6.4.3 in the base template of the test site. 2023-03-19 10:16:12 +08:00
122b7b059c Changed the default date and min date for the Tempus Dominus month chooser from strings to the JavaScript Date objects. 2023-03-19 10:15:32 +08:00
4977847dd8 Changed the entryType attribute of the JavaScript AccountSelector class from public to private, renamed it from entryType to #entryType. 2023-03-19 07:30:03 +08:00
b9b197ea27 Removed the unused #modal property from the JavaScript OriginalEntrySelector class. 2023-03-19 07:27:25 +08:00
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
129 changed files with 9738 additions and 5566 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,7 +23,9 @@ 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/static/*
include tests/test_site/templates/* include tests/test_site/templates/*
include tests/test_site/translations/* include tests/test_site/translations/*
include tests/test_site/translations/*/LC_MESSAGES/* include tests/test_site/translations/*/LC_MESSAGES/*

View File

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

View File

@ -0,0 +1,46 @@
accounting.journal\_entry package
=================================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.journal_entry.forms
accounting.journal_entry.utils
Submodules
----------
accounting.journal\_entry.converters module
-------------------------------------------
.. automodule:: accounting.journal_entry.converters
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.template\_filters module
--------------------------------------------------
.. automodule:: accounting.journal_entry.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.views module
--------------------------------------
.. automodule:: accounting.journal_entry.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.journal_entry
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,53 @@
accounting.journal\_entry.utils package
=======================================
Submodules
----------
accounting.journal\_entry.utils.account\_option module
------------------------------------------------------
.. automodule:: accounting.journal_entry.utils.account_option
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.description\_editor module
----------------------------------------------------------
.. automodule:: accounting.journal_entry.utils.description_editor
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.offset\_alias module
----------------------------------------------------
.. automodule:: accounting.journal_entry.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.operators module
------------------------------------------------
.. automodule:: accounting.journal_entry.utils.operators
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.original\_line\_items module
------------------------------------------------------------
.. automodule:: accounting.journal_entry.utils.original_line_items
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.journal_entry.utils
:members:
:undoc-members:
:show-inheritance:

View File

@ -10,8 +10,8 @@ Subpackages
accounting.account accounting.account
accounting.base_account accounting.base_account
accounting.currency accounting.currency
accounting.journal_entry
accounting.report accounting.report
accounting.transaction
accounting.utils accounting.utils
Submodules Submodules

View File

@ -1,61 +0,0 @@
accounting.transaction package
==============================
Submodules
----------
accounting.transaction.converters module
----------------------------------------
.. automodule:: accounting.transaction.converters
:members:
:undoc-members:
: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
-----------------------------------------------
.. automodule:: accounting.transaction.template_filters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.views module
-----------------------------------
.. automodule:: accounting.transaction.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction
:members:
:undoc-members:
:show-inheritance:

View File

@ -4,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
------------------------------------- -------------------------------------
@ -12,6 +20,14 @@ accounting.utils.flash\_errors module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.journal\_entry\_types module
---------------------------------------------
.. automodule:: accounting.utils.journal_entry_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.next\_uri module accounting.utils.next\_uri module
--------------------------------- ---------------------------------
@ -60,14 +76,6 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.txn\_types module
----------------------------------
.. automodule:: accounting.utils.txn_types
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module accounting.utils.user module
---------------------------- ----------------------------

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask' project = 'Mia! Accounting Flask'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '0.4.0' release = '0.7.0'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@
[metadata] [metadata]
name = mia-accounting-flask name = mia-accounting-flask
version = 0.5.0 version = 0.7.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)
@ -87,8 +80,8 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import currency from . import currency
currency.init_app(app, bp) currency.init_app(app, bp)
from . import transaction from . import journal_entry
transaction.init_app(app, bp) journal_entry.init_app(app, bp)
from . import report from . import report
report.init_app(app, bp) report.init_app(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,37 @@ 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 journal entry line items in the account need offset.
:param base_code: The code of the base account.
:return: True if journal entry line items 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 +135,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,8 +80,9 @@ 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(
"""Whether the the entries of this account need offset.""" validators=[NoOffsetNominalAccount()])
"""Whether the the journal entry line items of this account need offset."""
def populate_obj(self, obj: Account) -> None: def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object. """Populates the form data into an account object.
@ -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

@ -40,15 +40,15 @@ def get_account_query() -> list[Account]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\ l10n: list[AccountL10n] = AccountL10n.query\
.filter(AccountL10n.title.contains(k)).all() .filter(AccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_id for x in l10n} l10n_matches: set[str] = {x.account_id for x in l10n}
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.contains(k), Account.title_l10n.icontains(k),
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("Needs 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

@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\ l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
.filter(BaseAccountL10n.title.contains(k)).all() .filter(BaseAccountL10n.title.icontains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k), conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.contains(k), BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches))) BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\ return BaseAccount.query.filter(*conditions)\
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code).all()

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,14 +28,11 @@ 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 CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
class CodeUnique: class CodeUnique:
"""The validator to check if the code is unique.""" """The validator to check if the code is unique."""
def __call__(self, form: CurrencyForm, field: StringField) -> None:
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data == "": if field.data == "":
return return
if form.obj_code is not None and form.obj_code == field.data: if form.obj_code is not None and form.obj_code == field.data:
@ -46,6 +41,11 @@ class CurrencyForm(FlaskForm):
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"Code conflicts with another currency.")) "Code conflicts with another currency."))
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
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

@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\ l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.contains(k)).all() .filter(CurrencyL10n.name.icontains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.contains(k), conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.contains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches))) Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\ return Currency.query.filter(*conditions)\
.order_by(Currency.code).all() .order_by(Currency.code).all()

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

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The transaction management. """The journal entry management.
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
from .converters import TransactionConverter, TransactionTypeConverter, \ from .converters import JournalEntryConverter, JournalEntryTypeConverter, \
DateConverter DateConverter
app.url_map.converters["transaction"] = TransactionConverter app.url_map.converters["journalEntry"] = JournalEntryConverter
app.url_map.converters["transactionType"] = TransactionTypeConverter app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter
app.url_map.converters["date"] = DateConverter app.url_map.converters["date"] = DateConverter
from .views import bp as transaction_bp from .views import bp as journal_entry_bp
bp.register_blueprint(transaction_bp, url_prefix="/transactions") bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries")

View File

@ -0,0 +1,107 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the journal entry management.
"""
from datetime import date
from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting.models import JournalEntry, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryConverter(BaseConverter):
"""The journal entry converter to convert the journal entry ID from and to
the corresponding journal entry in the routes."""
def to_python(self, value: str) -> JournalEntry:
"""Converts a journal entry ID to a journal entry.
:param value: The journal entry ID.
:return: The corresponding journal entry.
"""
journal_entry: JournalEntry | None = JournalEntry.query\
.join(JournalEntryLineItem)\
.filter(JournalEntry.id == value)\
.options(selectinload(JournalEntry.line_items)
.selectinload(JournalEntryLineItem.offsets)
.selectinload(JournalEntryLineItem.journal_entry))\
.first()
if journal_entry is None:
abort(404)
return journal_entry
def to_url(self, value: JournalEntry) -> str:
"""Converts a journal entry to its ID.
:param value: The journal entry.
:return: The ID.
"""
return str(value.id)
class JournalEntryTypeConverter(BaseConverter):
"""The journal entry converter to convert the journal entry type ID from
and to the corresponding journal entry type in the routes."""
def to_python(self, value: str) -> JournalEntryType:
"""Converts a journal entry ID to a journal entry.
:param value: The journal entry ID.
:return: The corresponding journal entry type.
"""
type_dict: dict[str, JournalEntryType] \
= {x.value: x for x in JournalEntryType}
journal_entry_type: JournalEntryType | None = type_dict.get(value)
if journal_entry_type is None:
abort(404)
return journal_entry_type
def to_url(self, value: JournalEntryType) -> str:
"""Converts a journal entry type to its ID.
:param value: The journal entry type.
:return: The ID.
"""
return str(value.value)
class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the
corresponding date in the routes."""
def to_python(self, value: str) -> date:
"""Converts an ISO date to a date.
:param value: The ISO date.
:return: The corresponding date.
"""
try:
return date.fromisoformat(value)
except ValueError:
abort(404)
def to_url(self, value: date) -> str:
"""Converts a date to its ISO date.
:param value: The date.
:return: The ISO date.
"""
return value.isoformat()

View File

@ -0,0 +1,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 journal entry management.
"""
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
CashDisbursementJournalEntryForm, TransferJournalEntryForm

View File

@ -0,0 +1,303 @@
# 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 journal entry 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, JournalEntryLineItem
from accounting.journal_entry.utils.offset_alias import offset_alias
from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
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 SameCurrencyAsOriginalLineItems:
"""The validator to check if the currency is the same as the
original line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm)
if field.data is None:
return
original_line_item_id: set[int] \
= {x.original_line_item_id.data
for x in form.line_items
if x.original_line_item_id.data is not None}
if len(original_line_item_id) == 0:
return
original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_line_item_currency_codes:
if field.data != currency_code:
raise ValidationError(lazy_gettext(
"The currency must be the same as the"
" original line item."))
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_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items
if x.id.data is not None}))\
.group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all()
for original_line_item in original_line_items:
if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset."))
class NeedSomeLineItems:
"""The validator to check if there is any line item sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some line items."))
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
line items 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 journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def line_items(self) -> list[LineItemForm]:
"""Returns the line item sub-forms.
:return: The line item sub-forms.
"""
line_item_forms: list[LineItemForm] = []
if isinstance(self, CashReceiptCurrencyForm):
line_item_forms.extend([x.form for x in self.credit])
elif isinstance(self, CashDisbursementCurrencyForm):
line_item_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm):
line_item_forms.extend([x.form for x in self.debit])
line_item_forms.extend([x.form for x in self.credit])
return line_item_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
"""
line_item_forms: list[LineItemForm] = self.line_items
original_line_item_id: set[int] \
= {x.original_line_item_id.data for x in line_item_forms
if x.original_line_item_id.data is not None}
if len(original_line_item_id) > 0:
return True
line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select) > 0
class CashReceiptCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a
cash receipt journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeLineItems()])
"""The credit line items."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit line items.
:return: The total amount of the credit line items.
"""
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 line item 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 CashDisbursementCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a
cash disbursement journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeLineItems()])
"""The debit line items."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit line items.
:return: The total amount of the debit line items.
"""
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 line item 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 journal entry."""
no = IntegerField()
"""The order in the journal entry."""
code = StringField(
filters=[strip_text],
validators=[CURRENCY_REQUIRED,
CurrencyExists(),
SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()])
"""The currency code."""
debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeLineItems()])
"""The debit line items."""
credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeLineItems()])
"""The credit line items."""
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 line items.
:return: The total amount of the debit line items.
"""
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 line items.
:return: The total amount of the credit line items.
"""
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 line item 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 line item 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,593 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The journal entry forms for the journal entry management.
"""
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_journal_entries_in
DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date."))
"""The validator to check if the date is empty."""
class NotBeforeOriginalLineItems:
"""The validator to check if the date is not before the
original line items."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, JournalEntryForm)
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 line items."))
class NotAfterOffsetItems:
"""The validator to check if the date is not after the offset items."""
def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, JournalEntryForm)
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 items."))
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0:
raise ValidationError(lazy_gettext("Please add some currencies."))
class CannotDeleteOriginalLineItemsWithOffset:
"""The validator to check the original line items with offset."""
def __call__(self, form: FlaskForm, field: FieldList) -> None:
assert isinstance(form, JournalEntryForm)
if form.obj is None:
return
existing_matched_original_line_item_id: set[int] \
= {x.id for x in form.obj.line_items if len(x.offsets) > 0}
line_item_id_in_form: set[int] \
= {x.id.data for x in form.line_items if x.id.data is not None}
for line_item_id in existing_matched_original_line_item_id:
if line_item_id not in line_item_id_in_form:
raise ValidationError(lazy_gettext(
"Line items with offset cannot be deleted."))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
date = DateField()
"""The date."""
currencies = FieldList(FormField(CurrencyForm))
"""The line items categorized by their currencies."""
note = TextAreaField()
"""The note."""
def __init__(self, *args, **kwargs):
"""Constructs a base journal entry form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj()."""
self.collector: t.Type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
self.obj: JournalEntry | None = kwargs.get("obj")
"""The journal entry, when editing an existing one."""
self._is_need_payable: bool = False
"""Whether we need the payable original line items."""
self._is_need_receivable: bool = False
"""Whether we need the receivable original line items."""
self.__original_line_item_options: list[JournalEntryLineItem] | None \
= None
"""The options of the original line items."""
self.__net_balance_exceeded: dict[int, LazyString] | None = None
"""The original line items whose net balances were exceeded by the
amounts in the line item sub-forms."""
for line_item in self.line_items:
line_item.journal_entry_form = self
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)
self.date: DateField
self.__set_date(obj, self.date.data)
obj.note = self.note.data
collector_cls: t.Type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj)
collector.collect()
to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
self.is_modified = True
if is_new or db.session.is_modified(obj):
self.is_modified = True
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
@property
def line_items(self) -> list[LineItemForm]:
"""Collects and returns the line item sub-forms.
:return: The line item sub-forms.
"""
line_items: list[LineItemForm] = []
for currency in self.currencies:
line_items.extend(currency.line_items)
return line_items
def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
"""Sets the journal entry date and number.
:param obj: The journal entry object.
:param new_date: The new date.
:return: None.
"""
if obj.date is None or obj.date != new_date:
if obj.date is not None:
sort_journal_entries_in(obj.date, obj.id)
if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(JournalEntry.no))
.filter(JournalEntry.date == new_date))
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_journal_entries_in(new_date)
else:
sort_journal_entries_in(new_date, obj.id)
count: int = JournalEntry.query\
.filter(JournalEntry.date == new_date).count()
obj.date = new_date
obj.no = count + 1
@property
def debit_account_options(self) -> list[AccountOption]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()
if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(JournalEntryLineItem.is_debit)
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def credit_account_options(self) -> list[AccountOption]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()
if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
.filter(sa.not_(JournalEntryLineItem.is_debit))
.group_by(JournalEntryLineItem.account_id)).all())
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return: The currency errors, without the errors in their sub-forms.
"""
return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def description_editor(self) -> DescriptionEditor:
"""Returns the description editor.
:return: The description editor.
"""
return DescriptionEditor()
@property
def original_line_item_options(self) -> list[JournalEntryLineItem]:
"""Returns the selectable original line items.
:return: The selectable original line items.
"""
if self.__original_line_item_options is None:
self.__original_line_item_options \
= get_selectable_original_line_items(
{x.id.data for x in self.line_items
if x.id.data is not None},
self._is_need_payable, self._is_need_receivable)
return self.__original_line_item_options
@property
def min_date(self) -> dt.date | None:
"""Returns the minimal available date.
:return: The minimal available date.
"""
original_line_item_id: set[int] \
= {x.original_line_item_id.data for x in self.line_items
if x.original_line_item_id.data is not None}
if len(original_line_item_id) == 0:
return None
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
return db.session.scalar(select)
@property
def max_date(self) -> dt.date | None:
"""Returns the maximum available date.
:return: The maximum available date.
"""
line_item_id: set[int] = {x.id.data for x in self.line_items
if x.id.data is not None}
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
.join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.original_line_item_id
.in_(line_item_id))
return db.session.scalar(select)
T = t.TypeVar("T", bound=JournalEntryForm)
"""A journal entry form variant."""
class LineItemCollector(t.Generic[T], ABC):
"""The line item collector."""
def __init__(self, form: T, obj: JournalEntry):
"""Constructs the line item collector.
:param form: The journal entry form.
:param obj: The journal entry.
"""
self.form: T = form
"""The journal entry form."""
self.__obj: JournalEntry = obj
"""The journal entry object."""
self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
"""The existing line items."""
self.__line_items_by_id: dict[int, JournalEntryLineItem] \
= {x.id: x for x in self.__line_items}
"""A dictionary from the line item ID to their line items."""
self.__no_by_id: dict[int, int] \
= {x.id: x.no for x in self.__line_items}
"""A dictionary from the line item number to their line items."""
self.__currencies: list[JournalEntryCurrency] = obj.currencies
"""The currencies in the journal entry."""
self._debit_no: int = 1
"""The number index for the debit line items."""
self._credit_no: int = 1
"""The number index for the credit line items."""
self.to_keep: set[int] = set()
"""The ID of the existing line items to keep."""
@abstractmethod
def collect(self) -> set[int]:
"""Collects the line items.
:return: The ID of the line items to keep.
"""
def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
-> None:
"""Composes a line item from the form.
:param form: The line item form.
:param currency_code: The code of the currency.
:param no: The number of the line item.
:return: None.
"""
line_item: JournalEntryLineItem | None \
= self.__line_items_by_id.get(form.id.data)
if line_item is not None:
line_item.currency_code = currency_code
form.populate_obj(line_item)
line_item.no = no
if db.session.is_modified(line_item):
self.form.is_modified = True
else:
line_item = JournalEntryLineItem()
line_item.currency_code = currency_code
form.populate_obj(line_item)
line_item.no = no
self.__obj.line_items.append(line_item)
self.form.is_modified = True
self.to_keep.add(line_item.id)
def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
currency_code: str, no: int) -> None:
"""Composes the cash line item at the other debit or credit of the
cash journal entry.
:param forms: The line item forms in the same currency.
:param is_debit: True for a cash receipt journal entry, or False for a
cash disbursement journal entry.
:param currency_code: The code of the currency.
:param no: The number of the line item.
:return: None.
"""
candidates: list[JournalEntryLineItem] \
= [x for x in self.__line_items
if x.is_debit == is_debit and x.currency_code == currency_code]
line_item: JournalEntryLineItem
if len(candidates) > 0:
candidates.sort(key=lambda x: x.no)
line_item = candidates[0]
line_item.account_id = Account.cash().id
line_item.description = None
line_item.amount = sum([x.amount.data for x in forms])
line_item.no = no
if db.session.is_modified(line_item):
self.form.is_modified = True
else:
line_item = JournalEntryLineItem()
line_item.id = new_id(JournalEntryLineItem)
line_item.is_debit = is_debit
line_item.currency_code = currency_code
line_item.account_id = Account.cash().id
line_item.description = None
line_item.amount = sum([x.amount.data for x in forms])
line_item.no = no
self.__obj.line_items.append(line_item)
self.form.is_modified = True
self.to_keep.add(line_item.id)
def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
"""Sorts the line item sub-forms.
:param forms: The line item sub-forms.
:return: None.
"""
missing_no: int = 100 if len(self.__no_by_id) == 0 \
else max(self.__no_by_id.values()) + 100
ord_by_form: dict[LineItemForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
missing_no if x.id.data is None else
self.__no_by_id.get(x.id.data, missing_no),
ord_by_form.get(x)))
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
"""Sorts the currency forms.
:param forms: The currency forms.
:return: None.
"""
missing_no: int = len(self.__currencies) + 100
no_by_code: dict[str, int] = {self.__currencies[i].code: i
for i in range(len(self.__currencies))}
ord_by_form: dict[CurrencyForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
no_by_code.get(x.code.data, missing_no),
ord_by_form.get(x)))
class CashReceiptJournalEntryForm(JournalEntryForm):
"""The form to create or edit a cash receipt journal entry."""
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalLineItems(),
NotAfterOffsetItems()])
"""The date."""
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_need_receivable = True
class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
"""The line item collector for the cash receipt journal entries."""
def collect(self) -> None:
currencies: list[CashReceiptCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit cash line item
self._make_cash_line_item(list(currency.credit), True,
currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditLineItemForm] \
= [x.form for x in currency.credit]
self._sort_line_item_forms(credit_forms)
for credit_form in credit_forms:
self._add_line_item(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
class CashDisbursementJournalEntryForm(JournalEntryForm):
"""The form to create or edit a cash disbursement journal entry."""
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalLineItems(),
NotAfterOffsetItems()])
"""The date."""
currencies = FieldList(FormField(CashDisbursementCurrencyForm),
name="currency",
validators=[NeedSomeCurrencies()])
"""The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_need_payable = True
class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
"""The line item collector for the cash disbursement journal
entries."""
def collect(self) -> None:
currencies: list[CashDisbursementCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitLineItemForm] \
= [x.form for x in currency.debit]
self._sort_line_item_forms(debit_forms)
for debit_form in debit_forms:
self._add_line_item(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
self._make_cash_line_item(list(currency.debit), False,
currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
class TransferJournalEntryForm(JournalEntryForm):
"""The form to create or edit a transfer journal entry."""
date = DateField(
validators=[DATE_REQUIRED,
NotBeforeOriginalLineItems(),
NotAfterOffsetItems()])
"""The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
whole_form = BooleanField(
validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._is_need_payable = True
self._is_need_receivable = True
class Collector(LineItemCollector[TransferJournalEntryForm]):
"""The line item collector for the transfer journal entries."""
def collect(self) -> None:
currencies: list[TransferCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitLineItemForm] \
= [x.form for x in currency.debit]
self._sort_line_item_forms(debit_forms)
for debit_form in debit_forms:
self._add_line_item(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditLineItemForm] \
= [x.form for x in currency.credit]
self._sort_line_item_forms(credit_forms)
for credit_form in credit_forms:
self._add_line_item(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector

View File

@ -0,0 +1,537 @@
# 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 line item sub-forms for the journal entry 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, JournalEntryLineItem
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 OriginalLineItemExists:
"""The validator to check if the original line item exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
if db.session.get(JournalEntryLineItem, field.data) is None:
raise ValidationError(lazy_gettext(
"The original line item does not exist."))
class OriginalLineItemOppositeDebitCredit:
"""The validator to check if the original line item is on the opposite
debit or credit."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if isinstance(form, CreditLineItemForm) \
and original_line_item.is_debit:
return
if isinstance(form, DebitLineItemForm) \
and not original_line_item.is_debit:
return
raise ValidationError(lazy_gettext(
"The original line item is on the same debit or credit."))
class OriginalLineItemNeedOffset:
"""The validator to check if the original line item needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if not original_line_item.account.is_need_offset:
raise ValidationError(lazy_gettext(
"The original line item does not need offset."))
class OriginalLineItemNotOffset:
"""The validator to check if the original line item is not itself an
offset item."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem, field.data)
if original_line_item is None:
return
if original_line_item.original_line_item_id is not None:
raise ValidationError(lazy_gettext(
"The original line item cannot be an offset item."))
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 line items."""
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 line items."))
class IsCreditAccount:
"""The validator to check if the account is for credit line items."""
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 line items."))
class SameAccountAsOriginalLineItem:
"""The validator to check if the account is the same as the
original line item."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem,
form.original_line_item_id.data)
if original_line_item is None:
return
if field.data != original_line_item.account_code:
raise ValidationError(lazy_gettext(
"The account must be the same as the original line item."))
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, LineItemForm)
if field.data is None or form.id.data is None:
return
line_item: JournalEntryLineItem | None = db.session\
.query(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id == form.id.data)\
.options(selectinload(JournalEntryLineItem.offsets)).first()
if line_item is None or len(line_item.offsets) == 0:
return
if field.data != line_item.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 line item does not start from
debit."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitLineItemForm)
if field.data is None \
or field.data[0] != "2" \
or form.original_line_item_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 line item cannot start from debit."))
class NotStartReceivableFromCredit:
"""The validator to check that a receivable line item does not start
from credit."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditLineItemForm)
if field.data is None \
or field.data[0] != "1" \
or form.original_line_item_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 line item cannot start from credit."))
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 NotExceedingOriginalLineItemNetBalance:
"""The validator to check if the amount exceeds the net balance of the
original line item."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, LineItemForm)
if field.data is None or form.original_line_item_id.data is None:
return
original_line_item: JournalEntryLineItem | None \
= db.session.get(JournalEntryLineItem,
form.original_line_item_id.data)
if original_line_item is None:
return
is_debit: bool = isinstance(form, DebitLineItemForm)
existing_line_item_id: set[int] = set()
if form.journal_entry_form.obj is not None:
existing_line_item_id \
= {x.id for x in form.journal_entry_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntryLineItem.is_debit == is_debit),
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func)
.filter(be(JournalEntryLineItem.original_line_item_id
== original_line_item.id),
JournalEntryLineItem.id.not_in(existing_line_item_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.journal_entry_form.line_items
if x.original_line_item_id.data == original_line_item.id
and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_line_item.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 line item.", 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, LineItemForm)
if field.data is None or form.id.data is None:
return
is_debit: bool = isinstance(form, DebitLineItemForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\
.filter(be(JournalEntryLineItem.original_line_item_id
== form.id.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 LineItemForm(FlaskForm):
"""The base form to create or edit a line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField()
"""The Id of the original line item."""
account_code = StringField()
"""The account code."""
description = StringField()
"""The description."""
amount = DecimalField()
"""The amount."""
def __init__(self, *args, **kwargs):
"""Constructs a base line item form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
from .journal_entry import JournalEntryForm
self.journal_entry_form: JournalEntryForm | None = None
"""The source journal entry 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_line_item(self) -> JournalEntryLineItem | None:
"""Returns the original line item.
:return: The original line item.
"""
if not hasattr(self, "____original_line_item"):
def get_line_item() -> JournalEntryLineItem | None:
if self.original_line_item_id.data is None:
return None
return db.session.get(JournalEntryLineItem,
self.original_line_item_id.data)
setattr(self, "____original_line_item", get_line_item())
return getattr(self, "____original_line_item")
@property
def original_line_item_date(self) -> date | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original line item.
"""
return None if self.__original_line_item is None \
else self.__original_line_item.journal_entry.date
@property
def original_line_item_text(self) -> str | None:
"""Returns the text representation of the original line item.
:return: The text representation of the original line item.
"""
return None if self.__original_line_item is None \
else str(self.__original_line_item)
@property
def is_need_offset(self) -> bool:
"""Returns whether the line item needs offset.
:return: True if the line item needs offset, or False otherwise.
"""
if self.account_code.data is None:
return False
if self.account_code.data[0] == "1":
if isinstance(self, CreditLineItemForm):
return False
elif self.account_code.data[0] == "2":
if isinstance(self, DebitLineItemForm):
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[JournalEntryLineItem]:
"""Returns the offsets.
:return: The offsets.
"""
if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntryLineItem.query\
.filter(JournalEntryLineItem.original_line_item_id
== self.id.data)\
.options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.offsets)
.selectinload(
JournalEntryLineItem.journal_entry)).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.id.data is None:
return None
is_debit: bool = isinstance(self, DebitLineItemForm)
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.id.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 DebitLineItemForm(LineItemForm):
"""The form to create or edit a debit line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
"""The account code."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntryLineItem)
obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.description = self.description.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 CreditLineItemForm(LineItemForm):
"""The form to create or edit a credit line item."""
id = IntegerField()
"""The existing line item ID."""
no = IntegerField()
"""The order in the currency."""
original_line_item_id = IntegerField(
validators=[Optional(),
OriginalLineItemExists(),
OriginalLineItemOppositeDebitCredit(),
OriginalLineItemNeedOffset(),
OriginalLineItemNotOffset()])
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])
"""The account code."""
description = StringField(filters=[strip_text])
"""The description."""
amount = DecimalField(
validators=[PositiveAmount(),
NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()])
"""The amount."""
def populate_obj(self, obj: JournalEntryLineItem) -> None:
"""Populates the form data into a line item object.
:param obj: The line item object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntryLineItem)
obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.description = self.description.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,95 @@
# 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 journal entry management.
"""
from datetime import date
import sqlalchemy as sa
from flask import request
from accounting import db
from accounting.models import JournalEntry
def sort_journal_entries_in(journal_entry_date: date,
exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or
deleting a journal entry.
:param journal_entry_date: The date of the journal entry.
:param exclude: The journal entry ID to exclude.
:return: None.
"""
conditions: list[sa.BinaryExpression] \
= [JournalEntry.date == journal_entry_date]
if exclude is not None:
conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(*conditions)\
.order_by(JournalEntry.no).all()
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
class JournalEntryReorderForm:
"""The form to reorder the journal entries."""
def __init__(self, journal_entry_date: date):
"""Constructs the form to reorder the journal entries in a day.
:param journal_entry_date: The date.
"""
self.date: date = journal_entry_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
journal_entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.date == self.date).all()
# Collects the specified order.
orders: dict[JournalEntry, int] = {}
for journal_entry in journal_entries:
if f"{journal_entry.id}-no" in request.form:
try:
orders[journal_entry] \
= int(request.form[f"{journal_entry.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[JournalEntry] \
= [x for x in journal_entries if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for journal_entry in missing:
orders[journal_entry] = next_no
# Sort by the specified order first, and their original order.
journal_entries.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1
self.is_modified = True

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The template filters for the transaction management. """The template filters for the journal entry management.
""" """
from decimal import Decimal from decimal import Decimal
@ -26,10 +26,10 @@ from flask import request
def with_type(uri: str) -> str: def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified. """Adds the journal entry type to the URI, if it is specified.
:param uri: The URI. :param uri: The URI.
:return: The result URL, optionally with the transaction type added. :return: The result URL, optionally with the journal entry type added.
""" """
if "as" not in request.args: if "as" not in request.args:
return uri return uri
@ -43,10 +43,10 @@ def with_type(uri: str) -> str:
def to_transfer(uri: str) -> str: def to_transfer(uri: str) -> str:
"""Adds the transfer transaction type to the URI. """Adds the transfer journal entry type to the URI.
:param uri: The URI. :param uri: The URI.
:return: The result URL, with the transfer transaction type added. :return: The result URL, with the transfer journal entry type added.
""" """
uri_p: ParseResult = urlparse(uri) uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query) params: list[tuple[str, str]] = parse_qsl(uri_p.query)

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 journal entry 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 journal entry 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

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The summary editor. """The description editor.
""" """
import typing as t import typing as t
@ -22,14 +22,14 @@ import typing as t
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Account, JournalEntry from accounting.models import Account, JournalEntryLineItem
class SummaryAccount: class DescriptionAccount:
"""An account for a summary tag.""" """An account for a description tag."""
def __init__(self, account: Account, freq: int): def __init__(self, account: Account, freq: int):
"""Constructs an account for a summary tag. """Constructs an account for a description tag.
:param account: The account. :param account: The account.
:param freq: The frequency of the tag with the account. :param freq: The frequency of the tag with the account.
@ -59,17 +59,17 @@ class SummaryAccount:
self.freq = self.freq + freq self.freq = self.freq + freq
class SummaryTag: class DescriptionTag:
"""A summary tag.""" """A description tag."""
def __init__(self, name: str): def __init__(self, name: str):
"""Constructs a summary tag. """Constructs a description tag.
:param name: The tag name. :param name: The tag name.
""" """
self.name: str = name self.name: str = name
"""The tag name.""" """The tag name."""
self.__account_dict: dict[int, SummaryAccount] = {} self.__account_dict: dict[int, DescriptionAccount] = {}
"""The accounts that come with the tag, in the order of their """The accounts that come with the tag, in the order of their
frequency.""" frequency."""
self.freq: int = 0 self.freq: int = 0
@ -89,11 +89,11 @@ class SummaryTag:
:param freq: The frequency of the tag name with the account. :param freq: The frequency of the tag name with the account.
:return: None. :return: None.
""" """
self.__account_dict[account.id] = SummaryAccount(account, freq) self.__account_dict[account.id] = DescriptionAccount(account, freq)
self.freq = self.freq + freq self.freq = self.freq + freq
@property @property
def accounts(self) -> list[SummaryAccount]: def accounts(self) -> list[DescriptionAccount]:
"""Returns the accounts by the order of their frequencies. """Returns the accounts by the order of their frequencies.
:return: The accounts by the order of their frequencies. :return: The accounts by the order of their frequencies.
@ -109,17 +109,17 @@ class SummaryTag:
return [x.code for x in self.accounts] return [x.code for x in self.accounts]
class SummaryType: class DescriptionType:
"""A summary type""" """A description type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]): def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
"""Constructs a summary type. """Constructs a description type.
:param type_id: The type ID, either "general", "travel", or "bus". :param type_id: The type ID, either "general", "travel", or "bus".
""" """
self.id: t.Literal["general", "travel", "bus"] = type_id self.id: t.Literal["general", "travel", "bus"] = type_id
"""The type ID.""" """The type ID."""
self.__tag_dict: dict[str, SummaryTag] = {} self.__tag_dict: dict[str, DescriptionTag] = {}
"""A dictionary from the tag name to their corresponding tag.""" """A dictionary from the tag name to their corresponding tag."""
def add_tag(self, name: str, account: Account, freq: int) -> None: def add_tag(self, name: str, account: Account, freq: int) -> None:
@ -131,11 +131,11 @@ class SummaryType:
:return: None. :return: None.
""" """
if name not in self.__tag_dict: if name not in self.__tag_dict:
self.__tag_dict[name] = SummaryTag(name) self.__tag_dict[name] = DescriptionTag(name)
self.__tag_dict[name].add_account(account, freq) self.__tag_dict[name].add_account(account, freq)
@property @property
def tags(self) -> list[SummaryTag]: def tags(self) -> list[DescriptionTag]:
"""Returns the tags by the order of their frequencies. """Returns the tags by the order of their frequencies.
:return: The tags by the order of their frequencies. :return: The tags by the order of their frequencies.
@ -143,24 +143,24 @@ class SummaryType:
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType: class DescriptionDebitCredit:
"""A summary type""" """The description on debit or credit."""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]): def __init__(self, debit_credit: t.Literal["debit", "credit"]):
"""Constructs a summary entry type. """Constructs the description on debit or credit.
:param entry_type_id: The entry type ID, either "debit" or "credit". :param debit_credit: Either "debit" or "credit".
""" """
self.type: t.Literal["debit", "credit"] = entry_type_id self.debit_credit: t.Literal["debit", "credit"] = debit_credit
"""The entry type.""" """Either debit or credit."""
self.general: SummaryType = SummaryType("general") self.general: DescriptionType = DescriptionType("general")
"""The general tags.""" """The general tags."""
self.travel: SummaryType = SummaryType("travel") self.travel: DescriptionType = DescriptionType("travel")
"""The travel tags.""" """The travel tags."""
self.bus: SummaryType = SummaryType("bus") self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags.""" """The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"], self.__type_dict: dict[t.Literal["general", "travel", "bus"],
SummaryType] \ DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}} = {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags.""" """A dictionary from the type ID to the corresponding tags."""
@ -177,13 +177,13 @@ class SummaryEntryType:
self.__type_dict[tag_type].add_tag(name, account, freq) self.__type_dict[tag_type].add_tag(name, account, freq)
@property @property
def accounts(self) -> list[SummaryAccount]: def accounts(self) -> list[DescriptionAccount]:
"""Returns the suggested accounts of all tags in the summary editor in """Returns the suggested accounts of all tags in the description editor
the entry type, in their frequency order. in debit or credit, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order. :return: The suggested accounts of all tags, in their frequency order.
""" """
accounts: dict[int, SummaryAccount] = {} accounts: dict[int, DescriptionAccount] = {}
freq: dict[int, int] = {} freq: dict[int, int] = {}
for tag_type in self.__type_dict.values(): for tag_type in self.__type_dict.values():
for tag in tag_type.tags: for tag in tag_type.tags:
@ -197,37 +197,43 @@ class SummaryEntryType:
key=lambda x: -freq[x])] key=lambda x: -freq[x])]
class SummaryEditor: class DescriptionEditor:
"""The summary editor.""" """The description editor."""
def __init__(self): def __init__(self):
"""Constructs the summary editor.""" """Constructs the description editor."""
self.debit: SummaryEntryType = SummaryEntryType("debit") self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
"""The debit tags.""" """The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit") self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
"""The credit tags.""" """The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"), debit_credit: sa.Label = sa.case(
else_="credit").label("entry_type") (JournalEntryLineItem.is_debit, "debit"),
else_="credit").label("debit_credit")
tag_type: sa.Label = sa.case( tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"), (JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"), (sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"), JournalEntryLineItem.description.like("_%—_%↔_%")),
"travel"),
else_="general").label("tag_type") else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag") tag: sa.Label = get_prefix(JournalEntryLineItem.description, "")\
select: sa.Select = sa.Select(entry_type, tag_type, tag, .label("tag")
JournalEntry.account_id, select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\ sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None), .filter(JournalEntryLineItem.description.is_not(None),
JournalEntry.summary.like("_%—_%"))\ JournalEntryLineItem.description.like("_%—_%"),
.group_by(entry_type, tag_type, tag, JournalEntry.account_id) JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.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] \
= {x.id: x for x in Account.query = {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()} .filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \ debit_credit_dict: dict[t.Literal["debit", "credit"],
= {x.type: x for x in {self.debit, self.credit}} DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result: for row in result:
entry_type_dict[row.entry_type].add_tag( debit_credit_dict[row.debit_credit].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq) row.tag_type, row.tag, accounts[row.account_id], row.freq)

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 items.
"""
import typing as t
import sqlalchemy as sa
from accounting.models import JournalEntryLineItem
def offset_alias() -> sa.Alias:
"""Returns the SQLAlchemy alias for the offset items.
:return: The SQLAlchemy alias for the offset items.
"""
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(JournalEntryLineItem), name="offset"))

View File

@ -0,0 +1,336 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The operators for different journal entry types.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm
class JournalEntryOperator(ABC):
"""The base journal entry operator."""
CHECK_ORDER: int = -1
"""The order when checking the journal entry operator."""
@property
@abstractmethod
def form(self) -> t.Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
@abstractmethod
def render_create_template(self, form: FlaskForm) -> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
@abstractmethod
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
@abstractmethod
def render_edit_template(self, journal_entry: JournalEntry,
form: FlaskForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
@abstractmethod
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
@property
def _line_item_template(self) -> str:
"""Renders and returns the template for the line item sub-form.
:return: The template for the line item sub-form.
"""
return render_template(
"accounting/journal-entry/include/form-line-item.html",
currency_index="CURRENCY_INDEX",
debit_credit="DEBIT_CREDIT",
line_item_index="LINE_ITEM_INDEX",
form=LineItemForm())
class CashReceiptJournalEntry(JournalEntryOperator):
"""A cash receipt journal entry."""
CHECK_ORDER: int = 2
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
return CashReceiptJournalEntryForm
def render_create_template(self, form: CashReceiptJournalEntryForm) -> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
return render_template(
"accounting/journal-entry/receipt/create.html",
form=form,
journal_entry_type=JournalEntryType.CASH_RECEIPT,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
return render_template("accounting/journal-entry/receipt/detail.html",
obj=journal_entry)
def render_edit_template(self, journal_entry: JournalEntry,
form: CashReceiptJournalEntryForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
return render_template("accounting/journal-entry/receipt/edit.html",
journal_entry=journal_entry, form=form,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
return journal_entry.is_cash_receipt
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/journal-entry/receipt/include/form-currency.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
credit_total="-")
class CashDisbursementJournalEntry(JournalEntryOperator):
"""A cash disbursement journal entry."""
CHECK_ORDER: int = 1
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
return CashDisbursementJournalEntryForm
def render_create_template(self, form: CashDisbursementJournalEntryForm) \
-> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
return render_template(
"accounting/journal-entry/disbursement/create.html",
form=form,
journal_entry_type=JournalEntryType.CASH_DISBURSEMENT,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
return render_template(
"accounting/journal-entry/disbursement/detail.html",
obj=journal_entry)
def render_edit_template(self, journal_entry: JournalEntry,
form: CashDisbursementJournalEntryForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
return render_template(
"accounting/journal-entry/disbursement/edit.html",
journal_entry=journal_entry, form=form,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
return journal_entry.is_cash_disbursement
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/journal-entry/disbursement/include/form-currency.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-")
class TransferJournalEntry(JournalEntryOperator):
"""A transfer journal entry."""
CHECK_ORDER: int = 3
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
"""
return TransferJournalEntryForm
def render_create_template(self, form: TransferJournalEntryForm) -> str:
"""Renders the template for the form to create a journal entry.
:param form: The journal entry form.
:return: the form to create a journal entry.
"""
return render_template(
"accounting/journal-entry/transfer/create.html",
form=form,
journal_entry_type=JournalEntryType.TRANSFER,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def render_detail_template(self, journal_entry: JournalEntry) -> str:
"""Renders the template for the detail page.
:param journal_entry: The journal entry.
:return: the detail page.
"""
return render_template("accounting/journal-entry/transfer/detail.html",
obj=journal_entry)
def render_edit_template(self, journal_entry: JournalEntry,
form: TransferJournalEntryForm) -> str:
"""Renders the template for the form to edit a journal entry.
:param journal_entry: The journal entry.
:param form: The form.
:return: the form to edit a journal entry.
"""
return render_template("accounting/journal-entry/transfer/edit.html",
journal_entry=journal_entry, form=form,
currency_template=self.__currency_template,
line_item_template=self._line_item_template)
def is_my_type(self, journal_entry: JournalEntry) -> bool:
"""Checks and returns whether the journal entry belongs to the type.
:param journal_entry: The journal entry.
:return: True if the journal entry belongs to the type, or False
otherwise.
"""
return True
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/journal-entry/transfer/include/form-currency.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-", credit_total="-")
JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \
= {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(),
JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(),
JournalEntryType.TRANSFER: TransferJournalEntry()}
"""The map from the journal entry types to their operators."""
def get_journal_entry_op(journal_entry: JournalEntry,
is_check_as: bool = False) -> JournalEntryOperator:
"""Returns the journal entry operator that may be specified in the "as"
query parameter. If it is not specified, check the journal entry type from
the journal entry.
:param journal_entry: The journal entry.
:param is_check_as: True to check the "as" parameter, or False otherwise.
:return: None.
"""
if is_check_as and "as" in request.args:
type_dict: dict[str, JournalEntryType] \
= {x.value: x for x in JournalEntryType}
if request.args["as"] not in type_dict:
abort(404)
return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]]
for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(),
key=lambda x: x.CHECK_ORDER):
if journal_entry_type.is_my_type(journal_entry):
return journal_entry_type

View File

@ -0,0 +1,84 @@
# 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 line items.
"""
from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be
from .offset_alias import offset_alias
def get_selectable_original_line_items(
line_item_id_on_form: set[int], is_payable: bool,
is_receivable: bool) -> list[JournalEntryLineItem]:
"""Queries and returns the selectable original line items, with their net
balances. The offset amounts of the form is excluded.
:param line_item_id_on_form: The ID of the line items on the form.
:param is_payable: True to check the payable original line items, or False
otherwise.
:param is_receivable: True to check the receivable original line items, or
False otherwise.
:return: The selectable original line items, with their net balances.
"""
assert is_payable or is_receivable
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(line_item_id_on_form), 0),
(be(offset.c.is_debit == JournalEntryLineItem.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_(JournalEntryLineItem.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))
conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance)\
.join(Account)\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntryLineItem.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()}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
.join(JournalEntry)\
.order_by(JournalEntry.date, JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items

View File

@ -0,0 +1,235 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the journal entry management.
"""
from datetime import date
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
text2html
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
get_journal_entry_op
bp: Blueprint = Blueprint("journal-entry", __name__)
"""The view blueprint for the journal entry management."""
bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
bp.add_app_template_filter(format_amount_input,
"accounting_journal_entry_format_amount_input")
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
@has_permission(can_edit)
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
"""Shows the form to add a journal entry.
:param journal_entry_type: The journal entry type.
:return: The form to add a journal entry.
"""
journal_entry_op: JournalEntryOperator \
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
form: journal_entry_op.form
if "form" in session:
form = journal_entry_op.form(
ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = journal_entry_op.form()
form.date.data = date.today()
return journal_entry_op.render_create_template(form)
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
"""Adds a journal entry.
:param journal_entry_type: The journal entry type.
:return: The redirection to the journal entry detail on success, or the
journal entry creation form on error.
"""
journal_entry_op: JournalEntryOperator \
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
form: journal_entry_op.form = journal_entry_op.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.journal-entry.create",
journal_entry_type=journal_entry_type))))
journal_entry: JournalEntry = JournalEntry()
form.populate_obj(journal_entry)
db.session.add(journal_entry)
db.session.commit()
flash(s(lazy_gettext("The journal entry is added successfully.")),
"success")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
@has_permission(can_view)
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
"""Shows the journal entry detail.
:param journal_entry: The journal entry.
:return: The detail.
"""
journal_entry_op: JournalEntryOperator \
= get_journal_entry_op(journal_entry)
return journal_entry_op.render_detail_template(journal_entry)
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
@has_permission(can_edit)
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
"""Shows the form to edit a journal entry.
:param journal_entry: The journal entry.
:return: The form to edit the journal entry.
"""
journal_entry_op: JournalEntryOperator \
= get_journal_entry_op(journal_entry, is_check_as=True)
form: journal_entry_op.form
if "form" in session:
form = journal_entry_op.form(
ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.obj = journal_entry
form.validate()
else:
form = journal_entry_op.form(obj=journal_entry)
return journal_entry_op.render_edit_template(journal_entry, form)
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Updates a journal entry.
:param journal_entry: The journal entry.
:return: The redirection to the journal entry detail on success, or the
journal entry edit form on error.
"""
journal_entry_op: JournalEntryOperator \
= get_journal_entry_op(journal_entry, is_check_as=True)
form: journal_entry_op.form = journal_entry_op.form(request.form)
form.obj = journal_entry
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.journal-entry.edit",
journal_entry=journal_entry))))
with db.session.no_autoflush:
form.populate_obj(journal_entry)
if not form.is_modified:
flash(s(lazy_gettext("The journal entry was not modified.")),
"success")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
journal_entry.updated_by_id = get_current_user_pk()
journal_entry.updated_at = sa.func.now()
db.session.commit()
flash(s(lazy_gettext("The journal entry is updated successfully.")),
"success")
return redirect(inherit_next(__get_detail_uri(journal_entry)))
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
"""Deletes a journal entry.
:param journal_entry: The journal entry.
:return: The redirection to the journal entry list on success, or the
journal entry detail on error.
"""
journal_entry.delete()
sort_journal_entries_in(journal_entry.date, journal_entry.id)
db.session.commit()
flash(s(lazy_gettext("The journal entry is deleted successfully.")),
"success")
return redirect(or_next(__get_default_page_uri()))
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
@has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str:
"""Shows the order of the journal entries in a same date.
:param journal_entry_date: The date.
:return: The order of the journal entries in the date.
"""
journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == journal_entry_date) \
.order_by(JournalEntry.no).all()
return render_template("accounting/journal-entry/order.html",
date=journal_entry_date, list=journal_entries)
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
@has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect:
"""Reorders the journal entries in a date.
:param journal_entry_date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
form.save_order()
if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success")
return redirect(or_next(__get_default_page_uri()))
db.session.commit()
flash(s(lazy_gettext("The order is updated successfully.")), "success")
return redirect(or_next(__get_default_page_uri()))
def __get_detail_uri(journal_entry: JournalEntry) -> str:
"""Returns the detail URI of a journal entry.
:param journal_entry: The journal entry.
:return: The detail URI of the journal entry.
"""
return url_for("accounting.journal-entry.detail",
journal_entry=journal_entry)
def __get_default_page_uri() -> str:
"""Returns the URI for the default page.
:return: The URI for the default page.
"""
return url_for("accounting.report.default")

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
@ -52,7 +53,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account. :return: The string representation of the base account.
""" """
return f"{self.code} {self.title}" return f"{self.code} {self.title.title()}"
@property @property
def title(self) -> str: def title(self) -> str:
@ -113,8 +114,8 @@ class Account(db.Model):
"""The account number under the base account.""" """The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n = db.Column("title", db.String, nullable=False)
"""The title.""" """The title."""
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False) is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset.""" """Whether the journal entry line items 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())
"""The time of creation.""" """The time of creation."""
@ -138,8 +139,9 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account", l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False) lazy=False)
"""The localized titles.""" """The localized titles."""
entries = db.relationship("JournalEntry", back_populates="account") line_items = db.relationship("JournalEntryLineItem",
"""The journal entries.""" back_populates="account")
"""The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
"""The code of the cash account,""" """The code of the cash account,"""
@ -153,7 +155,7 @@ class Account(db.Model):
:return: The string representation of this account. :return: The string representation of this account.
""" """
return f"{self.base_code}-{self.no:03d} {self.title}" return f"{self.base_code}-{self.no:03d} {self.title.title()}"
@property @property
def code(self) -> str: def code(self) -> str:
@ -197,6 +199,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 +299,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 +315,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."""
@ -346,15 +364,16 @@ class Currency(db.Model):
l10n = db.relationship("CurrencyL10n", back_populates="currency", l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False) lazy=False)
"""The localized names.""" """The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency") line_items = db.relationship("JournalEntryLineItem",
"""The journal entries.""" back_populates="currency")
"""The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the currency. """Returns the string representation of the currency.
:return: The string representation of the currency. :return: The string representation of the currency.
""" """
return f"{self.name} ({self.code})" return f"{self.name.title()} ({self.code})"
@property @property
def name(self) -> str: def name(self) -> str:
@ -430,23 +449,23 @@ class CurrencyL10n(db.Model):
"""The localized name.""" """The localized name."""
class TransactionCurrency: class JournalEntryCurrency:
"""A currency in a transaction.""" """A currency in a journal entry."""
def __init__(self, code: str, debit: list[JournalEntry], def __init__(self, code: str, debit: list[JournalEntryLineItem],
credit: list[JournalEntry]): credit: list[JournalEntryLineItem]):
"""Constructs the currency in the transaction. """Constructs the currency in the journal entry.
:param code: The currency code. :param code: The currency code.
:param debit: The debit entries. :param debit: The debit line items.
:param credit: The credit entries. :param credit: The credit line items.
""" """
self.code: str = code self.code: str = code
"""The currency code.""" """The currency code."""
self.debit: list[JournalEntry] = debit self.debit: list[JournalEntryLineItem] = debit
"""The debit entries.""" """The debit line items."""
self.credit: list[JournalEntry] = credit self.credit: list[JournalEntryLineItem] = credit
"""The credit entries.""" """The credit line items."""
@property @property
def name(self) -> str: def name(self) -> str:
@ -458,28 +477,28 @@ class TransactionCurrency:
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount for x in self.debit]) return sum([x.amount for x in self.debit])
@property @property
def credit_total(self) -> str: def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount for x in self.credit]) return sum([x.amount for x in self.credit])
class Transaction(db.Model): class JournalEntry(db.Model):
"""A transaction.""" """A journal entry."""
__tablename__ = "accounting_transactions" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The transaction ID.""" """The journal entry ID."""
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False)
"""The date.""" """The date."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no = db.Column(db.Integer, nullable=False, default=text("1"))
@ -506,35 +525,38 @@ class Transaction(db.Model):
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction") line_items = db.relationship("JournalEntryLineItem",
"""The journal entries.""" back_populates="journal_entry")
"""The line items."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of this transaction. """Returns the string representation of this journal entry.
:return: The string representation of this transaction. :return: The string representation of this journal entry.
""" """
if self.is_cash_expense: if self.is_cash_disbursement:
return gettext("Cash Expense Transaction#%(id)s", id=self.id) return gettext("Cash Disbursement Journal Entry#%(id)s",
if self.is_cash_income: id=self.id)
return gettext("Cash Income Transaction#%(id)s", id=self.id) if self.is_cash_receipt:
return gettext("Transfer Transaction#%(id)s", id=self.id) return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
return gettext("Transfer Journal Entry#%(id)s", id=self.id)
@property @property
def currencies(self) -> list[TransactionCurrency]: def currencies(self) -> list[JournalEntryCurrency]:
"""Returns the journal entries categorized by their currencies. """Returns the line items categorized by their currencies.
:return: The currency categories. :return: The currency categories.
""" """
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no) line_items: list[JournalEntryLineItem] = sorted(self.line_items,
key=lambda x: x.no)
codes: list[str] = [] codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {} by_currency: dict[str, list[JournalEntryLineItem]] = {}
for entry in entries: for line_item in line_items:
if entry.currency_code not in by_currency: if line_item.currency_code not in by_currency:
codes.append(entry.currency_code) codes.append(line_item.currency_code)
by_currency[entry.currency_code] = [] by_currency[line_item.currency_code] = []
by_currency[entry.currency_code].append(entry) by_currency[line_item.currency_code].append(line_item)
return [TransactionCurrency(code=x, return [JournalEntryCurrency(code=x,
debit=[y for y in by_currency[x] debit=[y for y in by_currency[x]
if y.is_debit], if y.is_debit],
credit=[y for y in by_currency[x] credit=[y for y in by_currency[x]
@ -542,10 +564,11 @@ class Transaction(db.Model):
for x in codes] for x in codes]
@property @property
def is_cash_income(self) -> bool: def is_cash_receipt(self) -> bool:
"""Returns whether this is a cash income transaction. """Returns whether this is a cash receipt journal entry.
:return: True if this is a cash income transaction, or False otherwise. :return: True if this is a cash receipt journal entry, or False
otherwise.
""" """
for currency in self.currencies: for currency in self.currencies:
if len(currency.debit) > 1: if len(currency.debit) > 1:
@ -555,10 +578,10 @@ class Transaction(db.Model):
return True return True
@property @property
def is_cash_expense(self) -> bool: def is_cash_disbursement(self) -> bool:
"""Returns whether this is a cash expense transaction. """Returns whether this is a cash disbursement journal entry.
:return: True if this is a cash expense transaction, or False :return: True if this is a cash disbursement journal entry, or False
otherwise. otherwise.
""" """
for currency in self.currencies: for currency in self.currencies:
@ -568,70 +591,93 @@ class Transaction(db.Model):
return False return False
return True return True
@property
def can_delete(self) -> bool:
"""Returns whether the journal entry can be deleted.
:return: True if the journal entry can be deleted, or False otherwise.
"""
if not hasattr(self, "__can_delete"):
def has_offset() -> bool:
for line_item in self.line_items:
if len(line_item.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 journal entry.
:return: None. :return: None.
""" """
JournalEntry.query\ JournalEntryLineItem.query\
.filter(JournalEntry.transaction_id == self.id).delete() .filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
db.session.delete(self) db.session.delete(self)
class JournalEntry(db.Model): class JournalEntryLineItem(db.Model):
"""An accounting journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The entry ID.""" """The line item ID."""
transaction_id = db.Column(db.Integer, journal_entry_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id, db.ForeignKey(JournalEntry.id,
onupdate="CASCADE", onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False) nullable=False)
"""The transaction ID.""" """The journal entry ID."""
transaction = db.relationship(Transaction, back_populates="entries") journal_entry = db.relationship(JournalEntry, back_populates="line_items")
"""The transaction.""" """The journal entry."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry.""" """True for a debit line item, or False for a credit line item."""
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 line item number under the journal entry and debit or credit."""
offset_original_id = db.Column(db.Integer, original_line_item_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 line item."""
offset_original = db.relationship("JournalEntry", back_populates="offsets", original_line_item = db.relationship("JournalEntryLineItem",
back_populates="offsets",
remote_side=id, passive_deletes=True) remote_side=id, passive_deletes=True)
"""The original entry to offset.""" """The original line item."""
offsets = db.relationship("JournalEntry", back_populates="offset_original") offsets = db.relationship("JournalEntryLineItem",
"""The offset entries.""" back_populates="original_line_item")
"""The offset items."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False) nullable=False)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="entries") currency = db.relationship(Currency, back_populates="line_items")
"""The currency.""" """The currency."""
account_id = db.Column(db.Integer, account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, db.ForeignKey(Account.id,
onupdate="CASCADE"), onupdate="CASCADE"),
nullable=False) nullable=False)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False) account = db.relationship(Account, back_populates="line_items", lazy=False)
"""The account.""" """The account."""
summary = db.Column(db.String, nullable=True) description = db.Column(db.String, nullable=True)
"""The summary.""" """The description."""
amount = db.Column(db.Numeric(14, 2), nullable=False) amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount.""" """The amount."""
@property def __str__(self) -> str:
def eid(self) -> int | None: """Returns the string representation of the line item.
"""Returns the journal entry ID. This is the alternative name of the
ID field, to work with WTForms.
:return: The journal entry ID. :return: The string representation of the line item.
""" """
return self.id if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount
setattr(self, "__str",
gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.journal_entry.date),
description="" if self.description is None
else self.description,
amount=format_amount(self.amount)))
return getattr(self, "__str")
@property @property
def account_code(self) -> str: def account_code(self) -> str:
@ -645,14 +691,74 @@ class JournalEntry(db.Model):
def debit(self) -> Decimal | None: def debit(self) -> Decimal | None:
"""Returns the debit amount. """Returns the debit amount.
:return: The debit amount, or None if this is not a debit entry. :return: The debit amount, or None if this is not a debit line item.
""" """
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 line item needs offset.
:return: True if the line item 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.
: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 line item.
""" """
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:]
journal_entry_day: date = self.journal_entry.date
description: str = "" if self.description is None else self.description
return ([description],
[str(journal_entry_day.year),
"{}/{}".format(journal_entry_day.year,
journal_entry_day.month),
"{}/{}".format(journal_entry_day.month,
journal_entry_day.day),
"{}/{}/{}".format(journal_entry_day.year,
journal_entry_day.month,
journal_entry_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])

View File

@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in
import typing as t import typing as t
from datetime import date from datetime import date
from accounting.models import Transaction from accounting.models import JournalEntry
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
@ -61,8 +61,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod()) self.url_template: str = get_url(TemplatePeriod())
"""The URL template.""" """The URL template."""
first: Transaction | None \ first: JournalEntry | None \
= Transaction.query.order_by(Transaction.date).first() = JournalEntry.query.order_by(JournalEntry.date).first()
start: date | None = None if first is None else first.date start: date | None = None if first is None else first.date
# Attributes # Attributes

View File

@ -35,7 +35,7 @@ class ThisMonth(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "this-month" self.spec = "this-month"
self.desc = gettext("This month") self.desc = gettext("This Month")
self.is_a_month = True self.is_a_month = True
self.is_type_month = True self.is_type_month = True
@ -55,7 +55,7 @@ class LastMonth(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "last-month" self.spec = "last-month"
self.desc = gettext("Last month") self.desc = gettext("Last Month")
self.is_a_month = True self.is_a_month = True
self.is_type_month = True self.is_type_month = True
@ -75,7 +75,7 @@ class SinceLastMonth(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "since-last-month" self.spec = "since-last-month"
self.desc = gettext("Since last month") self.desc = gettext("Since Last Month")
self.is_type_month = True self.is_type_month = True
@ -90,7 +90,7 @@ class ThisYear(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "this-year" self.spec = "this-year"
self.desc = gettext("This year") self.desc = gettext("This Year")
self.is_a_year = True self.is_a_year = True
@ -105,7 +105,7 @@ class LastYear(Period):
def _set_properties(self) -> None: def _set_properties(self) -> None:
self.spec = "last-year" self.spec = "last-year"
self.desc = gettext("Last year") self.desc = gettext("Last Year")
self.is_a_year = True self.is_a_year = True

View File

@ -24,8 +24,8 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntry JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -124,17 +124,17 @@ class AccountCollector:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}] = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)).label("balance") else_=-JournalEntryLineItem.amount)).label("balance")
select_balance: sa.Select \ select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no, = sa.select(Account.id, Account.base_code, Account.no,
balance_func)\ balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\ .group_by(Account.id, Account.base_code, Account.no)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
@ -178,8 +178,8 @@ class AccountCollector:
if self.__period.start is None: if self.__period.start is None:
return None return None
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
Transaction.date < self.__period.start] JournalEntry.date < self.__period.start]
return self.__query_balance(conditions) return self.__query_balance(conditions)
def __add_current_period(self) -> None: def __add_current_period(self) -> None:
@ -188,20 +188,20 @@ 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.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return self.__query_balance(conditions) return self.__query_balance(conditions)
@staticmethod @staticmethod
@ -213,12 +213,12 @@ 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), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)) else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\ select_balance: sa.Select = sa.select(balance_func)\
.join(Transaction).join(Account).filter(*conditions) .join(JournalEntry).join(Account).filter(*conditions)
return db.session.scalar(select_balance) return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None, def __add_owner_s_equity(self, code: str, amount: Decimal | None,

View File

@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -37,27 +38,28 @@ 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
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry | None = None): def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The journal entry line item.
""" """
self.is_brought_forward: bool = False self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total entry.""" """Whether this is the total line item."""
self.date: date | None = None self.date: date | None = None
"""The date.""" """The date."""
self.account: Account | None = None self.account: Account | None = None
"""The account.""" """The account."""
self.summary: str | None = None self.description: str | None = None
"""The summary.""" """The description."""
self.income: Decimal | None = None self.income: Decimal | None = None
"""The income amount.""" """The income amount."""
self.expense: Decimal | None = None self.expense: Decimal | None = None
@ -67,24 +69,24 @@ class ReportEntry:
self.note: str | None = None self.note: str | None = None
"""The note.""" """The note."""
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the journal entry line item."""
if entry is not None: if line_item is not None:
self.date = entry.transaction.date self.date = line_item.journal_entry.date
self.account = entry.account self.account = line_item.account
self.summary = entry.summary self.description = line_item.description
self.income = None if entry.is_debit else entry.amount self.income = None if line_item.is_debit else line_item.amount
self.expense = entry.amount if entry.is_debit else None self.expense = line_item.amount if line_item.is_debit else None
self.note = entry.transaction.note self.note = line_item.journal_entry.note
self.url = url_for("accounting.transaction.detail", self.url = url_for("accounting.journal-entry.detail",
txn=entry.transaction) journal_entry=line_item.journal_entry)
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount, def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period): period: Period):
"""Constructs the report entry collector. """Constructs the line item collector.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
@ -96,73 +98,78 @@ class EntryCollector:
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period""" """The period"""
self.brought_forward: ReportEntry | None self.brought_forward: ReportLineItem | None
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] self.line_items: list[ReportLineItem]
"""The log entries.""" """The line items."""
self.total: ReportEntry | None self.total: ReportLineItem | None
"""The total entry.""" """The total line item."""
self.brought_forward = self.__get_brought_forward_entry() self.brought_forward = self.__get_brought_forward()
self.entries = self.__query_entries() self.line_items = self.__query_line_items()
self.total = self.__get_total_entry() self.total = self.__get_total()
self.__populate_balance() self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None: def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward entry. """Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the period starts from :return: The brought-forward line item, or None if the period starts
the beginning. from the beginning.
""" """
if self.__period.start is None: if self.__period.start is None:
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), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(JournalEntry.currency_code == self.__currency.code, .filter(be(JournalEntryLineItem.currency_code
== self.__currency.code),
self.__account_condition, self.__account_condition,
Transaction.date < self.__period.start) JournalEntry.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:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_brought_forward = True line_item.is_brought_forward = True
entry.date = self.__period.start line_item.date = self.__period.start
entry.account = Account.accumulated_change() line_item.account = Account.accumulated_change()
entry.summary = gettext("Brought forward") line_item.description = gettext("Brought forward")
if balance > 0: if balance > 0:
entry.income = balance line_item.income = balance
elif balance < 0: elif balance < 0:
entry.expense = -balance line_item.expense = -balance
entry.balance = balance line_item.balance = balance
return entry return line_item
def __query_entries(self) -> list[ReportEntry]: def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the log entries. """Queries and returns the line items.
:return: The log entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition] self.__account_condition]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
txn_with_account: sa.Select = sa.Select(Transaction.id).\ journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntry).join(Account).filter(*conditions) join(JournalEntryLineItem).join(Account).filter(*conditions)
return [ReportEntry(x) return [ReportLineItem(x)
for x in JournalEntry.query.join(Transaction).join(Account) for x in JournalEntryLineItem.query
.filter(JournalEntry.transaction_id.in_(txn_with_account), .join(JournalEntry).join(Account)
JournalEntry.currency_code == self.__currency.code, .filter(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account),
JournalEntryLineItem.currency_code
== self.__currency.code,
sa.not_(self.__account_condition)) sa.not_(self.__account_condition))
.order_by(Transaction.date, .order_by(JournalEntry.date,
JournalEntry.is_debit, JournalEntry.no,
JournalEntry.no) JournalEntryLineItem.is_debit,
.options(selectinload(JournalEntry.account), JournalEntryLineItem.no)
selectinload(JournalEntry.transaction))] .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))]
@property @property
def __account_condition(self) -> sa.BinaryExpression: def __account_condition(self) -> sa.BinaryExpression:
@ -173,66 +180,67 @@ class EntryCollector:
Account.base_code.startswith("22")) Account.base_code.startswith("22"))
return Account.id == self.__account.id return Account.id == self.__account.id
def __get_total_entry(self) -> ReportEntry | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total entry. """Composes the total line item.
:return: The total entry, or None if there is no data. :return: The total line item, or None if there is no data.
""" """
if self.brought_forward is None and len(self.entries) == 0: if self.brought_forward is None and len(self.line_items) == 0:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_total = True line_item.is_total = True
entry.summary = gettext("Total") line_item.description = gettext("Total")
entry.income = sum([x.income for x in self.entries line_item.income = sum([x.income for x in self.line_items
if x.income is not None]) if x.income is not None])
entry.expense = sum([x.expense for x in self.entries line_item.expense = sum([x.expense for x in self.line_items
if x.expense is not None]) if x.expense is not None])
entry.balance = entry.income - entry.expense line_item.balance = line_item.income - line_item.expense
if self.brought_forward is not None: if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance line_item.balance \
return entry = self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None: def __populate_balance(self) -> None:
"""Populates the balance of the entries. """Populates the balance of the line items.
:return: None. :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 line_item in self.line_items:
if entry.income is not None: if line_item.income is not None:
balance = balance + entry.income balance = balance + line_item.income
if entry.expense is not None: if line_item.expense is not None:
balance = balance - entry.expense balance = balance - line_item.expense
entry.balance = balance line_item.balance = balance
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: date | str | None, def __init__(self, journal_entry_date: date | str | None,
account: str | None, account: str | None,
summary: str | None, description: str | None,
income: str | Decimal | None, income: str | Decimal | None,
expense: str | Decimal | None, expense: str | Decimal | None,
balance: str | Decimal | None, balance: str | Decimal | None,
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param journal_entry_date: The journal entry date.
:param account: The account. :param account: The account.
:param summary: The summary. :param description: The description.
:param income: The income. :param income: The income.
:param expense: The expense. :param expense: The expense.
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = txn_date self.date: date | str | None = journal_entry_date
"""The date.""" """The date."""
self.account: str | None = account self.account: str | None = account
"""The account.""" """The account."""
self.summary: str | None = summary self.description: str | None = description
"""The summary.""" """The description."""
self.income: str | Decimal | None = income self.income: str | Decimal | None = income
"""The income.""" """The income."""
self.expense: str | Decimal | None = expense self.expense: str | Decimal | None = expense
@ -248,7 +256,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row. :return: The values of the row.
""" """
return [self.date, self.account, self.summary, return [self.date, self.account, self.description,
self.income, self.expense, self.balance, self.note] self.income, self.expense, self.balance, self.note]
@ -259,19 +267,19 @@ class PageParams(BasePageParams):
account: IncomeExpensesAccount, account: IncomeExpensesAccount,
period: Period, period: Period,
has_data: bool, has_data: bool,
pagination: Pagination[ReportEntry], pagination: Pagination[ReportLineItem],
brought_forward: ReportEntry | None, brought_forward: ReportLineItem | None,
entries: list[ReportEntry], line_items: list[ReportLineItem],
total: ReportEntry | None): total: ReportLineItem | None):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
:param period: The period. :param period: The period.
:param has_data: True if there is any data, or False otherwise. :param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry. :param brought_forward: The brought-forward line item.
:param entries: The log entries. :param line_items: The line items.
:param total: The total entry. :param total: The total line item.
""" """
self.currency: Currency = currency self.currency: Currency = currency
"""The currency.""" """The currency."""
@ -281,14 +289,14 @@ class PageParams(BasePageParams):
"""The period.""" """The period."""
self.__has_data: bool = has_data self.__has_data: bool = has_data
"""True if there is any data, or False otherwise.""" """True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination.""" """The pagination."""
self.brought_forward: ReportEntry | None = brought_forward self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] = entries self.line_items: list[ReportLineItem] = line_items
"""The report entries.""" """The line items."""
self.total: ReportEntry | None = total self.total: ReportLineItem | None = total
"""The total entry.""" """The total line item."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x)) lambda x: income_expenses_url(currency, account, x))
"""The period chooser.""" """The period chooser."""
@ -340,14 +348,15 @@ class PageParams(BasePageParams):
income_expenses_url(self.currency, current_al, income_expenses_url(self.currency, current_al,
self.period), self.period),
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(JournalEntry.currency_code == self.currency.code, .filter(be(JournalEntryLineItem.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"),
Account.base_code.startswith("22")))\ Account.base_code.startswith("22")))\
.group_by(JournalEntry.account_id) .group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),
income_expenses_url( income_expenses_url(
self.currency, self.currency,
@ -376,14 +385,15 @@ class IncomeExpenses(BaseReport):
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
collector: EntryCollector = EntryCollector( collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period) self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward self.__brought_forward: ReportLineItem | None \
"""The brought-forward entry.""" = collector.brought_forward
self.__entries: list[ReportEntry] = collector.entries """The brought-forward line item."""
"""The report entries.""" self.__line_items: list[ReportLineItem] = collector.line_items
self.__total: ReportEntry | None = collector.total """The line items."""
"""The total entry.""" self.__total: ReportLineItem | None = collector.total
"""The total line item."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -401,20 +411,20 @@ class IncomeExpenses(BaseReport):
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"), rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
gettext("Summary"), gettext("Income"), gettext("Description"), gettext("Income"),
gettext("Expense"), gettext("Balance"), gettext("Expense"), gettext("Balance"),
gettext("Note"))] gettext("Note"))]
if self.__brought_forward is not None: if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date, rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(), str(self.__brought_forward.account).title(),
self.__brought_forward.summary, self.__brought_forward.description,
self.__brought_forward.income, self.__brought_forward.income,
self.__brought_forward.expense, self.__brought_forward.expense,
self.__brought_forward.balance, self.__brought_forward.balance,
None)) None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary, rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
x.income, x.expense, x.balance, x.note) x.income, x.expense, x.balance, x.note)
for x in self.__entries]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, None, rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense, self.__total.income, self.__total.expense,
@ -426,31 +436,31 @@ class IncomeExpenses(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
all_entries: list[ReportEntry] = [] all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None: if self.__brought_forward is not None:
all_entries.append(self.__brought_forward) all_line_items.append(self.__brought_forward)
all_entries.extend(self.__entries) all_line_items.extend(self.__line_items)
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_line_items.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportLineItem] \
= Pagination[ReportEntry](all_entries) = Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_line_items) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward: if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_entries[0] brought_forward = page_line_items[0]
page_entries = page_entries[1:] page_line_items = page_line_items[1:]
total: ReportEntry | None = None total: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[-1].is_total: if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_entries[-1] total = page_line_items[-1]
page_entries = page_entries[:-1] page_line_items = page_line_items[:-1]
params: PageParams = PageParams(currency=self.__currency, params: PageParams = PageParams(currency=self.__currency,
account=self.__account, account=self.__account,
period=self.__period, period=self.__period,
has_data=has_data, has_data=has_data,
pagination=pagination, pagination=pagination,
brought_forward=brought_forward, brought_forward=brought_forward,
entries=page_entries, line_items=page_line_items,
total=total) total=total)
return render_template("accounting/report/income-expenses.html", return render_template("accounting/report/income-expenses.html",
report=params) report=params)

View File

@ -24,8 +24,8 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Transaction, \ from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntry JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -256,17 +256,17 @@ class IncomeStatement(BaseReport):
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)] = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount), (JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
else_=JournalEntry.amount)).label("balance") else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)

View File

@ -25,7 +25,8 @@ from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -37,58 +38,58 @@ from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry): def __init__(self, line_item: JournalEntryLineItem):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The journal entry line item.
""" """
self.entry: JournalEntry = entry self.line_item: JournalEntryLineItem = line_item
"""The journal entry line item."""
self.journal_entry: JournalEntry = line_item.journal_entry
"""The journal entry.""" """The journal entry."""
self.transaction: Transaction = entry.transaction self.currency: Currency = line_item.currency
"""The transaction."""
self.currency: Currency = entry.currency
"""The account.""" """The account."""
self.account: Account = entry.account self.account: Account = line_item.account
"""The account.""" """The account."""
self.summary: str | None = entry.summary self.description: str | None = line_item.description
"""The summary.""" """The description."""
self.debit: Decimal | None = entry.debit self.debit: Decimal | None = line_item.debit
"""The debit amount.""" """The debit amount."""
self.credit: Decimal | None = entry.credit self.credit: Decimal | None = line_item.credit
"""The credit amount.""" """The credit amount."""
self.amount: Decimal = entry.amount self.amount: Decimal = line_item.amount
"""The amount.""" """The amount."""
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: str | date, def __init__(self, journal_entry_date: str | date,
currency: str, currency: str,
account: str, account: str,
summary: str | None, description: str | None,
debit: str | Decimal | None, debit: str | Decimal | None,
credit: str | Decimal | None, credit: str | Decimal | None,
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param journal_entry_date: The journal entry date.
:param summary: The summary. :param description: The description.
:param debit: The debit amount. :param debit: The debit amount.
:param credit: The credit amount. :param credit: The credit amount.
:param note: The note. :param note: The note.
""" """
self.date: str | date = txn_date self.date: str | date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""
self.account: str = account self.account: str = account
"""The account.""" """The account."""
self.summary: str | None = summary self.description: str | None = description
"""The summary.""" """The description."""
self.debit: str | Decimal | None = debit self.debit: str | Decimal | None = debit
"""The debit amount.""" """The debit amount."""
self.credit: str | Decimal | None = credit self.credit: str | Decimal | None = credit
@ -102,7 +103,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row. :return: The values of the row.
""" """
return [self.date, self.currency, self.account, self.summary, return [self.date, self.currency, self.account, self.description,
self.debit, self.credit, self.note] self.debit, self.credit, self.note]
@ -110,19 +111,19 @@ class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, period: Period, def __init__(self, period: Period,
pagination: Pagination[JournalEntry], pagination: Pagination[JournalEntryLineItem],
entries: list[JournalEntry]): line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param period: The period. :param period: The period.
:param entries: The journal entries. :param line_items: The line items.
""" """
self.period: Period = period self.period: Period = period
"""The period.""" """The period."""
self.pagination: Pagination[JournalEntry] = pagination self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination.""" """The pagination."""
self.entries: list[JournalEntry] = entries self.line_items: list[JournalEntryLineItem] = line_items
"""The entries.""" """The line items."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: journal_url(x)) lambda x: journal_url(x))
"""The period chooser.""" """The period chooser."""
@ -133,7 +134,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise. :return: True if there is any data, or False otherwise.
""" """
return len(self.entries) > 0 return len(self.line_items) > 0
@property @property
def report_chooser(self) -> ReportChooser: def report_chooser(self) -> ReportChooser:
@ -145,20 +146,20 @@ class PageParams(BasePageParams):
period=self.period) period=self.period)
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]: def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the report entries. """Composes and returns the CSV rows from the line items.
:param entries: The report entries. :param line_items: The line items.
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"), rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"), gettext("Account"), gettext("Description"),
gettext("Debit"), gettext("Credit"), gettext("Debit"), gettext("Credit"),
gettext("Note"))] gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code, rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account).title(), x.summary, str(x.account).title(), x.description,
x.debit, x.credit, x.transaction.note) x.debit, x.credit, x.journal_entry.note)
for x in entries]) for x in line_items])
return rows return rows
@ -172,27 +173,29 @@ class Journal(BaseReport):
""" """
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
self.__entries: list[JournalEntry] = self.__query_entries() self.__line_items: list[JournalEntryLineItem] \
"""The journal entries.""" = self.__query_line_items()
"""The line items."""
def __query_entries(self) -> list[JournalEntry]: def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the journal entries. """Queries and returns the line items.
:return: The journal entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntry.query.join(Transaction)\ return JournalEntryLineItem.query.join(JournalEntry)\
.filter(*conditions)\ .filter(*conditions)\
.order_by(Transaction.date, .order_by(JournalEntry.date,
JournalEntry.is_debit.desc(), JournalEntry.no,
JournalEntry.no)\ JournalEntryLineItem.is_debit.desc(),
.options(selectinload(JournalEntry.account), JournalEntryLineItem.no)\
selectinload(JournalEntry.currency), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -200,17 +203,18 @@ class Journal(BaseReport):
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = f"journal-{period_spec(self.__period)}.csv" filename: str = f"journal-{period_spec(self.__period)}.csv"
return csv_download(filename, get_csv_rows(self.__entries)) return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str: def html(self) -> str:
"""Composes and returns the report as HTML. """Composes and returns the report as HTML.
:return: The report as HTML. :return: The report as HTML.
""" """
pagination: Pagination[JournalEntry] \ pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntry](self.__entries) = Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(period=self.__period, params: PageParams = PageParams(period=self.__period,
pagination=pagination, pagination=pagination,
entries=pagination.list) line_items=pagination.list)
return render_template("accounting/report/journal.html", return render_template("accounting/report/journal.html",
report=params) report=params)

View File

@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -36,25 +37,26 @@ 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
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry | None = None): def __init__(self, line_item: JournalEntryLineItem | None = None):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The journal entry line item.
""" """
self.is_brought_forward: bool = False self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total entry.""" """Whether this is the total line item."""
self.date: date | None = None self.date: date | None = None
"""The date.""" """The date."""
self.summary: str | None = None self.description: str | None = None
"""The summary.""" """The description."""
self.debit: Decimal | None = None self.debit: Decimal | None = None
"""The debit amount.""" """The debit amount."""
self.credit: Decimal | None = None self.credit: Decimal | None = None
@ -64,22 +66,22 @@ class ReportEntry:
self.note: str | None = None self.note: str | None = None
"""The note.""" """The note."""
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the journal entry line item."""
if entry is not None: if line_item is not None:
self.date = entry.transaction.date self.date = line_item.journal_entry.date
self.summary = entry.summary self.description = line_item.description
self.debit = entry.amount if entry.is_debit else None self.debit = line_item.amount if line_item.is_debit else None
self.credit = None if entry.is_debit else entry.amount self.credit = None if line_item.is_debit else line_item.amount
self.note = entry.transaction.note self.note = line_item.journal_entry.note
self.url = url_for("accounting.transaction.detail", self.url = url_for("accounting.journal-entry.detail",
txn=entry.transaction) journal_entry=line_item.journal_entry)
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self, currency: Currency, account: Account, period: Period): def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the report entry collector. """Constructs the line item collector.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
@ -91,123 +93,131 @@ class EntryCollector:
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period""" """The period"""
self.brought_forward: ReportEntry | None self.brought_forward: ReportLineItem | None
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] self.line_items: list[ReportLineItem]
"""The report entries.""" """The line items."""
self.total: ReportEntry | None self.total: ReportLineItem | None
"""The total entry.""" """The total line item."""
self.brought_forward = self.__get_brought_forward_entry() self.brought_forward = self.__get_brought_forward()
self.entries = self.__query_entries() self.line_items = self.__query_line_items()
self.total = self.__get_total_entry() self.total = self.__get_total()
self.__populate_balance() self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None: def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward entry. """Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the report starts from :return: The brought-forward line item, or None if the report starts
the beginning. from the beginning.
""" """
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), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(Transaction)\ select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(JournalEntry.currency_code == self.__currency.code, .filter(be(JournalEntryLineItem.currency_code
JournalEntry.account_id == self.__account.id, == self.__currency.code),
Transaction.date < self.__period.start) be(JournalEntryLineItem.account_id
== self.__account.id),
JournalEntry.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:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_brought_forward = True line_item.is_brought_forward = True
entry.date = self.__period.start line_item.date = self.__period.start
entry.summary = gettext("Brought forward") line_item.description = gettext("Brought forward")
if balance > 0: if balance > 0:
entry.debit = balance line_item.debit = balance
elif balance < 0: elif balance < 0:
entry.credit = -balance line_item.credit = -balance
entry.balance = balance line_item.balance = balance
return entry return line_item
def __query_entries(self) -> list[ReportEntry]: def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the report entries. """Queries and returns the line items.
:return: The report entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id] JournalEntryLineItem.account_id == self.__account.id]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction) return [ReportLineItem(x) for x in JournalEntryLineItem.query
.join(JournalEntry)
.filter(*conditions) .filter(*conditions)
.order_by(Transaction.date, .order_by(JournalEntry.date,
JournalEntry.is_debit.desc(), JournalEntry.no,
JournalEntry.no) JournalEntryLineItem.is_debit.desc(),
.options(selectinload(JournalEntry.transaction)).all()] JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry))
.all()]
def __get_total_entry(self) -> ReportEntry | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total entry. """Composes the total line item.
:return: The total entry, or None if there is no data. :return: The total line item, or None if there is no data.
""" """
if self.brought_forward is None and len(self.entries) == 0: if self.brought_forward is None and len(self.line_items) == 0:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_total = True line_item.is_total = True
entry.summary = gettext("Total") line_item.description = gettext("Total")
entry.debit = sum([x.debit for x in self.entries line_item.debit = sum([x.debit for x in self.line_items
if x.debit is not None]) if x.debit is not None])
entry.credit = sum([x.credit for x in self.entries line_item.credit = sum([x.credit for x in self.line_items
if x.credit is not None]) if x.credit is not None])
entry.balance = entry.debit - entry.credit line_item.balance = line_item.debit - line_item.credit
if self.brought_forward is not None: if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance line_item.balance \
return entry = self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None: def __populate_balance(self) -> None:
"""Populates the balance of the entries. """Populates the balance of the line items.
: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 line_item in self.line_items:
if entry.debit is not None: if line_item.debit is not None:
balance = balance + entry.debit balance = balance + line_item.debit
if entry.credit is not None: if line_item.credit is not None:
balance = balance - entry.credit balance = balance - line_item.credit
entry.balance = balance line_item.balance = balance
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, txn_date: date | str | None, def __init__(self, journal_entry_date: date | str | None,
summary: str | None, description: str | None,
debit: str | Decimal | None, debit: str | Decimal | None,
credit: str | Decimal | None, credit: str | Decimal | None,
balance: str | Decimal | None, balance: str | Decimal | None,
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param txn_date: The transaction date. :param journal_entry_date: The journal entry date.
:param summary: The summary. :param description: The description.
:param debit: The debit amount. :param debit: The debit amount.
:param credit: The credit amount. :param credit: The credit amount.
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = txn_date self.date: date | str | None = journal_entry_date
"""The date.""" """The date."""
self.summary: str | None = summary self.description: str | None = description
"""The summary.""" """The description."""
self.debit: str | Decimal | None = debit self.debit: str | Decimal | None = debit
"""The debit amount.""" """The debit amount."""
self.credit: str | Decimal | None = credit self.credit: str | Decimal | None = credit
@ -223,7 +233,7 @@ class CSVRow(BaseCSVRow):
:return: The values of the row. :return: The values of the row.
""" """
return [self.date, self.summary, return [self.date, self.description,
self.debit, self.credit, self.balance, self.note] self.debit, self.credit, self.balance, self.note]
@ -234,19 +244,19 @@ class PageParams(BasePageParams):
account: Account, account: Account,
period: Period, period: Period,
has_data: bool, has_data: bool,
pagination: Pagination[ReportEntry], pagination: Pagination[ReportLineItem],
brought_forward: ReportEntry | None, brought_forward: ReportLineItem | None,
entries: list[ReportEntry], line_items: list[ReportLineItem],
total: ReportEntry | None): total: ReportLineItem | None):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
:param period: The period. :param period: The period.
:param has_data: True if there is any data, or False otherwise. :param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry. :param brought_forward: The brought-forward line item.
:param entries: The report entries. :param line_items: The line items.
:param total: The total entry. :param total: The total line item.
""" """
self.currency: Currency = currency self.currency: Currency = currency
"""The currency.""" """The currency."""
@ -256,14 +266,14 @@ class PageParams(BasePageParams):
"""The period.""" """The period."""
self.__has_data: bool = has_data self.__has_data: bool = has_data
"""True if there is any data, or False otherwise.""" """True if there is any data, or False otherwise."""
self.pagination: Pagination[ReportEntry] = pagination self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination.""" """The pagination."""
self.brought_forward: ReportEntry | None = brought_forward self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] = entries self.line_items: list[ReportLineItem] = line_items
"""The entries.""" """The line items."""
self.total: ReportEntry | None = total self.total: ReportLineItem | None = total
"""The total entry.""" """The total line item."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: ledger_url(currency, account, x)) lambda x: ledger_url(currency, account, x))
"""The period chooser.""" """The period chooser."""
@ -302,9 +312,10 @@ 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(JournalEntryLineItem.account_id)\
.filter(JournalEntry.currency_code == self.currency.code)\ .filter(be(JournalEntryLineItem.currency_code
.group_by(JournalEntry.account_id) == self.currency.code))\
.group_by(JournalEntryLineItem.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)
for x in Account.query.filter(Account.id.in_(in_use)) for x in Account.query.filter(Account.id.in_(in_use))
@ -327,14 +338,15 @@ class Ledger(BaseReport):
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
collector: EntryCollector = EntryCollector( collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period) self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward self.__brought_forward: ReportLineItem | None \
"""The brought-forward entry.""" = collector.brought_forward
self.__entries: list[ReportEntry] = collector.entries """The brought-forward line item."""
"""The report entries.""" self.__line_items: list[ReportLineItem] = collector.line_items
self.__total: ReportEntry | None = collector.total """The line items."""
"""The total entry.""" self.__total: ReportLineItem | None = collector.total
"""The total line item."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -351,19 +363,19 @@ class Ledger(BaseReport):
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"), rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
gettext("Debit"), gettext("Credit"), gettext("Debit"), gettext("Credit"),
gettext("Balance"), gettext("Note"))] gettext("Balance"), gettext("Note"))]
if self.__brought_forward is not None: if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date, rows.append(CSVRow(self.__brought_forward.date,
self.__brought_forward.summary, self.__brought_forward.description,
self.__brought_forward.debit, self.__brought_forward.debit,
self.__brought_forward.credit, self.__brought_forward.credit,
self.__brought_forward.balance, self.__brought_forward.balance,
None)) None))
rows.extend([CSVRow(x.date, x.summary, rows.extend([CSVRow(x.date, x.description,
x.debit, x.credit, x.balance, x.note) x.debit, x.credit, x.balance, x.note)
for x in self.__entries]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, rows.append(CSVRow(gettext("Total"), None,
self.__total.debit, self.__total.credit, self.__total.debit, self.__total.credit,
@ -375,31 +387,31 @@ class Ledger(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
all_entries: list[ReportEntry] = [] all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None: if self.__brought_forward is not None:
all_entries.append(self.__brought_forward) all_line_items.append(self.__brought_forward)
all_entries.extend(self.__entries) all_line_items.extend(self.__line_items)
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_line_items.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportLineItem] \
= Pagination[ReportEntry](all_entries) = Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_line_items) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward: if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_entries[0] brought_forward = page_line_items[0]
page_entries = page_entries[1:] page_line_items = page_line_items[1:]
total: ReportEntry | None = None total: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[-1].is_total: if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_entries[-1] total = page_line_items[-1]
page_entries = page_entries[:-1] page_line_items = page_line_items[:-1]
params: PageParams = PageParams(currency=self.__currency, params: PageParams = PageParams(currency=self.__currency,
account=self.__account, account=self.__account,
period=self.__period, period=self.__period,
has_data=has_data, has_data=has_data,
pagination=pagination, pagination=pagination,
brought_forward=brought_forward, brought_forward=brought_forward,
entries=page_entries, line_items=page_line_items,
total=total) total=total)
return render_template("accounting/report/ledger.html", return render_template("accounting/report/ledger.html",
report=params) report=params)

View File

@ -26,29 +26,30 @@ from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \ from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Transaction, JournalEntry JournalEntry, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport 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
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self): def __init__(self):
"""Constructs the report entry collector.""" """Constructs the line item collector."""
self.entries: list[JournalEntry] = self.__query_entries() self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
"""The report entries.""" """The line items."""
def __query_entries(self) -> list[JournalEntry]: def __query_line_items(self) -> list[JournalEntryLineItem]:
"""Queries and returns the journal entries. """Queries and returns the line items.
:return: The journal entries. :return: The line items.
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
@ -56,22 +57,28 @@ class EntryCollector:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [JournalEntry.summary.contains(k), = [JournalEntryLineItem.description.icontains(k),
JournalEntry.account_id.in_( JournalEntryLineItem.account_id.in_(
self.__get_account_condition(k)), self.__get_account_condition(k)),
JournalEntry.currency_code.in_( JournalEntryLineItem.currency_code.in_(
self.__get_currency_condition(k)), self.__get_currency_condition(k)),
JournalEntry.transaction_id.in_( JournalEntryLineItem.journal_entry_id.in_(
self.__get_transaction_condition(k))] self.__get_journal_entry_condition(k))]
try: try:
sub_conditions.append(JournalEntry.amount == Decimal(k)) sub_conditions.append(
JournalEntryLineItem.amount == Decimal(k))
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.filter(*conditions)\ return JournalEntryLineItem.query.join(JournalEntry)\
.options(selectinload(JournalEntry.account), .filter(*conditions)\
selectinload(JournalEntry.currency), .order_by(JournalEntry.date,
selectinload(JournalEntry.transaction)).all() JournalEntry.no,
JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
@staticmethod @staticmethod
def __get_account_condition(k: str) -> sa.Select: def __get_account_condition(k: str) -> sa.Select:
@ -85,14 +92,14 @@ class EntryCollector:
sa.func.char_length(sa.cast(Account.no, sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1) sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\ select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.contains(k)) .filter(AccountL10n.title.icontains(k))
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.contains(k), Account.title_l10n.icontains(k),
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("Needs 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
@ -103,57 +110,63 @@ class EntryCollector:
:return: The condition to filter the currency. :return: The condition to filter the currency.
""" """
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\ select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.contains(k)) .filter(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code).filter( return sa.select(Currency.code).filter(
sa.or_(Currency.code.contains(k), sa.or_(Currency.code.icontains(k),
Currency.name_l10n.contains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(select_l10n))) Currency.code.in_(select_l10n)))
@staticmethod @staticmethod
def __get_transaction_condition(k: str) -> sa.Select: def __get_journal_entry_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the transaction. """Composes and returns the condition to filter the journal entry.
:param k: The keyword. :param k: The keyword.
:return: The condition to filter the transaction. :return: The condition to filter the journal entry.
""" """
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)] conditions: list[sa.BinaryExpression] \
txn_date: datetime = [JournalEntry.note.icontains(k)]
journal_entry_date: datetime
try: try:
txn_date = datetime.strptime(k, "%Y") journal_entry_date = datetime.strptime(k, "%Y")
conditions.append( conditions.append(
sa.extract("year", Transaction.date) == txn_date.year) be(sa.extract("year", JournalEntry.date)
== journal_entry_date.year))
except ValueError: except ValueError:
pass pass
try: try:
txn_date = datetime.strptime(k, "%Y/%m") journal_entry_date = datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("year", Transaction.date) == txn_date.year, sa.extract("year", JournalEntry.date)
sa.extract("month", Transaction.date) == txn_date.month)) == journal_entry_date.year,
sa.extract("month", JournalEntry.date)
== journal_entry_date.month))
except ValueError: except ValueError:
pass pass
try: try:
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("month", Transaction.date) == txn_date.month, sa.extract("month", JournalEntry.date)
sa.extract("day", Transaction.date) == txn_date.day)) == journal_entry_date.month,
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
except ValueError: except ValueError:
pass pass
return sa.select(Transaction.id).filter(sa.or_(*conditions)) return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
class PageParams(BasePageParams): class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, pagination: Pagination[JournalEntry], def __init__(self, pagination: Pagination[JournalEntryLineItem],
entries: list[JournalEntry]): line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param entries: The search result entries. :param line_items: The search result line items.
""" """
self.pagination: Pagination[JournalEntry] = pagination self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination.""" """The pagination."""
self.entries: list[JournalEntry] = entries self.line_items: list[JournalEntryLineItem] = line_items
"""The entries.""" """The line items."""
@property @property
def has_data(self) -> bool: def has_data(self) -> bool:
@ -161,7 +174,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise. :return: True if there is any data, or False otherwise.
""" """
return len(self.entries) > 0 return len(self.line_items) > 0
@property @property
def report_chooser(self) -> ReportChooser: def report_chooser(self) -> ReportChooser:
@ -177,8 +190,9 @@ class Search(BaseReport):
def __init__(self): def __init__(self):
"""Constructs a search.""" """Constructs a search."""
self.__entries: list[JournalEntry] = EntryCollector().entries self.__line_items: list[JournalEntryLineItem] \
"""The journal entries.""" = LineItemCollector().line_items
"""The line items."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -186,16 +200,17 @@ class Search(BaseReport):
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = "search-{q}.csv".format(q=request.args["q"]) filename: str = "search-{q}.csv".format(q=request.args["q"])
return csv_download(filename, get_csv_rows(self.__entries)) return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str: def html(self) -> str:
"""Composes and returns the report as HTML. """Composes and returns the report as HTML.
:return: The report as HTML. :return: The report as HTML.
""" """
pagination: Pagination[JournalEntry] \ pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntry](self.__entries) = Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(pagination=pagination, params: PageParams = PageParams(pagination=pagination,
entries=pagination.list) line_items=pagination.list)
return render_template("accounting/report/search.html", return render_template("accounting/report/search.html",
report=params) report=params)

View File

@ -24,7 +24,8 @@ from flask import Response, render_template
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Transaction, JournalEntry from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -178,16 +179,16 @@ class TrialBalance(BaseReport):
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Transaction.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Transaction.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntry.amount)).label("balance") else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Transaction).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)

View File

@ -26,8 +26,8 @@ import sqlalchemy as sa
from flask import request from flask import request
from accounting import db from accounting import db
from accounting.models import Currency, JournalEntry from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.txn_types import TransactionType from accounting.utils.journal_entry_types import JournalEntryType
from .option_link import OptionLink from .option_link import OptionLink
from .report_chooser import ReportChooser from .report_chooser import ReportChooser
@ -52,12 +52,12 @@ class BasePageParams(ABC):
""" """
@property @property
def txn_types(self) -> t.Type[TransactionType]: def journal_entry_types(self) -> t.Type[JournalEntryType]:
"""Returns the transaction types. """Returns the journal entry types.
:return: The transaction types. :return: The journal entry types.
""" """
return TransactionType return JournalEntryType
@property @property
def csv_uri(self) -> str: def csv_uri(self) -> str:
@ -81,8 +81,8 @@ class BasePageParams(ABC):
:return: The currency options. :return: The currency options.
""" """
in_use: set[str] = set(db.session.scalars( in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code) sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntry.currency_code)).all()) .group_by(JournalEntryLineItem.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code) return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use)) for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()] .order_by(Currency.code).all()]

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,46 +116,70 @@
border-bottom: thick double slategray; border-bottom: thick double slategray;
} }
/* Links between objects */
.accounting-original-line-item {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-original-line-item a {
color: inherit;
text-decoration: none;
}
.accounting-original-line-item a:hover {
color: inherit;
}
.accounting-offset-line-items {
border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem;
}
.accounting-offset-line-items ul li {
list-style: none;
}
.accounting-offset-line-items ul li a {
color: inherit;
text-decoration: none;
}
.accounting-offset-line-items ul li a:hover {
color: inherit;
}
/** The option selector */ /** The option selector */
.accounting-selector-list { .accounting-selector-list {
height: 20rem; height: 20rem;
overflow-y: scroll; overflow-y: scroll;
} }
/** The transaction management */ /** The journal entry management */
.accounting-currency-control { .accounting-currency-control {
background-color: transparent; background-color: transparent;
} }
.accounting-currency-content { .accounting-currency-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
} }
.accounting-entry-content { .accounting-line-item-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
background-color: transparent; background-color: transparent;
} }
.accounting-entry-control {
border-color: transparent;
}
.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;
} }
.accounting-transaction-entry { .accounting-journal-entry-line-item {
border: none; border: none;
} }
.accounting-transaction-entry-header { .accounting-journal-entry-line-item-header {
font-weight: bolder; font-weight: bolder;
border-bottom: thick double slategray; border-bottom: thick double slategray;
} }
.list-group-item.accounting-transaction-entry-total { .list-group-item.accounting-journal-entry-line-item-total {
font-weight: bolder; font-weight: bolder;
border-top: thick double slategray; border-top: thick double slategray;
} }
.accounting-line-item-editor-original-line-item-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 +218,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,90 +24,293 @@
// 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");
} }
initializeBaseAccountQuery();
} }
/** /**
* Initializes the query on the base account options. * 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();
}
}
/**
* 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) {
/**
* The selector modal
* @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 {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);
};
}
this.#clearButton.onclick = () => {
this.#form.clearBaseAccount();
};
this.#initializeBaseAccountQuery();
}
/**
* Initializes the query.
*
*/
#initializeBaseAccountQuery() {
this.#query.addEventListener("input", () => {
if (this.#query.value === "") {
for (const option of this.#options) {
option.classList.remove("d-none"); option.classList.remove("d-none");
} }
optionList.classList.remove("d-none"); this.#optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none"); this.#queryNoResult.classList.add("d-none");
return return
} }
let hasAnyMatched = false; let hasAnyMatched = false;
for (const option of options) { for (const option of this.#options) {
const queryValues = JSON.parse(option.dataset.queryValues); const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false; let isMatched = false;
for (const queryValue of queryValues) { for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) { if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
isMatched = true; isMatched = true;
break; break;
} }
@ -120,65 +323,36 @@ function initializeBaseAccountQuery() {
} }
} }
if (!hasAnyMatched) { if (!hasAnyMatched) {
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");
} }
}); });
} }
/** /**
* Validates the form. * The callback when the base account selector is shown.
* *
* @returns {boolean} true if valid, or false otherwise * @param baseCode {string} the active base code
* @private
*/ */
function validateForm() { onOpen(baseCode) {
let isValid = true; for (const option of this.#options) {
isValid = validateBase() && isValid; if (option.dataset.code === baseCode) {
isValid = validateTitle() && isValid; option.classList.add("active");
return isValid; } else {
option.classList.remove("active");
} }
/**
* 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"); if (baseCode === "") {
error.innerText = ""; this.#clearButton.classList.add("btn-secondary")
return true; this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true;
} else {
this.#clearButton.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary")
this.#clearButton.disabled = false;
} }
/**
* 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

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form * account-selector.js: The JavaScript for the account selector
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.
@ -22,11 +22,6 @@
*/ */
"use strict"; "use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
AccountSelector.initialize();
});
/** /**
* The account selector. * The account selector.
* *
@ -34,10 +29,16 @@ document.addEventListener("DOMContentLoaded", () => {
class AccountSelector { class AccountSelector {
/** /**
* The entry type * The line item editor
* @type {JournalEntryLineItemEditor}
*/
#lineItemEditor;
/**
* Either "debit" or "credit"
* @type {string} * @type {string}
*/ */
#entryType; #debitCredit;
/** /**
* 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 lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/ */
constructor(modal) { constructor(lineItemEditor, debitCredit) {
this.#entryType = modal.dataset.entryType; this.#lineItemEditor = lineItemEditor
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; this.#debitCredit = debitCredit;
this.#init(); this.#prefix = "accounting-account-selector-" + debitCredit;
} 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();
btnClear.onclick = () => {
formAccountControl.classList.remove("accounting-not-empty");
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.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
for (const option of this.#options) {
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
} }
} 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,33 +142,31 @@ 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.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
const formAccount = document.getElementById("accounting-entry-form-account"); if (this.#lineItemEditor.accountCode !== null) {
const inUse = [formAccount.dataset.code]; inUse.push(this.#lineItemEditor.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;
} }
const queryValues = JSON.parse(option.dataset.queryValues); const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) { for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) { if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
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.#lineItemEditor.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.#lineItemEditor.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 lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: AccountSelector, credit: AccountSelector}}
*/ */
static initialize() { static getInstances(lineItemEditor) {
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.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
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 * @private
*/ */
let isAsyncValid = {}; class CurrencyForm {
/**
* The form.
* @type {HTMLFormElement}
*/
#formElement;
/**
* The code
* @type {HTMLInputElement}
*/
#code;
/**
* The error message of the code
* @type {HTMLDivElement}
*/
#codeError;
/**
* The name
* @type {HTMLInputElement}
*/
#name;
/**
* 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 false;
};
}
/** /**
* Validates the form. * Validates the form.
* *
* @returns {boolean} true if valid, or false otherwise * @returns {Promise<boolean>} true if valid, or false otherwise
* @private
*/ */
function validateForm() { async #validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true; let isValid = true;
isValid = validateCode() && isValid; isValid = await this.#validateCode() && isValid;
isValid = validateName() && isValid; isValid = this.#validateName() && isValid;
isAsyncValid["_sync"] = isValid; return isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
for (const key of Object.keys(isAsyncValid)) {
isValid = isAsyncValid[key] && isValid;
}
if (isValid) {
document.getElementById("accounting-form").submit()
}
} }
/** /**
* Validates the code. * Validates the code.
* *
* @param changeEvent {Event} the change event, if invoked from onchange * @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise * @returns {Promise<boolean>} true if valid, or false otherwise
* @private
*/ */
function validateCode(changeEvent = null) { async #validateCode(changeEvent = null) {
const key = "code"; this.#code.value = this.#code.value.trim();
const isSubmission = changeEvent === null; if (this.#code.value === "") {
let hasAsyncValidation = false; this.#code.classList.add("is-invalid");
const field = document.getElementById("accounting-code"); this.#codeError.innerText = A_("Please fill in the 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; return false;
} }
const blocklist = JSON.parse(field.dataset.blocklist); const blocklist = JSON.parse(this.#code.dataset.blocklist);
if (blocklist.includes(field.value)) { if (blocklist.includes(this.#code.value)) {
field.classList.add("is-invalid"); this.#code.classList.add("is-invalid");
error.innerText = A_("This code is not available."); this.#codeError.innerText = A_("This code is not available.");
return false; return false;
} }
if (!field.value.match(/^[A-Z]{3}$/)) { if (!this.#code.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid"); this.#code.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters."); this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false; return false;
} }
const original = field.dataset.original; const original = this.#code.dataset.original;
if (original === "" || field.value !== original) { if (original === "" || this.#code.value !== original) {
hasAsyncValidation = true; const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
validateAsyncCodeIsDuplicated(isSubmission, key); 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;
} }
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
} }
this.#code.classList.remove("is-invalid");
this.#codeError.innerText = "";
return true; return true;
} }
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
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
* @private
*/ */
function validateName() { #validateName() {
const field = document.getElementById("accounting-name"); this.#name.value = this.#name.value.trim();
const error = document.getElementById("accounting-name-error"); if (this.#name.value === "") {
field.value = field.value.trim(); this.#name.classList.add("is-invalid");
if (field.value === "") { this.#nameError.innerText = A_("Please fill in the name.");
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
return false; return false;
} }
field.classList.remove("is-invalid"); this.#name.classList.remove("is-invalid");
error.innerText = ""; this.#nameError.innerText = "";
return true; return true;
} }
/**
* The form
* @type {CurrencyForm}
*/
static #form;
/**
* Initializes the currency form.
*
*/
static initialize() {
this.#form = new CurrencyForm();
}
}

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* summary-editor.js: The JavaScript for the summary editor * description-editor.js: The JavaScript for the description editor
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.
@ -22,19 +22,20 @@
*/ */
"use strict"; "use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
SummaryEditor.initialize();
});
/** /**
* A summary editor. * A description editor.
* *
*/ */
class SummaryEditor { class DescriptionEditor {
/** /**
* The summary editor form * The line item editor
* @type {JournalEntryLineItemEditor}
*/
#lineItemEditor;
/**
* The description editor form
* @type {HTMLFormElement} * @type {HTMLFormElement}
*/ */
#form; #form;
@ -46,37 +47,43 @@ class SummaryEditor {
prefix; prefix;
/** /**
* The modal of the summary editor * The modal of the description editor
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#modal; #modal;
/** /**
* The entry type, either "debit" or "credit" * Either "debit" or "credit"
* @type {string} * @type {string}
*/ */
#entryType; debitCredit;
/** /**
* The current tab. * The current tab
* @type {TabPlane} * @type {TabPlane}
*/ */
currentTab; currentTab;
/** /**
* The summary input. * The description input
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
summary; description;
/** /**
* The number input. * The button to the original line item 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}}
@ -130,35 +107,32 @@ class SummaryEditor {
tabPlanes = {}; tabPlanes = {};
/** /**
* Constructs a summary editor. * Constructs a description editor.
* *
* @param form {HTMLFormElement} the summary editor form * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @param debitCredit {string} either "debit" or "credit"
*/ */
constructor(form) { constructor(lineItemEditor, debitCredit) {
this.#form = form; this.#lineItemEditor = lineItemEditor;
this.#entryType = form.dataset.entryType; this.debitCredit = debitCredit;
this.prefix = "accounting-summary-editor-" + form.dataset.entryType; this.prefix = "accounting-description-editor-" + debitCredit;
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.description = document.getElementById(this.prefix + "-description");
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;
} }
this.currentTab = this.tabPlanes.general; this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts(); this.#initializeSuggestedAccounts();
this.summary.onchange = () => this.#onSummaryChange(); this.description.onchange = () => this.#onDescriptionChange();
this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen();
this.#form.onsubmit = () => { this.#form.onsubmit = () => {
if (this.currentTab.validate()) { if (this.currentTab.validate()) {
this.#submit(); this.#submit();
@ -168,11 +142,11 @@ class SummaryEditor {
} }
/** /**
* The callback when the summary input is changed. * The callback when the description input is changed.
* *
*/ */
#onSummaryChange() { #onDescriptionChange() {
this.summary.value = this.summary.value.trim(); this.description.value = this.description.value.trim();
for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
if (tabPlane.populate()) { if (tabPlane.populate()) {
break; break;
@ -235,43 +209,34 @@ class SummaryEditor {
} }
/** /**
* Submits the summary. * Submits the description.
* *
*/ */
#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.#lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else {
this.#lineItemEditor.saveDescription(this.description.value);
}
} }
/** /**
* The callback when the summary editor is shown. * The callback when the description editor is shown.
* *
*/ */
#onOpen() { onOpen() {
this.#reset(); this.#reset();
this.summary.value = this.#formSummary.dataset.value; this.description.value = this.#lineItemEditor.description === null? "": this.#lineItemEditor.description;
this.#onSummaryChange(); this.#onDescriptionChange();
} }
/** /**
* Resets the summary editor. * Resets the description editor.
* *
*/ */
#reset() { #reset() {
this.summary.value = ""; this.description.value = "";
for (const tabPlane of Object.values(this.tabPlanes)) { for (const tabPlane of Object.values(this.tabPlanes)) {
tabPlane.reset(); tabPlane.reset();
} }
@ -279,33 +244,18 @@ class SummaryEditor {
} }
/** /**
* The summary editors. * Returns the description editor instances.
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
*/
static #editors = {}
/**
* Initializes the summary editors.
* *
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
* @return {{debit: DescriptionEditor, credit: DescriptionEditor}}
*/ */
static initialize() { static getInstances(lineItemEditor) {
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor")); const editors = {}
const entryForm = document.getElementById("accounting-entry-form"); const forms = Array.from(document.getElementsByClassName("accounting-description-editor"));
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.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit);
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();
} }
} }
@ -318,8 +268,8 @@ class SummaryEditor {
class TabPlane { class TabPlane {
/** /**
* The parent summary editor * The parent description editor
* @type {SummaryEditor} * @type {DescriptionEditor}
*/ */
editor; editor;
@ -344,7 +294,7 @@ class TabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
* @param editor {SummaryEditor} the parent summary editor * @param editor {DescriptionEditor} the parent description editor
*/ */
constructor(editor) { constructor(editor) {
this.editor = editor; this.editor = editor;
@ -370,9 +320,9 @@ class TabPlane {
reset() { throw new Error("Method not implemented."); } reset() { throw new Error("Method not implemented."); }
/** /**
* Populates the tab plane with the summary input. * Populates the tab plane with the description input.
* *
* @return {boolean} true if the summary input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @abstract * @abstract
*/ */
populate() { throw new Error("Method not implemented."); } populate() { throw new Error("Method not implemented."); }
@ -433,7 +383,7 @@ class TagTabPlane extends TabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
* @param editor {SummaryEditor} the parent summary editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
@ -445,7 +395,7 @@ class TagTabPlane extends TabPlane {
this.initializeTagButtons(); this.initializeTagButtons();
this.tag.onchange = () => { this.tag.onchange = () => {
this.onTagChange(); this.onTagChange();
this.updateSummary(); this.updateDescription();
}; };
} }
@ -474,11 +424,11 @@ class TagTabPlane extends TabPlane {
} }
/** /**
* Updates the summary according to the input in the tab plane. * Updates the description according to the input in the tab plane.
* *
* @abstract * @abstract
*/ */
updateSummary() { throw new Error("Method not implemented."); } updateDescription() { throw new Error("Method not implemented."); }
/** /**
* Switches to the tab plane. * Switches to the tab plane.
@ -511,7 +461,7 @@ class TagTabPlane extends TabPlane {
tagButton.classList.add("btn-primary"); tagButton.classList.add("btn-primary");
this.tag.value = tagButton.dataset.value; this.tag.value = tagButton.dataset.value;
this.editor.filterSuggestedAccounts(tagButton); this.editor.filterSuggestedAccounts(tagButton);
this.updateSummary(); this.updateDescription();
}; };
} }
} }
@ -582,28 +532,28 @@ class GeneralTagTab extends TagTabPlane {
}; };
/** /**
* Updates the summary according to the input in the tab plane. * Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
updateSummary() { updateDescription() {
const pos = this.editor.summary.value.indexOf("—"); const pos = this.editor.description.value.indexOf("—");
const prefix = this.tag.value === ""? "": this.tag.value + "—"; const prefix = this.tag.value === ""? "": this.tag.value + "—";
if (pos === -1) { if (pos === -1) {
this.editor.summary.value = prefix + this.editor.summary.value; this.editor.description.value = prefix + this.editor.description.value;
} else { } else {
this.editor.summary.value = prefix + this.editor.summary.value.substring(pos + 1); this.editor.description.value = prefix + this.editor.description.value.substring(pos + 1);
} }
} }
/** /**
* Populates the tab plane with the summary input. * Populates the tab plane with the description input.
* *
* @return {boolean} true if the summary input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.summary.value.match(/^([^—]+)—/); const found = this.editor.description.value.match(/^([^—]+)—/);
if (found === null) { if (found === null) {
return false; return false;
} }
@ -672,7 +622,7 @@ class GeneralTripTab extends TagTabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
* @param editor {SummaryEditor} the parent summary editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
@ -685,7 +635,7 @@ class GeneralTripTab extends TagTabPlane {
this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction")); this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction"));
this.#from.onchange = () => { this.#from.onchange = () => {
this.#from.value = this.#from.value.trim(); this.#from.value = this.#from.value.trim();
this.updateSummary(); this.updateDescription();
this.validateFrom(); this.validateFrom();
}; };
for (const directionButton of this.#directionButtons) { for (const directionButton of this.#directionButtons) {
@ -696,12 +646,12 @@ class GeneralTripTab extends TagTabPlane {
} }
directionButton.classList.remove("btn-outline-primary"); directionButton.classList.remove("btn-outline-primary");
directionButton.classList.add("btn-primary"); directionButton.classList.add("btn-primary");
this.updateSummary(); this.updateDescription();
}; };
} }
this.#to.onchange = () => { this.#to.onchange = () => {
this.#to.value = this.#to.value.trim(); this.#to.value = this.#to.value.trim();
this.updateSummary(); this.updateDescription();
this.validateTo(); this.validateTo();
}; };
} }
@ -717,11 +667,11 @@ class GeneralTripTab extends TagTabPlane {
}; };
/** /**
* Updates the summary according to the input in the tab plane. * Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
updateSummary() { updateDescription() {
let direction; let direction;
for (const directionButton of this.#directionButtons) { for (const directionButton of this.#directionButtons) {
if (directionButton.classList.contains("btn-primary")) { if (directionButton.classList.contains("btn-primary")) {
@ -729,7 +679,7 @@ class GeneralTripTab extends TagTabPlane {
break; break;
} }
} }
this.editor.summary.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value; this.editor.description.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
} }
/** /**
@ -757,13 +707,13 @@ class GeneralTripTab extends TagTabPlane {
} }
/** /**
* Populates the tab plane with the summary input. * Populates the tab plane with the description input.
* *
* @return {boolean} true if the summary input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) { if (found === null) {
return false; return false;
} }
@ -884,7 +834,7 @@ class BusTripTab extends TagTabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
* @param editor {SummaryEditor} the parent summary editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
@ -897,17 +847,17 @@ class BusTripTab extends TagTabPlane {
this.#toError = document.getElementById(this.prefix + "-to-error") this.#toError = document.getElementById(this.prefix + "-to-error")
this.#route.onchange = () => { this.#route.onchange = () => {
this.#route.value = this.#route.value.trim(); this.#route.value = this.#route.value.trim();
this.updateSummary(); this.updateDescription();
this.validateRoute(); this.validateRoute();
}; };
this.#from.onchange = () => { this.#from.onchange = () => {
this.#from.value = this.#from.value.trim(); this.#from.value = this.#from.value.trim();
this.updateSummary(); this.updateDescription();
this.validateFrom(); this.validateFrom();
}; };
this.#to.onchange = () => { this.#to.onchange = () => {
this.#to.value = this.#to.value.trim(); this.#to.value = this.#to.value.trim();
this.updateSummary(); this.updateDescription();
this.validateTo(); this.validateTo();
}; };
} }
@ -923,12 +873,12 @@ class BusTripTab extends TagTabPlane {
}; };
/** /**
* Updates the summary according to the input in the tab plane. * Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
updateSummary() { updateDescription() {
this.editor.summary.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value; this.editor.description.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
} }
/** /**
@ -950,13 +900,13 @@ class BusTripTab extends TagTabPlane {
} }
/** /**
* Populates the tab plane with the summary input. * Populates the tab plane with the description input.
* *
* @return {boolean} true if the summary input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found === null) { if (found === null) {
return false; return false;
} }
@ -1051,7 +1001,7 @@ class RegularPaymentTab extends TabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
* @param editor {SummaryEditor} the parent summary editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
@ -1083,9 +1033,9 @@ class RegularPaymentTab extends TabPlane {
} }
/** /**
* Populates the tab plane with the summary input. * Populates the tab plane with the description input.
* *
* @return {boolean} true if the summary input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
*/ */
populate() { populate() {
@ -1113,15 +1063,15 @@ class AnnotationTab extends TabPlane {
/** /**
* Constructs a tab plane. * Constructs a tab plane.
* *
* @param editor {SummaryEditor} the parent summary editor * @param editor {DescriptionEditor} the parent description editor
* @override * @override
*/ */
constructor(editor) { constructor(editor) {
super(editor); super(editor);
this.editor.number.onchange = () => this.updateSummary(); this.editor.number.onchange = () => this.updateDescription();
this.editor.note.onchange = () => { this.editor.note.onchange = () => {
this.editor.note.value = this.editor.note.value.trim(); this.editor.note.value = this.editor.note.value.trim();
this.updateSummary(); this.updateDescription();
}; };
} }
@ -1136,20 +1086,20 @@ class AnnotationTab extends TabPlane {
}; };
/** /**
* Updates the summary according to the input in the tab plane. * Updates the description according to the input in the tab plane.
* *
* @override * @override
*/ */
updateSummary() { updateDescription() {
const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/); const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
if (found !== null) { if (found !== null) {
this.editor.summary.value = found[1]; this.editor.description.value = found[1];
} }
if (parseInt(this.editor.number.value) > 1) { if (parseInt(this.editor.number.value) > 1) {
this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value; this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
} }
if (this.editor.note.value !== "") { if (this.editor.note.value !== "") {
this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")"; this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
} }
} }
@ -1164,25 +1114,25 @@ class AnnotationTab extends TabPlane {
} }
/** /**
* Populates the tab plane with the summary input. * Populates the tab plane with the description input.
* *
* @return {boolean} true if the summary input matches this tab, or false otherwise * @return {boolean} true if the description input matches this tab, or false otherwise
* @override * @override
*/ */
populate() { populate() {
const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/); const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
this.editor.summary.value = found[1]; this.editor.description.value = found[1];
if (found[2] === undefined || parseInt(found[2]) === 1) { if (found[2] === undefined || parseInt(found[2]) === 1) {
this.editor.number.value = ""; this.editor.number.value = "";
} else { } else {
this.editor.number.value = found[2]; this.editor.number.value = found[2];
this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value; this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
} }
if (found[3] === undefined) { if (found[3] === undefined) {
this.editor.note.value = ""; this.editor.note.value = "";
} else { } else {
this.editor.note.value = found[3]; this.editor.note.value = found[3];
this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")"; this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
} }
return true; return true;
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,596 @@
/* The Mia! Accounting Flask Project
* journal-entry-line-item-editor.js: The JavaScript for the journal entry line item 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 line item editor.
*
*/
class JournalEntryLineItemEditor {
/**
* The journal entry form
* @type {JournalEntryForm}
*/
form;
/**
* The journal entry line item editor
* @type {HTMLFormElement}
*/
#element;
/**
* The bootstrap modal
* @type {HTMLDivElement}
*/
#modal;
/**
* Either "debit" or "credit"
* @type {string}
*/
debitCredit;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-line-item-editor"
/**
* The container of the original line item
* @type {HTMLDivElement}
*/
#originalLineItemContainer;
/**
* The control of the original line item
* @type {HTMLDivElement}
*/
#originalLineItemControl;
/**
* The original line item
* @type {HTMLDivElement}
*/
#originalLineItemText;
/**
* The error message of the original line item
* @type {HTMLDivElement}
*/
#originalLineItemError;
/**
* The delete button of the original line item
* @type {HTMLButtonElement}
*/
#originalLineItemDelete;
/**
* The control of the description
* @type {HTMLDivElement}
*/
#descriptionControl;
/**
* The description
* @type {HTMLDivElement}
*/
#descriptionText;
/**
* The error message of the description
* @type {HTMLDivElement}
*/
#descriptionError;
/**
* The control of the account
* @type {HTMLDivElement}
*/
#accountControl;
/**
* The account
* @type {HTMLDivElement}
*/
#accountText;
/**
* The error message of the account
* @type {HTMLDivElement}
*/
#accountError;
/**
* The amount
* @type {HTMLInputElement}
*/
#amountInput;
/**
* The error message of the amount
* @type {HTMLDivElement}
*/
#amountError;
/**
* The journal entry line item to edit
* @type {LineItemSubForm|null}
*/
lineItem;
/**
* The debit or credit sub-form
* @type {DebitCreditSubForm}
*/
#debitCreditSubForm;
/**
* Whether the journal entry line item needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original line item
* @type {string|null}
*/
originalLineItemId = null;
/**
* The date of the original line item
* @type {string|null}
*/
originalLineItemDate = null;
/**
* The text of the original line item
* @type {string|null}
*/
originalLineItemText = null;
/**
* The account code
* @type {string|null}
*/
accountCode = null;
/**
* The account text
* @type {string|null}
*/
accountText = null;
/**
* The description
* @type {string|null}
*/
description = null;
/**
* The amount
* @type {string}
*/
amount = "";
/**
* The description editors
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
*/
#descriptionEditors;
/**
* The account selectors
* @type {{debit: AccountSelector, credit: AccountSelector}}
*/
#accountSelectors;
/**
* The original line item selector
* @type {OriginalLineItemSelector}
*/
originalLineItemSelector;
/**
* Constructs a new journal entry line item editor.
*
* @param form {JournalEntryForm} the journal entry form
*/
constructor(form) {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
this.#descriptionControl = document.getElementById(this.#prefix + "-description-control");
this.#descriptionText = document.getElementById(this.#prefix + "-description");
this.#descriptionError = document.getElementById(this.#prefix + "-description-error");
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
this.#accountText = document.getElementById(this.#prefix + "-account");
this.#accountError = document.getElementById(this.#prefix + "-account-error")
this.#amountInput = document.getElementById(this.#prefix + "-amount");
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#descriptionEditors = DescriptionEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
this.#amountInput.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.lineItem === null) {
this.lineItem = this.#debitCreditSubForm.addLineItem();
}
this.amount = this.#amountInput.value;
this.lineItem.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
return false;
};
}
/**
* Saves the original line item from the original line item selector.
*
* @param originalLineItem {OriginalLineItem} the original line item
*/
saveOriginalLineItem(originalLineItem) {
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty");
this.originalLineItemId = originalLineItem.id;
this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false);
if (originalLineItem.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty");
} else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = originalLineItem.description === ""? null: originalLineItem.description;
this.#descriptionText.innerText = originalLineItem.description;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalLineItem.accountCode;
this.accountText = originalLineItem.accountText;
this.#accountText.innerText = originalLineItem.accountText;
this.#amountInput.value = String(originalLineItem.netBalance);
this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0";
this.#validate();
}
/**
* Clears the original line item.
*
*/
clearOriginalLineItem() {
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.originalLineItemId = null;
this.originalLineItemDate = null;
this.originalLineItemText = null;
this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#accountText.innerText = "";
this.#amountInput.max = "";
}
/**
* Returns the currency code.
*
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#debitCreditSubForm.currency.getCurrencyCode();
}
/**
* Saves the description from the description editor.
*
* @param description {string} the description
*/
saveDescription(description) {
if (description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty");
} else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = description === ""? null: description;
this.#descriptionText.innerText = description;
this.#validateDescription();
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
}
/**
* Saves the description with the suggested account from the description editor.
*
* @param description {string} the description
* @param accountCode {string} the account code
* @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
*/
saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = accountCode;
this.accountText = accountText;
this.#accountText.innerText = accountText;
this.#validateAccount();
this.saveDescription(description)
}
/**
* Clears the account.
*
*/
clearAccount() {
this.isNeedOffset = false;
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
this.accountText = null;
this.#accountText.innerText = "";
this.#validateAccount();
}
/**
* Sets the account.
*
* @param code {string} the account code
* @param text {string} the account text
* @param isNeedOffset {boolean} true if the line items 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.#accountText.innerText = text;
this.#validateAccount();
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalLineItem() && isValid;
isValid = this.#validateDescription() && isValid;
isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid
return isValid;
}
/**
* Validates the original line item.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateOriginalLineItem() {
this.#originalLineItemControl.classList.remove("is-invalid");
this.#originalLineItemError.innerText = "";
return true;
}
/**
* Validates the description.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
#validateDescription() {
this.#descriptionText.classList.remove("is-invalid");
this.#descriptionError.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.#amountInput.value = this.#amountInput.value.trim();
this.#amountInput.classList.remove("is-invalid");
if (this.#amountInput.value === "") {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in the amount.");
return false;
}
const amount =new Decimal(this.#amountInput.value);
if (amount.lessThanOrEqualTo(0)) {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("Please fill in a positive amount.");
return false;
}
if (this.#amountInput.max !== "") {
if (amount.greaterThan(new Decimal(this.#amountInput.max))) {
this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original line item.", {balance: new Decimal(this.#amountInput.max)});
return false;
}
}
if (this.#amountInput.min !== "") {
const min = new Decimal(this.#amountInput.min);
if (amount.lessThan(min)) {
this.#amountInput.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.#amountInput.classList.remove("is-invalid");
this.#amountError.innerText = "";
return true;
}
/**
* The callback when adding a new journal entry line item.
*
* @param debitCredit {DebitCreditSubForm} the debit or credit sub-form
*/
onAddNew(debitCredit) {
this.lineItem = null;
this.#debitCreditSubForm = debitCredit;
this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = false;
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.#originalLineItemControl.classList.remove("is-invalid");
this.originalLineItemId = null;
this.originalLineItemDate = null;
this.originalLineItemText = null;
this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true);
this.#descriptionControl.classList.remove("accounting-not-empty");
this.#descriptionControl.classList.remove("is-invalid");
this.description = null;
this.#descriptionText.innerText = ""
this.#descriptionError.innerText = ""
this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid");
this.accountCode = null;
this.accountText = null;
this.#accountText.innerText = "";
this.#accountError.innerText = "";
this.#amountInput.value = "";
this.#amountInput.max = "";
this.#amountInput.min = "0";
this.#amountInput.classList.remove("is-invalid");
this.#amountError.innerText = "";
}
/**
* The callback when editing a journal entry line item.
*
* @param lineItem {LineItemSubForm} the journal entry line item sub-form
*/
onEdit(lineItem) {
this.lineItem = lineItem;
this.#debitCreditSubForm = lineItem.debitCreditSubForm;
this.debitCredit = this.#debitCreditSubForm.debitCredit;
this.isNeedOffset = lineItem.isNeedOffset();
this.originalLineItemId = lineItem.getOriginalLineItemId();
this.originalLineItemDate = lineItem.getOriginalLineItemDate();
this.originalLineItemText = lineItem.getOriginalLineItemText();
this.#originalLineItemText.innerText = this.originalLineItemText;
if (this.originalLineItemId === null) {
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
} else {
this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty");
}
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.description = lineItem.getDescription();
if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty");
} else {
this.#descriptionControl.classList.add("accounting-not-empty");
}
this.#descriptionText.innerText = this.description === null? "": this.description;
if (lineItem.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = lineItem.getAccountCode();
this.accountText = lineItem.getAccountText();
this.#accountText.innerText = this.accountText;
this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
const maxAmount = this.#getMaxAmount();
this.#amountInput.max = maxAmount === null? "": maxAmount;
this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
this.#validate();
}
/**
* Finds out the max amount.
*
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalLineItemId === null) {
return null;
}
return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId);
}
/**
* Sets the enable status of the description and account.
*
* @param isEnabled {boolean} true to enable, or false otherwise
*/
#setEnableDescriptionAccount(isEnabled) {
if (isEnabled) {
this.#descriptionControl.dataset.bsToggle = "modal";
this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal";
this.#descriptionControl.classList.remove("accounting-disabled");
this.#descriptionControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal";
this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable");
} else {
this.#descriptionControl.dataset.bsToggle = "";
this.#descriptionControl.dataset.bsTarget = "";
this.#descriptionControl.classList.add("accounting-disabled");
this.#descriptionControl.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

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* transaction-order.js: The JavaScript for the transaction order * journal-entry-order.js: The JavaScript for the journal entry order
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.

View File

@ -0,0 +1,409 @@
/* The Mia! Accounting Flask Project
* original-line-item-selector.js: The JavaScript for the original line item 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 line item selector.
*
*/
class OriginalLineItemSelector {
/**
* The line item editor
* @type {JournalEntryLineItemEditor}
*/
lineItemEditor;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-line-item-selector";
/**
* 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 {OriginalLineItem[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalLineItem>}
*/
#optionById;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* Either "credit" or "debit"
*/
#debitCredit;
/**
* Constructs an original line item selector.
*
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
*/
constructor(lineItemEditor) {
this.lineItemEditor = lineItemEditor;
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 OriginalLineItem(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 line item.
*
* @param currentLineItem {LineItemSubForm} the line item sub-form that is currently editing
* @param form {JournalEntryForm} the journal entry form
* @param originalLineItemId {string} the ID of the original line item
* @return {Decimal} the net balance of the original line item
*/
getNetBalance(currentLineItem, form, originalLineItemId) {
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
let otherOffset = new Decimal(0);
for (const otherLineItem of otherLineItems) {
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
const amount = otherLineItem.getAmount();
if (amount !== null) {
otherOffset = otherOffset.plus(amount);
}
}
}
return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset);
}
/**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing line item
*
*/
#updateNetBalances() {
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
const otherOffsets = {}
for (const otherLineItem of otherLineItems) {
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
const amount = otherLineItem.getAmount();
if (otherOriginalLineItemId === null || amount === null) {
continue;
}
if (!(otherOriginalLineItemId in otherOffsets)) {
otherOffsets[otherOriginalLineItemId] = new Decimal("0");
}
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].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.#debitCredit, 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 line item selector is shown.
*
*/
onOpen() {
this.#currencyCode = this.lineItemEditor.getCurrencyCode();
this.#debitCredit = this.lineItemEditor.debitCredit;
for (const option of this.#options) {
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
}
this.#query.value = "";
this.#updateNetBalances();
this.#filterOptions();
}
}
/**
* An original line item.
*
*/
class OriginalLineItem {
/**
* The original line item selector
* @type {OriginalLineItemSelector}
*/
#selector;
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The ID
* @type {string}
*/
id;
/**
* The date
* @type {string}
*/
date;
/**
* Either "debit" or "credit"
* @type {string}
*/
#debitCredit;
/**
* The currency code
* @type {string}
*/
#currencyCode;
/**
* The account code
* @type {string}
*/
accountCode;
/**
* The account text
* @type {string}
*/
accountText;
/**
* The description
* @type {string}
*/
description;
/**
* 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 line item
* @type {string}
*/
text;
/**
* The values to query against
* @type {string[][]}
*/
#queryValues;
/**
* Constructs an original line item.
*
* @param selector {OriginalLineItemSelector} the original line item 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.#debitCredit = element.dataset.debitCredit;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.description = element.dataset.description;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(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 debitCredit {string} either "debit" or "credit"
* @param currencyCode {string} the currency code
* @param query {string|null} the query term
*/
isMatched(debitCredit, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.lineItemEditor.form.getDate()
&& this.#isDebitCreditMatches(debitCredit)
&& this.#currencyCode === currencyCode
&& this.#isQueryMatches(query);
}
/**
* Returns whether the original line item matches the debit or credit.
*
* @param debitCredit {string} either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise
*/
#isDebitCreditMatches(debitCredit) {
return (debitCredit === "debit" && this.#debitCredit === "credit")
|| (debitCredit === "credit" && this.#debitCredit === "debit");
}
/**
* Returns whether the original line item 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

@ -175,7 +175,7 @@ class MonthTab extends TabPlane {
let start = monthChooser.dataset.start; let start = monthChooser.dataset.start;
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, { this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
restrictions: { restrictions: {
minDate: start, minDate: new Date(start),
}, },
display: { display: {
inline: true, inline: true,
@ -184,7 +184,7 @@ class MonthTab extends TabPlane {
clock: false, clock: false,
}, },
}, },
defaultDate: monthChooser.dataset.default, defaultDate: new Date(monthChooser.dataset.default),
}); });
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => { monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
const date = e.detail.date; const date = e.detail.date;

View File

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

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The template globals for the transaction management. """The template globals.
""" """
from flask import current_app from flask import current_app

View File

@ -66,7 +66,7 @@ First written: 2023/1/31
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1> <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -83,11 +83,11 @@ First written: 2023/1/31
{% endif %} {% endif %}
<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|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_("Needs Offset") }}</span>
</div> </div>
{% endif %} {% endif %}
<div class="small text-secondary fst-italic"> <div class="small text-secondary fst-italic">

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,10 +62,10 @@ 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 line items 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,8 +58,8 @@ First written: 2023/1/30
{% for item in list %} {% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
{{ item }} {{ item }}
{% if item.is_offset_needed %} {% if item.is_need_offset %}
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span> <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
{% endif %} {% endif %}
</a> </a>
{% endfor %} {% endfor %}

View File

@ -33,7 +33,7 @@ First written: 2023/2/1
</div> </div>
<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|title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %} {% if obj.accounts %}
<div> <div>

View File

@ -62,7 +62,7 @@ First written: 2023/2/6
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1> <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Currency") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -79,7 +79,7 @@ First written: 2023/2/6
{% endif %} {% endif %}
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.name }}</div> <div class="accounting-card-title">{{ obj.name|title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
<div class="small text-secondary fst-italic"> <div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div> <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>

View File

@ -28,25 +28,25 @@ First written: 2023/1/26
</span> </span>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li> <li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
<i class="fa-solid fa-book"></i> <i class="fa-solid fa-book"></i>
{{ A_("Reports") }} {{ A_("Reports") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
{{ A_("Accounts") }} {{ A_("Accounts") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>
{{ A_("Base Accounts") }} {{ A_("Base Accounts") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}"> <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-money-bill-wave"></i> <i class="fa-solid fa-money-bill-wave"></i>
{{ A_("Currencies") }} {{ A_("Currencies") }}
</a> </a>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
create.html: The cash expense transaction creation form create.html: The cash disbursement journal entry creation form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash expense transaction creation 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
#} #}
{% extends "accounting/transaction/expense/include/form.html" %} {% extends "accounting/journal-entry/disbursement/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail.html: The account detail detail.html: The cash disbursement journal entry detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,36 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26 First written: 2023/2/26
#} #}
{% extends "accounting/transaction/include/detail.html" %} {% extends "accounting/journal-entry/include/detail.html" %}
{% block to_transfer %} {% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }} {{ A_("To Transfer") }}
</a> </a>
{% endblock %} {% endblock %}
{% block transaction_currencies %} {% block journal_entry_currencies %}
{% for currency in obj.currencies %} {% for currency in obj.currencies %}
<div class="mb-3"> <div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<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-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %} {% with line_items = currency.debit %}
<li class="list-group-item accounting-transaction-entry"> {% include "accounting/journal-entry/include/detail-line-items.html" %}
<div class="d-flex justify-content-between"> {% endwith %}
<div> <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<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">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
edit.html: The cash income transaction edit form edit.html: The cash disbursement journal entry edit form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The cash income transaction edit 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
#} #}
{% extends "accounting/transaction/income/include/form.html" %} {% extends "accounting/journal-entry/disbursement/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} {% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash expense transaction form form-currency.html: The currency sub-form in the cash disbursement journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -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>
@ -43,23 +44,14 @@ First written: 2023/2/25
<div class="mb-3"> <div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
{% for entry_form in debit_forms %} {% for line_item_form in debit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "debit", debit_credit = "debit",
entry_index = loop.index, line_item_index = loop.index,
entry_id = entry_form.eid.data, only_one_line_item_form = debit_forms|length == 1,
only_one_entry_form = debit_forms|length == 1, form = line_item_form.form %}
account_code_data = entry_form.account_code.data|accounting_default, {% include "accounting/journal-entry/include/form-line-item.html" %}
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -70,7 +62,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-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The cash expense transaction form form.html: The cash disbursement journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The cash expense 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
#} #}
{% extends "accounting/transaction/include/form.html" %} {% extends "accounting/journal-entry/include/form.html" %}
{% block currency_sub_forms %} {% block currency_sub_forms %}
{% if form.currencies %} {% if form.currencies %}
@ -29,10 +29,11 @@ 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 %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %} {% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -40,17 +41,17 @@ First written: 2023/2/25
only_one_currency_form = True, only_one_currency_form = True,
currency_code_data = accounting_default_currency_code(), currency_code_data = accounting_default_currency_code(),
debit_total = "-" %} debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %} {% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block form_modals %}
{% with summary_editor = form.summary_editor.debit %} {% with description_editor = form.description_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "debit", {% with debit_credit = "debit",
account_options = form.debit_account_options %} account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
account-selector-modal.html: The modal for the account selector
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-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-line-item-editor-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ debit_credit }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,193 @@
{#
The Mia! Accounting Flask Project
description-editor-modal.html: The modal of the description 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/28
#}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-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-description-editor-{{ description_editor.debit_credit }}-modal-label">
<label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label>
</h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between mb-3">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-description" class="form-control" type="text" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
{{ A_("Offset...") }}
</button>
</div>
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
{{ A_("General") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Travel") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Bus") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Regular") }}
</span>
</li>
<li class="nav-item">
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Annotation") }}
</span>
</li>
</ul>
{# A general description with a tag #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in description_editor.general.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
</div>
{# A general trip with the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
<div class="form-floating mb-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% for tag in description_editor.travel.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div>
</div>
<div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
<div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% for tag in description_editor.bus.tags %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }}
</button>
{% endfor %}
</div>
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# A regular income or payment #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab">
{# TODO: To be done #}
</div>
{# The annotation #}
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
<div class="form-floating">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mt-2">
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% for account in description_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }}
</button>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,75 @@
{#
The Mia! Accounting Flask Project
detail-line-items-item: The line items in the journal entry 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 line_item in line_items %}
<li class="list-group-item accounting-journal-entry-line-item">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ line_item.account }}</div>
{% if line_item.description is not none %}
<div>{{ line_item.description }}</div>
{% endif %}
{% if line_item.original_line_item %}
<div class="fst-italic small accounting-original-line-item">
<a href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.original_line_item.journal_entry)|accounting_append_next }}">
{{ A_("Offset %(item)s", item=line_item.original_line_item) }}
</a>
</div>
{% endif %}
{% if line_item.is_need_offset %}
<div class="fst-italic small accounting-offset-line-items">
{% if line_item.offsets %}
<div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in line_item.offsets %}
<li>
<a href="{{ url_for("accounting.journal-entry.detail", journal_entry=offset.journal_entry)|accounting_append_next }}">
{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% if line_item.balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ line_item.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>{{ line_item.amount|accounting_format_amount }}</div>
</div>
</li>
{% endfor %}
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -31,34 +31,41 @@ First written: 2023/2/26
{{ A_("Back") }} {{ A_("Back") }}
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}"> <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
{{ A_("Settings") }} {{ A_("Settings") }}
</a> </a>
{% endif %} {% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }} {{ A_("Order") }}
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
{% block to_transfer %}{% endblock %} {% block to_transfer %}{% endblock %}
{% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
{{ A_("Delete") }} {{ A_("Delete") }}
</button> </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>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square"></i>
</a> </a>
</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.journal-entry.delete", journal_entry=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 %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">
@ -67,11 +74,11 @@ First written: 2023/2/26
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1> <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Journal Entry") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }} {{ A_("Do you really want to delete this journal entry?") }}
</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>
@ -92,13 +99,13 @@ First written: 2023/2/26
{{ obj.date|accounting_format_date }} {{ obj.date|accounting_format_date }}
</div> </div>
{% block transaction_currencies %}{% endblock %} {% block journal_entry_currencies %}{% endblock %}
{% if obj.note %} {% if obj.note %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<i class="far fa-comment-dots"></i> <i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }} {{ obj.note|accounting_journal_entry_text2html|safe }}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -0,0 +1,74 @@
{#
The Mia! Accounting Flask Project
form-line-item.html: The line item sub-form in the journal entry form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}" {% if form.is_need_offset %} data-is-need-offset="true" {% endif %}>
{% if form.id.data %}
<input type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-id" value="{{ form.id.data }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
<div class="accounting-line-item-content">
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text" class="small">{{ form.account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}">
{% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-offsets" class="fst-italic small accounting-offset-line-items {% if not form.is_need_offset %} d-none {% endif %}">
{% if form.offsets %}
<div class="d-flex justify-content-between {% if not form.offsets %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in form.offsets %}
<li>{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if form.net_balance == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ form.net_balance|accounting_format_amount }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ form.amount.data|accounting_format_amount }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The transfer transaction form form.html: The base journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -23,9 +23,11 @@ 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/journal-entry-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-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/summary-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -37,14 +39,14 @@ First written: 2023/2/26
</a> </a>
</div> </div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}"> <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-line-item-template="{{ line_item_template }}">
{{ form.csrf_token }} {{ form.csrf_token }}
{% if request.args.next %} {% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">
{% 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/journal-entry/include/journal-entry-line-item-editor-modal.html" %}
{% block form_modals %}{% endblock %} {% block form_modals %}{% endblock %}
{% include "accounting/journal-entry/include/original-line-item-selector-modal.html" %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,76 @@
{#
The Mia! Accounting Flask Project
journal-entry-line-item-editor-modal: The modal of the journal entry line item 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-line-item-editor">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-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-line-item-editor-modal-label">{{ A_("Line Item 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-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3">
<div class="accounting-line-item-editor-original-line-item-content">
<div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
<label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label>
<div id="accounting-line-item-editor-original-line-item"></div>
</div>
<div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-line-item-editor-original-line-item-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-line-item-editor-description-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-description">{{ A_("Description") }}</label>
<div id="accounting-line-item-editor-description"></div>
</div>
<div id="accounting-line-item-editor-description-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-line-item-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-line-item-editor-account">{{ A_("Account") }}</label>
<div id="accounting-line-item-editor-account"></div>
</div>
<div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-line-item-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-line-item-selector-modal.html: The modal of the original line item 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-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-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-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-original-line-item-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
{% for line_item in form.original_line_item_options %}
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.description|accounting_default }}</div>
<div>
<span class="badge bg-primary rounded-pill">
<span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span>
/ {{ line_item.amount|accounting_format_amount }}
</span>
</div>
</li>
{% endfor %}
</ul>
<p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
order.html: The order of the transactions in a same day order.html: The order of the journal entries in a same day
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -23,10 +23,10 @@ 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-order.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block content %} {% block content %}
@ -38,7 +38,7 @@ First written: 2023/2/26
</div> </div>
{% if list|length > 1 and accounting_can_edit() %} {% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post"> <form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" 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 %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
create.html: The transfer transaction creation form create.html: The cash receipt journal entry creation form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The transfer transaction creation 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
#} #}
{% extends "accounting/transaction/transfer/include/form.html" %} {% extends "accounting/journal-entry/receipt/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail.html: The account detail detail.html: The cash receipt journal entry detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,36 +19,26 @@ detail.html: The account detail
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26 First written: 2023/2/26
#} #}
{% extends "accounting/transaction/include/detail.html" %} {% extends "accounting/journal-entry/include/detail.html" %}
{% block to_transfer %} {% block to_transfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
{{ A_("To Transfer") }} {{ A_("To Transfer") }}
</a> </a>
{% endblock %} {% endblock %}
{% block transaction_currencies %} {% block journal_entry_currencies %}
{% for currency in obj.currencies %} {% for currency in obj.currencies %}
<div class="mb-3"> <div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<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-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
{% for entry in currency.credit %} {% with line_items = currency.credit %}
<li class="list-group-item accounting-transaction-entry"> {% include "accounting/journal-entry/include/detail-line-items.html" %}
<div class="d-flex justify-content-between"> {% endwith %}
<div> <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<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">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
edit.html: The cash expense transaction edit form edit.html: The cash receipt journal entry edit form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The cash expense transaction edit 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
#} #}
{% extends "accounting/transaction/expense/include/form.html" %} {% extends "accounting/journal-entry/receipt/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} {% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash income transaction form form-currency.html: The currency sub-form in the cash receipt journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -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>
@ -43,23 +44,14 @@ First written: 2023/2/25
<div class="mb-3"> <div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
{% for entry_form in credit_forms %} {% for line_item_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "credit", debit_credit = "credit",
entry_index = loop.index, line_item_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_line_item_form = credit_forms|length == 1,
entry_id = entry_form.eid.data, form = line_item_form.form %}
account_code_data = entry_form.account_code.data|accounting_default, {% include "accounting/journal-entry/include/form-line-item.html" %}
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -70,7 +62,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-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The cash income transaction form form.html: The cash receipt journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: The cash income 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
#} #}
{% extends "accounting/transaction/include/form.html" %} {% extends "accounting/journal-entry/include/form.html" %}
{% block currency_sub_forms %} {% block currency_sub_forms %}
{% if form.currencies %} {% if form.currencies %}
@ -29,10 +29,11 @@ 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 %}
{% include "accounting/transaction/income/include/form-currency-item.html" %} {% include "accounting/journal-entry/receipt/include/form-currency.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -40,17 +41,17 @@ First written: 2023/2/25
only_one_currency_form = True, only_one_currency_form = True,
currency_code_data = accounting_default_currency_code(), currency_code_data = accounting_default_currency_code(),
credit_total = "-" %} credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %} {% include "accounting/journal-entry/receipt/include/form-currency.html" %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block form_modals %}
{% with summary_editor = form.summary_editor.credit %} {% with description_editor = form.description_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "credit", {% with debit_credit = "credit",
account_options = form.credit_account_options %} account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
create.html: The cash income transaction creation form create.html: The transfer journal entry creation form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ create.html: The cash income transaction creation 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
#} #}
{% extends "accounting/transaction/income/include/form.html" %} {% extends "accounting/journal-entry/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -0,0 +1,64 @@
{#
The Mia! Accounting Flask Project
detail.html: The transfer journal entry detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/26
#}
{% extends "accounting/journal-entry/include/detail.html" %}
{% block journal_entry_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<div class="row">
{# The debit line items #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Debit") }}</li>
{% with line_items = currency.debit %}
{% include "accounting/journal-entry/include/detail-line-items.html" %}
{% endwith %}
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
</div>
{# The credit line items #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Credit") }}</li>
{% with line_items = currency.credit %}
{% include "accounting/journal-entry/include/detail-line-items.html" %}
{% endwith %}
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form edit.html: The transfer journal entry edit form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,10 +19,10 @@ edit.html: The transfer transaction edit 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
#} #}
{% extends "accounting/transaction/transfer/include/form.html" %} {% extends "accounting/journal-entry/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} {% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the transfer transaction form form-currency.html: The currency sub-form in the transfer journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,49 +19,41 @@ 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>
</div> </div>
<div class="row"> <div class="row">
{# The debit entries #} {# The debit line items #}
<div class="col-sm-6 mb-3"> <div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list"> <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
{% for entry_form in debit_forms %} {% for line_item_form in debit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "debit", debit_credit = "debit",
entry_index = loop.index, line_item_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_line_item_form = debit_forms|length == 1,
entry_id = entry_form.eid.data, form = line_item_form.form %}
account_code_data = entry_form.account_code.data|accounting_default, {% include "accounting/journal-entry/include/form-line-item.html" %}
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -72,7 +64,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-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
@ -81,27 +73,18 @@ First written: 2023/2/25
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div> <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div> </div>
{# The credit entries #} {# The credit line items #}
<div class="col-sm-6 mb-3"> <div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
{% for entry_form in credit_forms %} {% for line_item_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_id = entry_form.eid.data, debit_credit = "credit",
entry_type = "credit", line_item_index = loop.index,
entry_index = loop.index, only_one_line_item_form = credit_forms|length == 1,
only_one_entry_form = debit_forms|length == 1, form = line_item_form.form %}
account_code_data = entry_form.account_code.data|accounting_default, {% include "accounting/journal-entry/include/form-line-item.html" %}
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -112,7 +95,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-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
form.html: The transfer transaction form form.html: The transfer journal entry form
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -19,7 +19,7 @@ form.html: 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
#} #}
{% extends "accounting/transaction/include/form.html" %} {% extends "accounting/journal-entry/include/form.html" %}
{% block currency_sub_forms %} {% block currency_sub_forms %}
{% if form.currencies %} {% if form.currencies %}
@ -29,13 +29,14 @@ 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,
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 %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %} {% include "accounting/journal-entry/transfer/include/form-currency.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
{% else %} {% else %}
@ -44,24 +45,24 @@ First written: 2023/2/25
currency_code_data = accounting_default_currency_code(), currency_code_data = accounting_default_currency_code(),
debit_total = "-", debit_total = "-",
credit_total = "-" %} credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %} {% include "accounting/journal-entry/transfer/include/form-currency.html" %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block form_modals %} {% block form_modals %}
{% with summary_editor = form.summary_editor.debit %} {% with description_editor = form.description_editor.debit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with summary_editor = form.summary_editor.credit %} {% with description_editor = form.description_editor.credit %}
{% include "accounting/transaction/include/summary-editor-modal.html" %} {% include "accounting/journal-entry/include/description-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "debit", {% with debit_credit = "debit",
account_options = form.debit_account_options %} account_options = form.debit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "credit", {% with debit_credit = "credit",
account_options = form.credit_account_options %} account_options = form.credit_account_options %}
{% include "accounting/transaction/include/account-selector-modal.html" %} {% include "accounting/journal-entry/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
add-txn-material-fab.html: The material floating action buttons to add a new transaction add-journal-entry-material-fab.html: The material floating action buttons to add a new journal entry
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -22,13 +22,13 @@ First written: 2023/2/25
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab"> <div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group"> <div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}"> <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash expense") }} {{ A_("Cash Disbursement") }}
</a> </a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}"> <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash income") }} {{ A_("Cash Receipt") }}
</a> </a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}"> <a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }} {{ A_("Transfer") }}
</a> </a>
</div> </div>

View File

@ -19,9 +19,9 @@ income-expenses-row-desktop.html: The row in the income and expenses log for the
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div>{{ entry.date|accounting_format_date }}</div> <div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div> <div>{{ line_item.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>

View File

@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5 First written: 2023/3/5
#} #}
<div> <div>
{% if entry.date or entry.account %} {% if line_item.date or line_item.account %}
<div class="text-muted small"> <div class="text-muted small">
{% if entry.date %} {% if line_item.date %}
{{ entry.date|accounting_format_date }} {{ line_item.date|accounting_format_date }}
{% endif %} {% endif %}
{% if entry.account %} {% if line_item.account %}
{{ entry.account.title|title }} {{ line_item.account.title|title }}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% if entry.summary %} {% if line_item.description %}
<div>{{ entry.summary }}</div> <div>{{ line_item.description }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="text-nowrap"> <div class="text-nowrap">
{% if entry.income %} {% if line_item.income %}
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span> <span class="badge rounded-pill bg-success">+{{ line_item.income|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if entry.expense %} {% if line_item.expense %}
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ line_item.expense|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if entry.balance < 0 %} {% if line_item.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
{% else %} {% else %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %} {% endif %}
</div> </div>

View File

@ -19,8 +19,10 @@ ledger-row-desktop.html: The row in the ledger for the desktop computers
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div>{{ entry.date|accounting_format_date }}</div> <div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.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 line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
{% endif %}

View File

@ -20,22 +20,24 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5 First written: 2023/3/5
#} #}
<div> <div>
{% if entry.date %} {% if line_item.date %}
<div class="text-muted small"> <div class="text-muted small">
{{ entry.date|accounting_format_date }} {{ line_item.date|accounting_format_date }}
</div> </div>
{% endif %} {% endif %}
{% if entry.summary %} {% if line_item.description %}
<div>{{ entry.summary }}</div> <div>{{ line_item.description }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
{% if entry.debit %} {% if line_item.debit %}
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if entry.credit %} {% if line_item.credit %}
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
{% endif %}
{% if report.account.is_real %}
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %} {% endif %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
</div> </div>

View File

@ -55,14 +55,14 @@ First written: 2023/3/4
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab"> <div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
<div> <div>
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}"> <a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
{{ A_("This month") }} {{ A_("This Month") }}
</a> </a>
{% if report.period_chooser.has_last_month %} {% if report.period_chooser.has_last_month %}
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}"> <a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
{{ A_("Last month") }} {{ A_("Last Month") }}
</a> </a>
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}"> <a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
{{ A_("Since last month") }} {{ A_("Since Last Month") }}
</a> </a>
{% endif %} {% endif %}
</div> </div>
@ -74,11 +74,11 @@ First written: 2023/3/4
{# The year periods #} {# The year periods #}
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab"> <div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}"> <a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
{{ A_("This year") }} {{ A_("This Year") }}
</a> </a>
{% if report.period_chooser.has_last_year %} {% if report.period_chooser.has_last_year %}
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}"> <a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
{{ A_("Last year") }} {{ A_("Last Year") }}
</a> </a>
{% endif %} {% endif %}
{% if report.period_chooser.available_years %} {% if report.period_chooser.available_years %}

View File

@ -27,17 +27,17 @@ First written: 2023/3/8
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li> <li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}"> <a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash Expense") }} {{ A_("Cash Disbursement") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}"> <a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash Income") }} {{ A_("Cash Receipt") }}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}"> <a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }} {{ A_("Transfer") }}
</a> </a>
</li> </li>

View File

@ -38,7 +38,7 @@ First written: 2023/3/5
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}
@ -54,7 +54,7 @@ First written: 2023/3/5
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div> <div>{{ A_("Date") }}</div>
<div>{{ A_("Account") }}</div> <div>{{ A_("Account") }}</div>
<div>{{ A_("Summary") }}</div> <div>{{ A_("Description") }}</div>
<div class="accounting-amount">{{ A_("Income") }}</div> <div class="accounting-amount">{{ A_("Income") }}</div>
<div class="accounting-amount">{{ A_("Expense") }}</div> <div class="accounting-amount">{{ A_("Expense") }}</div>
<div class="accounting-amount">{{ A_("Balance") }}</div> <div class="accounting-amount">{{ A_("Balance") }}</div>
@ -62,26 +62,26 @@ First written: 2023/3/5
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with entry = report.brought_forward %} {% with line_item = report.brought_forward %}
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
{% include "accounting/report/include/income-expenses-row-desktop.html" %} {% include "accounting/report/include/income-expenses-row-desktop.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-desktop.html" %} {% include "accounting/report/include/income-expenses-row-desktop.html" %}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = report.total %}
<div class="accounting-report-table-footer"> <div class="accounting-report-table-footer">
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div> <div class="accounting-amount">{{ line_item.income|accounting_format_amount }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div> <div class="accounting-amount">{{ line_item.expense|accounting_format_amount }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
@ -90,19 +90,19 @@ First written: 2023/3/5
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with entry = report.brought_forward %} {% with line_item = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-row-mobile.html" %} {% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}"> <a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-mobile.html" %} {% include "accounting/report/include/income-expenses-row-mobile.html" %}
</a> </a>
{% endfor %} {% endfor %}
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/income-expenses-row-mobile.html" %} {% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div> </div>

View File

@ -37,7 +37,7 @@ First written: 2023/3/7
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}

View File

@ -36,7 +36,7 @@ First written: 2023/3/4
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}
@ -53,47 +53,47 @@ First written: 2023/3/4
<div>{{ A_("Date") }}</div> <div>{{ A_("Date") }}</div>
<div>{{ A_("Currency") }}</div> <div>{{ A_("Currency") }}</div>
<div>{{ A_("Account") }}</div> <div>{{ A_("Account") }}</div>
<div>{{ A_("Summary") }}</div> <div>{{ A_("Description") }}</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> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div> <div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div> <div>{{ line_item.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ entry.account.title|title }} {{ line_item.account.title|title }}
</div> </div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }} {{ line_item.journal_entry.date|accounting_format_date }}
{{ entry.account.title|title }} {{ line_item.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %} {% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %} {% endif %}
</div> </div>
{% if entry.summary is not none %} {% if line_item.description is not none %}
<div>{{ entry.summary }}</div> <div>{{ line_item.description }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span> <span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
</div> </div>
</div> </div>
</a> </a>

View File

@ -38,7 +38,7 @@ First written: 2023/3/5
{% endwith %} {% endwith %}
</div> </div>
{% include "accounting/report/include/add-txn-material-fab.html" %} {% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/period-chooser.html" %} {% include "accounting/report/include/period-chooser.html" %}
@ -49,38 +49,42 @@ 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_("Description") }}</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>
{% if report.account.is_real %}
<div class="accounting-amount">{{ A_("Balance") }}</div> <div class="accounting-amount">{{ A_("Balance") }}</div>
{% endif %}
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with entry = report.brought_forward %} {% with line_item = report.brought_forward %}
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
{% include "accounting/report/include/ledger-row-desktop.html" %} {% include "accounting/report/include/ledger-row-desktop.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-desktop.html" %} {% include "accounting/report/include/ledger-row-desktop.html" %}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = report.total %}
<div class="accounting-report-table-footer"> <div class="accounting-report-table-footer">
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.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 line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
{% endif %}
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
@ -89,19 +93,19 @@ First written: 2023/3/5
<div class="list-group d-md-none"> <div class="list-group d-md-none">
{% if report.brought_forward %} {% if report.brought_forward %}
{% with entry = report.brought_forward %} {% with line_item = report.brought_forward %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-row-mobile.html" %} {% include "accounting/report/include/ledger-row-mobile.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}"> <a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-mobile.html" %} {% include "accounting/report/include/ledger-row-mobile.html" %}
</a> </a>
{% endfor %} {% endfor %}
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = report.total %}
<div class="list-group-item list-group-item-action d-flex justify-content-between"> <div class="list-group-item list-group-item-action d-flex justify-content-between">
{% include "accounting/report/include/ledger-row-mobile.html" %} {% include "accounting/report/include/ledger-row-mobile.html" %}
</div> </div>

Some files were not shown because too many files have changed in this diff Show More